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 (
+ <>
+
+
+
+
+
+
+
+ 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 (
+
+ )
+}
diff --git a/src/app/repo/modules/SyncRepoDialog.tsx b/src/app/repo/modules/SyncRepoDialog.tsx
new file mode 100644
index 0000000..4ee6260
--- /dev/null
+++ b/src/app/repo/modules/SyncRepoDialog.tsx
@@ -0,0 +1,85 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { useRepoStore } from '../store'
+import { useState, useEffect } from 'react'
+import { get, set } from 'idb-keyval'
+
+const SYNC_REPO_STORAGE_KEY = 'sync-repo-mapping'
+
+export function SyncRepoDialog() {
+ const { syncDialogOpen, setSyncDialogOpen, selectedSyncRepo, buildSync } = useRepoStore()
+ const [toRepo, setToRepo] = useState('')
+
+ useEffect(() => {
+ const loadSavedMapping = async () => {
+ if (syncDialogOpen && selectedSyncRepo) {
+ const currentPath = selectedSyncRepo.path || ''
+ // 从 idb-keyval 获取存储的映射
+ const mapping = await get>(SYNC_REPO_STORAGE_KEY)
+ // 如果有存储的值,使用存储的值,否则使用当前仓库路径
+ setToRepo(mapping?.[currentPath] || currentPath)
+ }
+ }
+ loadSavedMapping()
+ }, [syncDialogOpen, selectedSyncRepo])
+
+ const handleSync = async () => {
+ if (!selectedSyncRepo || !toRepo.trim()) {
+ return
+ }
+
+ // 保存映射到 idb-keyval
+ const currentPath = selectedSyncRepo.path || ''
+ const mapping = await get>(SYNC_REPO_STORAGE_KEY) || {}
+ mapping[currentPath] = toRepo
+ await set(SYNC_REPO_STORAGE_KEY, mapping)
+
+ await buildSync(selectedSyncRepo, { toRepo })
+ setSyncDialogOpen(false)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/app/repo/modules/WorkspaceDetailDialog.tsx b/src/app/repo/modules/WorkspaceDetailDialog.tsx
new file mode 100644
index 0000000..f5a960a
--- /dev/null
+++ b/src/app/repo/modules/WorkspaceDetailDialog.tsx
@@ -0,0 +1,185 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { useRepoStore } from '../store'
+import type { WorkspaceOpen } from '../store'
+import {
+ Code2,
+ Terminal,
+ Sparkles,
+ MousePointer2,
+ Box,
+ Lock,
+ Radio,
+ Bot,
+ Zap,
+ Copy,
+ Check
+} from 'lucide-react'
+import { useState } from 'react'
+import { toast } from 'sonner'
+
+type LinkItemKey = keyof WorkspaceOpen;
+interface LinkItem {
+ key: LinkItemKey
+ label: string
+ icon: React.ReactNode
+ order?: number
+ getUrl: (data: Partial) => string | undefined
+}
+
+const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => {
+ const [isHovered, setIsHovered] = useState(false)
+ const [isCopied, setIsCopied] = useState(false)
+
+ const handleClick = () => {
+ if (url?.startsWith?.('ssh') || url?.startsWith?.('cnb')) {
+ copy()
+ return;
+ }
+ if (url) {
+ window.open(url, '_blank')
+ }
+ }
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(url!)
+ setIsCopied(true)
+ toast.success('已复制到剪贴板')
+ setTimeout(() => setIsCopied(false), 2000)
+ } catch (error) {
+ toast.error('复制失败')
+ }
+ }
+ const handleCopy = async (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (!url) return
+ copy()
+ }
+
+ return (
+
+ )
+}
+
+export function WorkspaceDetailDialog() {
+ const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink } = useRepoStore()
+
+ const linkItems: LinkItem[] = [
+ {
+ key: 'webide' as LinkItemKey,
+ label: 'Web IDE',
+ icon: ,
+ order: 1,
+ getUrl: (data) => data.webide
+ },
+ {
+ key: 'vscode' as LinkItemKey,
+ label: 'VS Code',
+ icon: ,
+ order: 2,
+ getUrl: (data) => data.vscode
+ },
+ {
+ key: 'vscode-insiders' as LinkItemKey,
+ label: 'VS Code Insiders',
+ icon: ,
+ order: 5,
+ getUrl: (data) => data['vscode-insiders']
+ },
+ {
+ key: 'cursor' as LinkItemKey,
+ label: 'Cursor',
+ icon: ,
+ order: 6,
+ getUrl: (data) => data.cursor
+ },
+ {
+ key: 'jetbrains' as LinkItemKey,
+ label: 'JetBrains IDEs',
+ icon: ,
+ order: 7,
+ getUrl: (data) => Object.values(data.jetbrains || {}).find(Boolean)
+ },
+ {
+ key: 'ssh' as LinkItemKey,
+ label: 'SSH',
+ icon: ,
+ order: 4,
+ getUrl: (data) => data.ssh
+ },
+ {
+ key: 'remoteSsh' as LinkItemKey,
+ label: 'Remote SSH',
+ icon: ,
+ order: 8,
+ getUrl: (data) => data.remoteSsh
+ },
+ {
+ key: 'codebuddy' as LinkItemKey,
+ label: 'CodeBuddy',
+ icon: ,
+ order: 9,
+ getUrl: (data) => data.codebuddy
+ },
+ {
+ key: 'codebuddycn' as LinkItemKey,
+ label: 'CodeBuddy CN',
+ icon: ,
+ order: 3,
+ getUrl: (data) => data.codebuddycn
+ },
+ ].sort((a, b) => (a.order || 0) - (b.order || 0))
+
+ return (
+
+ )
+}
diff --git a/src/app/repo/page.tsx b/src/app/repo/page.tsx
new file mode 100644
index 0000000..03335c7
--- /dev/null
+++ b/src/app/repo/page.tsx
@@ -0,0 +1,93 @@
+import { useEffect } from 'react'
+import { useRepoStore } from './store/index'
+import { RepoCard } from './components/RepoCard'
+import { EditRepoDialog } from './modules/EditRepoDialog'
+import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
+import { SyncRepoDialog } from './modules/SyncRepoDialog'
+
+export const App = () => {
+ const { list, getList, loading, editRepo, setEditRepo, showEditDialog, setShowEditDialog, startWorkspace, getWorkspaceList, deleteItem, setSelectedSyncRepo, setSyncDialogOpen } = useRepoStore()
+
+ useEffect(() => {
+ getList()
+ getWorkspaceList()
+ }, [])
+
+ const handleEdit = (repo: any) => {
+ setEditRepo(repo)
+ setShowEditDialog(true)
+ }
+
+ const handleIssue = (repo: any) => {
+ window.open(`https://cnb.cool/${repo.path}/-/issues`)
+ }
+
+ const handleSettings = (repo: any) => {
+ window.open(`https://cnb.cool/${repo.path}/-/settings`)
+ }
+
+ const handleDelete = (repo: any) => {
+ if (repo.path)
+ deleteItem(repo.path)
+ }
+
+ const handleSync = (repo: any) => {
+ setSelectedSyncRepo(repo)
+ setSyncDialogOpen(true)
+ }
+
+ return (
+
+
+
+
仓库列表
+
共 {list.length} 个仓库
+
+
+
+ {list.map((repo) => (
+
+ ))}
+
+
+ {list.length === 0 && !loading && (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+export default App;
\ No newline at end of file
diff --git a/src/app/repo/store/build.ts b/src/app/repo/store/build.ts
new file mode 100644
index 0000000..63714a8
--- /dev/null
+++ b/src/app/repo/store/build.ts
@@ -0,0 +1,33 @@
+export const myOrgs = ['kevisual', 'kevision', 'skillpod', 'zxj.im', 'abearxiong']
+
+export const createBuildConfig = (params: { repo: string }) => {
+ const toRepo = params.repo!;
+ return `
+# .cnb.yml
+include:
+ - https://cnb.cool/kevisual/cnb/-/blob/main/.cnb/template.yml
+
+.common_env: &common_env
+ env:
+ TO_REPO: ${toRepo}
+ TO_URL: git.xiongxiao.me
+ imports:
+ - https://cnb.cool/kevisual/env/-/blob/main/.env.development
+
+.common_sync_to_gitea: &common_sync_to_gitea
+ - <<: *common_env
+ services: !reference [.common_sync_to_gitea_template, services]
+ stages: !reference [.common_sync_to_gitea_template, stages]
+
+.common_sync_from_gitea: &common_sync_from_gitea
+ - <<: *common_env
+ services: !reference [.common_sync_from_gitea_template, services]
+ stages: !reference [.common_sync_from_gitea_template, stages]
+
+main:
+ api_trigger_sync_to_gitea:
+ - <<: *common_sync_to_gitea
+ api_trigger_sync_from_gitea:
+ - <<: *common_sync_from_gitea
+`
+};
\ No newline at end of file
diff --git a/src/app/repo/store/index.ts b/src/app/repo/store/index.ts
new file mode 100644
index 0000000..b4db4ba
--- /dev/null
+++ b/src/app/repo/store/index.ts
@@ -0,0 +1,313 @@
+import { create } from 'zustand';
+import { query } from '@/modules/query';
+import { toast } from 'sonner';
+import { cnb } from '@/agents/app'
+import { WorkspaceInfo } from '@kevisual/cnb'
+import { createBuildConfig } from './build';
+interface DisplayModule {
+ activity: boolean;
+ contributors: boolean;
+ release: boolean;
+}
+
+interface Languages {
+ language: string;
+ color: string;
+}
+
+interface Data {
+ id: string;
+ name: string;
+ freeze: boolean;
+ status: number;
+ visibility_level: string;
+ flags: string;
+ created_at: string;
+ updated_at: string;
+ description: string;
+ site: string;
+ topics: string;
+ license: string;
+ display_module: DisplayModule;
+ star_count: number;
+ fork_count: number;
+ mark_count: number;
+ last_updated_at?: string | null;
+ web_url: string;
+ path: string;
+ tags: any;
+ open_issue_count: number;
+ open_pull_request_count: number;
+ languages: Languages;
+ second_languages: Languages;
+ last_update_username: string;
+ last_update_nickname: string;
+ access: string;
+ stared: boolean;
+ star_time: string;
+ pinned: boolean;
+ pinned_time: string;
+}
+
+type State = {
+ formData: Record;
+ setFormData: (data: Record) => void;
+ showEdit: boolean;
+ setShowEdit: (showEdit: boolean) => void;
+ loading: boolean;
+ setLoading: (loading: boolean) => void;
+ list: Data[];
+ editRepo: Data | null;
+ setEditRepo: (repo: Data | null) => void;
+ showEditDialog: boolean;
+ setShowEditDialog: (show: boolean) => void;
+ getList: (silent?: boolean) => Promise;
+ updateRepoInfo: (data: Partial) => Promise;
+ deleteItem: (repo: string) => Promise;
+ workspaceList: WorkspaceInfo[];
+ getWorkspaceList: () => Promise;
+ startWorkspace: (data: Partial, params?: { open?: boolean, branch?: string }) => Promise;
+ getWorkspaceDetail: (data: WorkspaceInfo) => Promise;
+ workspaceLink: Partial;
+ showWorkspaceDialog: boolean;
+ setShowWorkspaceDialog: (show: boolean) => void;
+ syncDialogOpen: boolean;
+ setSyncDialogOpen: (open: boolean) => void;
+ selectedSyncRepo: Data | null;
+ setSelectedSyncRepo: (repo: Data | null) => void;
+ buildSync: (data: Partial, params: { toRepo?: string, fromRepo?: string }) => Promise;
+}
+
+export const useRepoStore = create((set, get) => {
+ return {
+ formData: {},
+ setFormData: (data) => set({ formData: data }),
+ showEdit: false,
+ setShowEdit: (showEdit) => set({ showEdit }),
+ loading: false,
+ setLoading: (loading) => set({ loading }),
+ list: [],
+ editRepo: null,
+ setEditRepo: (repo) => set({ editRepo: repo }),
+ showEditDialog: false,
+ setShowEditDialog: (show) => set({ showEditDialog: show }),
+ showWorkspaceDialog: false,
+ setShowWorkspaceDialog: (show) => set({ showWorkspaceDialog: show }),
+ syncDialogOpen: false,
+ setSyncDialogOpen: (open) => set({ syncDialogOpen: open }),
+ selectedSyncRepo: null,
+ setSelectedSyncRepo: (repo) => set({ selectedSyncRepo: repo }),
+ getItem: async (id) => {
+ const { setLoading } = get();
+ setLoading(true);
+ try {
+ const res = await query.post({
+ path: 'demo',
+ key: 'item',
+ data: { id }
+ })
+ if (res.code === 200) {
+ return res;
+ } else {
+ toast.error(res.message || '请求失败');
+ }
+ } finally {
+ setLoading(false);
+ }
+ },
+ getList: async (silent = false) => {
+ const { setLoading } = get();
+ if (!silent) {
+ setLoading(true);
+ }
+ try {
+ const res = await cnb.repo.getRepoList({})
+ if (res.code === 200) {
+ const list = res.data! || []
+ set({ list });
+ } else {
+ toast.error(res.message || '请求失败');
+ }
+ return res;
+ } finally {
+ if (!silent) {
+ setLoading(false);
+ }
+ }
+ },
+ updateRepoInfo: async (data) => {
+ const repo = data.path!;
+ const topics = data.topics?.split?.(',');
+ const updateData = {
+ description: data.description,
+ license: data?.license as any,
+ site: data.site,
+ topics: topics,
+ }
+ const res = await cnb.repo.updateRepoInfo(repo, updateData)
+ if (res.code === 200) {
+ toast.success('更新成功');
+ } else {
+ toast.error(res.message || '更新失败');
+ }
+ },
+ deleteItem: async (repo: string) => {
+ try {
+ const res = await cnb.repo.deleteRepoCookie(repo)
+ if (res.code === 200) {
+ toast.success('删除成功');
+ // 刷新列表
+ await get().getList(true);
+ } else {
+ toast.error(res.message || '删除失败');
+ }
+ } catch (e: any) {
+ // 如果是 JSON 解析错误,说明删除成功但响应为空
+ if (e.message?.includes('JSON') || e.message?.includes('json')) {
+ toast.success('删除成功');
+ // 刷新列表
+ await get().getList(true);
+ } else {
+ toast.error('删除失败');
+ console.error('删除错误:', e);
+ }
+ }
+ },
+ workspaceList: [],
+ getWorkspaceList: async () => {
+ const res = await cnb.workspace.list({
+ status: 'running',
+ pageSize: 100
+ })
+ if (res.code === 200) {
+ const list: WorkspaceInfo[] = res.data?.list as any;
+ set({ workspaceList: list || [] })
+ } else {
+ toast.error(res.message || '请求失败');
+ }
+ },
+ startWorkspace: async (data, params = { open: true, branch: 'main' }) => {
+ const repo = data.path;
+ const checkOpen = async () => {
+ const res = await cnb.workspace.startWorkspace(repo!, {
+ branch: params.branch || 'main'
+ })
+ if (res.code === 200) {
+ if (!res?.data?.sn) {
+ const url = res.data?.url! || '';
+ if (url.includes('loading')) {
+ return {
+ code: 400,
+ data: res.data
+ }
+ }
+ return {
+ code: 200,
+ data: res.data
+ };
+ }
+ toast.success(`新创建了一个工作区,sn: ${res.data.sn}`)
+ return {
+ code: 300,
+ data: res.data,
+ message: '第一次启动'
+ }
+ } else {
+ return {
+ code: res.code,
+ message: res.message || '请求失败'
+ }
+ }
+ }
+ const res = await checkOpen()
+ if (res.code === 300) {
+ toast.success(`新创建了一个工作区,sn: ${res.data?.sn}`)
+ if (params.open) {
+ const loadingToastId = toast.loading('正在启动工作区...')
+ const interval = setInterval(async () => {
+ const check = await checkOpen()
+ if (check.code === 200 && check.data?.url) {
+ clearInterval(interval)
+ toast.dismiss(loadingToastId)
+ toast.success(`工作区已启动,正在打开: ${check.data.url}`)
+ setTimeout(() => {
+ window.open(check.data?.url, '_blank')
+ }, 200)
+ } else if (check.code === 400) {
+ //
+ }
+ }, 1000);
+
+ // 30秒后自动停止检测
+ setTimeout(() => {
+ clearInterval(interval)
+ toast.dismiss(loadingToastId)
+ toast.error('工作区启动超时')
+ }, 3 * 60 * 1000)
+ }
+ return;
+ }
+ if (res.code === 200 && res.data?.url) {
+ console.log('res', res)
+ toast.success(`工作区已启动,正在打开: ${res.data.url}`)
+ setTimeout(() => {
+ window.open(res.data?.url, '_blank')
+ }, 200)
+ } else {
+ toast.error(res.message || '启动失败');
+ }
+ return res;
+ },
+ getWorkspaceDetail: async (workspaceInfo) => {
+ const res = await cnb.workspace.getDetail(workspaceInfo.slug, workspaceInfo.sn) as any;
+ if (res.code === 200) {
+ set({
+ workspaceLink: res.data,
+ showWorkspaceDialog: true
+ })
+ }
+ },
+ workspaceLink: {},
+ buildSync: async (data, params) => {
+ const repo = data.path!;
+ const toRepo = params.toRepo;
+ const fromRepo = params.fromRepo;
+ if (!toRepo && !fromRepo) {
+ toast.error('请选择同步的目标仓库或来源仓库')
+ return;
+ }
+ let event = toRepo ? 'api_trigger_sync_to_gitea' : 'api_trigger_sync_from_gitea';
+ const res = await cnb.build.startBuild(repo, {
+ branch: 'main',
+ env: {},
+ event: event,
+ config: createBuildConfig({ repo: toRepo! || fromRepo! }),
+ })
+ if (res.code === 200) {
+ toast.success('同步提交成功')
+ } else {
+ toast.error(res.message || '同步提交失败')
+ }
+ }
+ }
+})
+
+export type WorkspaceOpen = {
+ codebuddy: string;
+ codebuddycn: string;
+ cursor: string;
+ jetbrains: Record;
+ jumpUrl: string;
+ remoteSsh: string;
+ ssh: string;
+ vscode: string;
+ 'vscode-insiders': string;
+ webide: string;
+}
+const openWorkspace = (workspace: WorkspaceInfo, params: { vscode?: boolean, ssh?: boolean }) => {
+ const openVsCode = params?.vscode ?? true;
+ if (openVsCode) {
+ const pipeline_id = workspace.pipeline_id;
+ const url = `vscode://vscode-remote/ssh-remote+${pipeline_id}`
+ }
+}
\ No newline at end of file
diff --git a/src/app/store/index.ts b/src/app/store/index.ts
new file mode 100644
index 0000000..af2993c
--- /dev/null
+++ b/src/app/store/index.ts
@@ -0,0 +1,130 @@
+import { create } from 'zustand';
+import { query } from '@/modules/query';
+import { toast } from 'sonner';
+import { cnb } from '@/agents/app'
+
+interface DisplayModule {
+ activity: boolean;
+ contributors: boolean;
+ release: boolean;
+}
+
+interface Languages {
+ language: string;
+ color: string;
+}
+
+interface Data {
+ id: string;
+ name: string;
+ freeze: boolean;
+ status: number;
+ visibility_level: string;
+ flags: string;
+ created_at: string;
+ updated_at: string;
+ description: string;
+ site: string;
+ topics: string;
+ license: string;
+ display_module: DisplayModule;
+ star_count: number;
+ fork_count: number;
+ mark_count: number;
+ last_updated_at?: string | null;
+ web_url: string;
+ path: string;
+ tags: any;
+ open_issue_count: number;
+ open_pull_request_count: number;
+ languages: Languages;
+ second_languages: Languages;
+ last_update_username: string;
+ last_update_nickname: string;
+ access: string;
+ stared: boolean;
+ star_time: string;
+ pinned: boolean;
+ pinned_time: string;
+}
+
+type State = {
+ formData: Record;
+ setFormData: (data: Record) => void;
+ showEdit: boolean;
+ setShowEdit: (showEdit: boolean) => void;
+ loading: boolean;
+ setLoading: (loading: boolean) => void;
+ list: Data[];
+ editRepo: Data | null;
+ setEditRepo: (repo: Data | null) => void;
+ showEditDialog: boolean;
+ setShowEditDialog: (show: boolean) => void;
+ getList: () => Promise;
+ updateRepoInfo: (data: Partial) => Promise;
+}
+
+export const useRepoStore = create((set, get) => {
+ return {
+ formData: {},
+ setFormData: (data) => set({ formData: data }),
+ showEdit: false,
+ setShowEdit: (showEdit) => set({ showEdit }),
+ loading: false,
+ setLoading: (loading) => set({ loading }),
+ list: [],
+ editRepo: null,
+ setEditRepo: (repo) => set({ editRepo: repo }),
+ showEditDialog: false,
+ setShowEditDialog: (show) => set({ showEditDialog: show }),
+ getItem: async (id) => {
+ const { setLoading } = get();
+ setLoading(true);
+ try {
+ const res = await query.post({
+ path: 'demo',
+ key: 'item',
+ data: { id }
+ })
+ if (res.code === 200) {
+ return res;
+ } else {
+ toast.error(res.message || '请求失败');
+ }
+ } finally {
+ setLoading(false);
+ }
+ },
+ getList: async () => {
+ const { setLoading } = get();
+ setLoading(true);
+ try {
+ const res = await cnb.repo.getRepoList({})
+ if (res.code === 200) {
+ const list = res.data! || []
+ set({ list });
+ } else {
+ toast.error(res.message || '请求失败');
+ }
+ return res;
+ } finally {
+ setLoading(false);
+ }
+ },
+ updateRepoInfo: async (data) => {
+ const repo = data.path!;
+ const updateData = {
+ description: data.description,
+ license: data?.license as any,
+ site: data.site,
+ topics: data.topics?.split?.(','),
+ }
+ const res = await cnb.repo.updateRepoInfo(repo, updateData)
+ if (res.code === 200) {
+ toast.success('更新成功');
+ } else {
+ toast.error(res.message || '更新失败');
+ }
+ },
+ }
+})
\ No newline at end of file
diff --git a/src/components/tags-input.tsx b/src/components/tags-input.tsx
new file mode 100644
index 0000000..494e552
--- /dev/null
+++ b/src/components/tags-input.tsx
@@ -0,0 +1,78 @@
+import * as React from "react"
+import { X } from "lucide-react"
+import { Badge } from "./ui/badge"
+import { Input } from "./ui/input"
+import { cn } from "@/lib/utils"
+
+interface TagsInputProps {
+ value: string[]
+ onChange: (tags: string[]) => void
+ placeholder?: string
+ className?: string
+}
+
+export function TagsInput({ value, onChange, placeholder, className }: TagsInputProps) {
+ const [inputValue, setInputValue] = React.useState("")
+ const inputRef = React.useRef(null)
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === ",") {
+ e.preventDefault()
+ addTag()
+ } else if (e.key === "Backspace" && !inputValue && value.length > 0) {
+ removeTag(value.length - 1)
+ }
+ }
+
+ const addTag = () => {
+ const trimmedValue = inputValue.trim()
+ if (trimmedValue && !value.includes(trimmedValue)) {
+ onChange([...value, trimmedValue])
+ setInputValue("")
+ }
+ }
+
+ const removeTag = (index: number) => {
+ onChange(value.filter((_, i) => i !== index))
+ }
+
+ return (
+ inputRef.current?.focus()}
+ >
+ {value.map((tag, index) => (
+
+ {tag}
+
+
+ ))}
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={addTag}
+ placeholder={value.length === 0 ? placeholder : ""}
+ className="flex-1 min-w-[150px] border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-7 placeholder:text-neutral-400"
+ />
+
+ )
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index c509160..83d0a90 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -30,7 +30,7 @@ function DialogOverlay({
return (
)
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..53a798b
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,88 @@
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick<
+ PopoverPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..668fd71
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..e457d08
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,64 @@
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 5a7ad57..f570f97 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -1,14 +1,24 @@
-import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
+import { Link, Outlet, createRootRoute, useNavigate } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { Toaster } from '@/components/ui/sonner'
-
+import { useConfigStore } from '@/app/config/store'
+import { useEffect } from 'react'
export const Route = createRootRoute({
component: RootComponent,
})
function RootComponent() {
+ const navigate = useNavigate()
+ useEffect(() => {
+ const config = useConfigStore.getState().config;
+ if (!config.CNB_API_KEY) {
+ navigate({
+ to: '/config'
+ })
+ }
+ }, [])
return (
-
+
- Home
+ 仓库列表
+
+
+ 配置项
-
+
+
+
+
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index af2f49e..fb5c5df 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,9 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
-import Home from '@/app/page'
+import App from '@/app/page'
export const Route = createFileRoute('/')({
component: RouteComponent,
})
function RouteComponent() {
- return
+ return
}
\ No newline at end of file