初始化模版工程
This commit is contained in:
7
components/nova-sdk/tools/components/index.ts
Normal file
7
components/nova-sdk/tools/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Skill Form
|
||||
export { SkillForm } from './skill-form'
|
||||
export type { SkillFormState } from './skill-form'
|
||||
|
||||
// MCP Components
|
||||
export { MCPJsonEditor } from './mcp-json-editor'
|
||||
export type { MCPJsonEditorRef } from './mcp-json-editor'
|
||||
148
components/nova-sdk/tools/components/mcp-json-editor.tsx
Normal file
148
components/nova-sdk/tools/components/mcp-json-editor.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useMemoizedFn } from 'ahooks'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MCPJsonEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSave?: (parsedConfig: Record<string, unknown>) => void | Promise<void>
|
||||
showButtons?: boolean
|
||||
}
|
||||
|
||||
export interface MCPJsonEditorRef {
|
||||
triggerSave: () => Promise<void>
|
||||
}
|
||||
|
||||
export const MCPJsonEditor = forwardRef<MCPJsonEditorRef, MCPJsonEditorProps>(
|
||||
({ value, onChange, onSave, showButtons = true }, ref) => {
|
||||
const [jsonValue, setJsonValue] = useState(value)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const parseJsonToConfig = useMemoizedFn(async () => {
|
||||
if (isSaving) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = JSON.parse(jsonValue)
|
||||
const { mcpServers = {} as Record<string, unknown> } = res
|
||||
const mcpServer = Object.entries(mcpServers)[0]
|
||||
|
||||
if (!mcpServer) {
|
||||
toast.error('未检测到合法的MCP配置')
|
||||
return
|
||||
}
|
||||
|
||||
const [key, value] = mcpServer as [string, Record<string, unknown>]
|
||||
const code = key
|
||||
const command = value.command ?? ''
|
||||
const args = value.args ?? []
|
||||
const env = value.env ?? {}
|
||||
const url = value.url ?? ''
|
||||
|
||||
if ((!url && !command) || !code) {
|
||||
toast.error('非法配置')
|
||||
return
|
||||
}
|
||||
|
||||
const type = command ? 'command' : 'sse'
|
||||
const argsString = (args as string[]).join(' ')
|
||||
const envString = Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
if (
|
||||
type === 'command' &&
|
||||
!['npx', 'uv', 'uvx'].includes(value.command as string ?? '')
|
||||
) {
|
||||
toast.error(`非法配置,不支持的命令: ${value.command}`)
|
||||
return
|
||||
}
|
||||
|
||||
const parsedConfig = {
|
||||
type,
|
||||
command,
|
||||
code,
|
||||
name: code,
|
||||
args: argsString,
|
||||
env: envString,
|
||||
url,
|
||||
headers: value.headers ?? '',
|
||||
}
|
||||
|
||||
onChange(jsonValue)
|
||||
await onSave?.(parsedConfig)
|
||||
} catch {
|
||||
toast.error('解析失败,请检查JSON格式')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
})
|
||||
|
||||
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
setJsonValue(newValue)
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
triggerSave: parseJsonToConfig,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col' style={{ pointerEvents: 'auto' }}>
|
||||
<div className='flex-1 flex flex-col' style={{ pointerEvents: 'auto' }}>
|
||||
<Textarea
|
||||
className='flex-1 rounded-xl border border-input bg-card focus:border-primary/40 focus:ring-2 focus:ring-ring'
|
||||
style={{
|
||||
resize: 'none',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", consolas, "source-code-pro", monospace',
|
||||
minHeight: '300px',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 1,
|
||||
position: 'relative',
|
||||
cursor: 'text',
|
||||
caretColor: 'var(--primary)',
|
||||
outline: 'none',
|
||||
userSelect: 'text',
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={`仅支持解析一个MCP,如果有多个仅解析第一个,示例:
|
||||
{
|
||||
"mcpServers": {
|
||||
"RedNote MCP": {
|
||||
"command": "npx",
|
||||
"args": ["rednote-mcp", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
或者 SSE 方式:
|
||||
{
|
||||
"mcpServers": {
|
||||
"Weather MCP": {
|
||||
"url": "http://localhost:8000/mcp"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
value={jsonValue}
|
||||
onChange={handleJsonChange}
|
||||
/>
|
||||
</div>
|
||||
{showButtons && (
|
||||
<div className='shrink-0 pt-3 flex items-center justify-end gap-3'>
|
||||
<Button onClick={parseJsonToConfig} disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='h-4 w-4 animate-spin' />}
|
||||
{isSaving ? '解析中...' : '解析并保存'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
MCPJsonEditor.displayName = 'MCPJsonEditor'
|
||||
147
components/nova-sdk/tools/components/skill-form.tsx
Normal file
147
components/nova-sdk/tools/components/skill-form.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useMemoizedFn } from 'ahooks'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface SkillFormState {
|
||||
name: string
|
||||
description?: string
|
||||
file?: { file: File; fileList: File[] }
|
||||
}
|
||||
|
||||
interface SkillFormProps {
|
||||
values?: { name: string; description?: string }
|
||||
className?: string
|
||||
teamId: string
|
||||
onBack: () => void
|
||||
onSave: (values: SkillFormState) => Promise<void>
|
||||
}
|
||||
|
||||
const name_required_text = '请输入SKILL工具名称'
|
||||
const file_tooltip_text = '仅支持上传 .zip 文件'
|
||||
|
||||
export const SkillForm = memo<SkillFormProps>(
|
||||
({ className, values, onBack, onSave }) => {
|
||||
const [name, setName] = useState(values?.name || '')
|
||||
const [description, setDescription] = useState(values?.description || '')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const isEdit = !!values
|
||||
|
||||
useEffect(() => {
|
||||
if (values) {
|
||||
setName(values.name)
|
||||
setDescription(values.description || '')
|
||||
}
|
||||
}, [values])
|
||||
|
||||
const handleSave = useMemoizedFn(async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('错误')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEdit && !file) {
|
||||
toast.error('错误')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await onSave({
|
||||
name,
|
||||
description,
|
||||
file: file ? { file: file as File, fileList: [file as File] } : undefined
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
const isZip = selectedFile.type === 'application/zip' || selectedFile.name.endsWith('.zip')
|
||||
if (!isZip) {
|
||||
toast.error('错误')
|
||||
return
|
||||
}
|
||||
setFile(selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-transparent text-foreground'>
|
||||
<div className={cn('flex-1 p-24px overflow-y-auto space-y-6', className)}>
|
||||
<div className='space-y-2 px-[4px]'>
|
||||
<Label htmlFor='name' className='mb-[] text-sm font-medium text-foreground'>名称</Label>
|
||||
<Input
|
||||
id='name'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={name_required_text}
|
||||
maxLength={100}
|
||||
className='mt-[8px] border-border bg-card text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2 px-[4px]'>
|
||||
<Label htmlFor='description' className='text-sm font-medium text-foreground'>描述</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder='请输入SKILL工具的描述'
|
||||
rows={3}
|
||||
className='mt-[8px] min-h-[120px] resize-y border-border bg-card text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2 px-[4px]'>
|
||||
<Label htmlFor='file' className='text-sm font-medium text-foreground'>文件</Label>
|
||||
<div className='relative mt-[8px] cursor-pointer rounded-lg border-2 border-dashed border-border bg-card p-8 text-center transition-colors hover:border-primary'>
|
||||
<Inbox className='mx-auto mb-4 h-12 w-12 cursor-pointer text-muted-foreground' />
|
||||
<input
|
||||
id='file'
|
||||
type='file'
|
||||
accept='.zip,application/zip'
|
||||
onChange={handleFileChange}
|
||||
className='opacity-0 absolute top-0 left-0 w-full h-full'
|
||||
/>
|
||||
<label htmlFor='file' className='cursor-pointer'>
|
||||
<p className='text-sm text-foreground'>
|
||||
{file ? file.name : '点击或拖拽文件到这里上传'}
|
||||
</p>
|
||||
<p className='mt-2 text-xs text-muted-foreground'>{file_tooltip_text}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[16px] flex justify-end gap-8px border-t border-border/70 bg-transparent px-24px pb-24px pt-16px'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={onBack}
|
||||
className='mr-[8px] hover:bg-transparent hover:border-border hover:text-foreground'
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SkillForm.displayName = 'SkillForm'
|
||||
Reference in New Issue
Block a user