|
|
@@ -1,12 +1,13 @@
|
|
|
import { useState, useEffect } from 'react'
|
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
-import { ArrowLeft, Save, X, Database, CheckSquare, Square } from 'lucide-react'
|
|
|
+import { ArrowLeft, Save, X, Database, CheckSquare, Square, FolderOpen } from 'lucide-react'
|
|
|
import toast from 'react-hot-toast'
|
|
|
import { agentsApi } from '../api/agents'
|
|
|
import { knowledgeApi } from '../api/knowledge'
|
|
|
import { Button, Input, Textarea, Card, PageHeader, Spinner, Tabs } from '../components/ui'
|
|
|
import { clsx } from '../lib/clsx'
|
|
|
+import DirPickerModal from '../components/DirPickerModal'
|
|
|
|
|
|
// ── Tool tag editor (same as AgentImport) ────────────────────────
|
|
|
|
|
|
@@ -62,6 +63,7 @@ const TABS = [
|
|
|
{ id: 'model', label: '模型配置' },
|
|
|
{ id: 'behavior', label: '行为配置' },
|
|
|
{ id: 'memory', label: '记忆 & 运行时' },
|
|
|
+ { id: 'guard', label: '验收 & 安全' },
|
|
|
{ id: 'tools', label: '工具列表' },
|
|
|
{ id: 'prompt', label: '系统提示词' },
|
|
|
{ id: 'kb', label: '知识库' },
|
|
|
@@ -81,6 +83,11 @@ export default function AgentEdit() {
|
|
|
const [description, setDescription] = useState('')
|
|
|
const [agentType, setAgentType] = useState('react')
|
|
|
const [agentDir, setAgentDir] = useState('')
|
|
|
+ const [workDir, setWorkDir] = useState('')
|
|
|
+ const [sourceDir, setSourceDir] = useState('')
|
|
|
+ const [runDir, setRunDir] = useState('')
|
|
|
+ // which dir picker is open: 'work' | 'source' | 'run' | null
|
|
|
+ const [dirPickerFor, setDirPickerFor] = useState<'work' | 'source' | 'run' | null>(null)
|
|
|
|
|
|
// model
|
|
|
const [provider, setProvider] = useState('')
|
|
|
@@ -101,10 +108,42 @@ export default function AgentEdit() {
|
|
|
const [runtimeEngine, setRuntimeEngine] = useState('cek')
|
|
|
const [costBudget, setCostBudget] = useState('0.5')
|
|
|
|
|
|
+ // guard (验收 & 安全) — agent-level output validation + tool safety
|
|
|
+ const [guardEnabled, setGuardEnabled] = useState(false)
|
|
|
+ const [guardValidator, setGuardValidator] = useState('')
|
|
|
+ const [guardRetry, setGuardRetry] = useState('0')
|
|
|
+ const [guardFallback, setGuardFallback] = useState('error')
|
|
|
+ const [guardBlockDangerous, setGuardBlockDangerous] = useState(true)
|
|
|
+ const [guardHighRiskConfirm, setGuardHighRiskConfirm] = useState(false)
|
|
|
+ const [guardMaxOutput, setGuardMaxOutput] = useState('0')
|
|
|
+
|
|
|
// tools & prompt
|
|
|
const [localTools, setLocalTools] = useState<string[]>([])
|
|
|
const [systemPrompt, setSystemPrompt] = useState('')
|
|
|
|
|
|
+ // sub-agents (multi-agent orchestrator). Read-only view: each entry maps a
|
|
|
+ // sub-agent name to { tool: call_*, config: ./path.yml } or an inline def.
|
|
|
+ const [subAgents, setSubAgents] = useState<Record<string, unknown>>({})
|
|
|
+ // Clicking a sub-agent loads & shows its config file. Cache keyed by name.
|
|
|
+ const [openSub, setOpenSub] = useState<string | null>(null)
|
|
|
+ const [subConfigs, setSubConfigs] = useState<Record<string, {
|
|
|
+ loading: boolean; content?: string; path?: string | null; error?: string
|
|
|
+ }>>({})
|
|
|
+
|
|
|
+ const toggleSub = async (name: string) => {
|
|
|
+ if (openSub === name) { setOpenSub(null); return }
|
|
|
+ setOpenSub(name)
|
|
|
+ if (subConfigs[name]?.content !== undefined || subConfigs[name]?.error) return
|
|
|
+ setSubConfigs(p => ({ ...p, [name]: { loading: true } }))
|
|
|
+ try {
|
|
|
+ const r = await agentsApi.subAgentConfig(agentId!, name)
|
|
|
+ setSubConfigs(p => ({ ...p, [name]: { loading: false, content: r.content, path: r.path } }))
|
|
|
+ } catch (e) {
|
|
|
+ const msg = (e as Error)?.message || String(e)
|
|
|
+ setSubConfigs(p => ({ ...p, [name]: { loading: false, error: msg } }))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// knowledge bases
|
|
|
const [linkedKBs, setLinkedKBs] = useState<string[]>([])
|
|
|
const [kbSearchMode, setKbSearchMode] = useState('bm25')
|
|
|
@@ -129,6 +168,9 @@ export default function AgentEdit() {
|
|
|
setDescription(agent.description ?? '')
|
|
|
setAgentType((cfg.type as string) ?? 'react')
|
|
|
setAgentDir(agent.agent_dir ?? '')
|
|
|
+ setWorkDir(agent.work_dir ?? '')
|
|
|
+ setSourceDir(agent.source_dir ?? '')
|
|
|
+ setRunDir(agent.run_dir ?? '')
|
|
|
|
|
|
setProvider((m.provider as string) ?? '')
|
|
|
setModelName((m.name as string) ?? '')
|
|
|
@@ -147,8 +189,23 @@ export default function AgentEdit() {
|
|
|
setRuntimeEngine((rt.engine as string) ?? 'cek')
|
|
|
setCostBudget(String(rt.costBudget ?? 0.5))
|
|
|
|
|
|
+ const g = (cfg.guard as Record<string, unknown>) ?? {}
|
|
|
+ setGuardEnabled(Object.keys(g).length > 0)
|
|
|
+ setGuardValidator((g.validator as string) ?? '')
|
|
|
+ setGuardRetry(String(g.retry ?? 0))
|
|
|
+ setGuardFallback((g.fallback as string) ?? 'error')
|
|
|
+ setGuardBlockDangerous(g.dangerousCommandBlock !== false) // default true
|
|
|
+ setGuardHighRiskConfirm(Boolean(g.highRiskConfirmation))
|
|
|
+ setGuardMaxOutput(String(g.maxOutputLength ?? 0))
|
|
|
+
|
|
|
setLocalTools((mcp.localTools as string[]) ?? [])
|
|
|
setSystemPrompt((cfg.systemPrompt as string) ?? '')
|
|
|
+ // Compiler accepts both `subAgents` (camelCase) and `sub_agents`.
|
|
|
+ setSubAgents(
|
|
|
+ (cfg.subAgents as Record<string, unknown>) ??
|
|
|
+ (cfg.sub_agents as Record<string, unknown>) ??
|
|
|
+ {},
|
|
|
+ )
|
|
|
setLinkedKBs(agent.kb_ids ?? [])
|
|
|
setKbSearchMode(agent.kb_search_mode ?? 'bm25')
|
|
|
setReady(true)
|
|
|
@@ -187,9 +244,26 @@ export default function AgentEdit() {
|
|
|
}
|
|
|
cfg.mcp = { ...((cfg.mcp as object) ?? {}), localTools }
|
|
|
|
|
|
+ if (guardEnabled) {
|
|
|
+ cfg.guard = {
|
|
|
+ ...((cfg.guard as object) ?? {}),
|
|
|
+ ...(guardValidator.trim() ? { validator: guardValidator.trim() } : {}),
|
|
|
+ retry: parseInt(guardRetry) || 0,
|
|
|
+ fallback: guardFallback,
|
|
|
+ dangerousCommandBlock: guardBlockDangerous,
|
|
|
+ highRiskConfirmation: guardHighRiskConfirm,
|
|
|
+ maxOutputLength: parseInt(guardMaxOutput) || 0,
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ delete cfg.guard
|
|
|
+ }
|
|
|
+
|
|
|
return agentsApi.update(agentId!, {
|
|
|
- config: cfg,
|
|
|
- agent_dir: agentDir,
|
|
|
+ config: cfg,
|
|
|
+ agent_dir: agentDir,
|
|
|
+ work_dir: workDir,
|
|
|
+ source_dir: sourceDir,
|
|
|
+ run_dir: runDir,
|
|
|
changelog: `Web UI 编辑 — ${new Date().toLocaleString('zh-CN')}`,
|
|
|
})
|
|
|
},
|
|
|
@@ -240,7 +314,19 @@ export default function AgentEdit() {
|
|
|
</div>
|
|
|
|
|
|
<Card>
|
|
|
- <Tabs tabs={TABS} active={tab} onChange={setTab} />
|
|
|
+ <Tabs
|
|
|
+ tabs={(() => {
|
|
|
+ if (Object.keys(subAgents).length === 0) return TABS
|
|
|
+ // Insert the read-only 子智能体 tab right AFTER 工具列表 (sub-agents
|
|
|
+ // are compiled into call_* tools, so they belong next to tools).
|
|
|
+ // Fall back to before 知识库 if the tools tab is ever renamed.
|
|
|
+ const after = TABS.findIndex(t => t.id === 'tools')
|
|
|
+ const i = after >= 0 ? after + 1 : TABS.findIndex(t => t.id === 'kb')
|
|
|
+ return [...TABS.slice(0, i), { id: 'subagents', label: '子智能体' }, ...TABS.slice(i)]
|
|
|
+ })()}
|
|
|
+ active={tab}
|
|
|
+ onChange={setTab}
|
|
|
+ />
|
|
|
|
|
|
{/* ── 基本设置 ── */}
|
|
|
{tab === 'basic' && (
|
|
|
@@ -257,12 +343,49 @@ export default function AgentEdit() {
|
|
|
<option value="groupchat">多智能体群聊</option>
|
|
|
</select>
|
|
|
</div>
|
|
|
+ {/* ── 三目录配置 ── */}
|
|
|
+ <div className="space-y-3 rounded-xl border border-indigo-100 bg-indigo-50/40 p-4">
|
|
|
+ <p className="text-xs font-semibold text-indigo-700 uppercase tracking-wide">目录配置</p>
|
|
|
+
|
|
|
+ {/* source_dir */}
|
|
|
+ <DirField
|
|
|
+ label="① 数据来源目录"
|
|
|
+ hint="只读输入数据所在位置;智能体启动时自动提示从此处读取文件"
|
|
|
+ value={sourceDir}
|
|
|
+ onChange={setSourceDir}
|
|
|
+ onBrowse={() => setDirPickerFor('source')}
|
|
|
+ placeholder="/Users/me/papers"
|
|
|
+ color="blue"
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* work_dir */}
|
|
|
+ <DirField
|
|
|
+ label="② 工作产物目录"
|
|
|
+ hint="Bash 工具的初始路径;智能体在此写入最终交付成果"
|
|
|
+ value={workDir}
|
|
|
+ onChange={setWorkDir}
|
|
|
+ onBrowse={() => setDirPickerFor('work')}
|
|
|
+ placeholder="/Users/me/Documents/my-project"
|
|
|
+ color="amber"
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* run_dir */}
|
|
|
+ <DirField
|
|
|
+ label="③ 运行日志目录"
|
|
|
+ hint="每次对话的 run_*/traces/中间产物落在这里;留空则自动使用【工作产物目录】"
|
|
|
+ value={runDir}
|
|
|
+ onChange={setRunDir}
|
|
|
+ onBrowse={() => setDirPickerFor('run')}
|
|
|
+ placeholder="留空 = 自动(工作产物目录 + /workspace)"
|
|
|
+ color="green"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
<Input
|
|
|
- label="工作目录(agent_dir)"
|
|
|
+ label="智能体数据目录(高级)"
|
|
|
value={agentDir}
|
|
|
onChange={e => setAgentDir(e.target.value)}
|
|
|
placeholder="如 agentexample/research67"
|
|
|
- hint="绑定后执行时自动创建 workspace/run_* 目录,工具生成的文件可在对话页下载"
|
|
|
+ hint="Pack 或模板的系统目录,用于加载子智能体配置。普通用户无需填写。"
|
|
|
/>
|
|
|
</div>
|
|
|
)}
|
|
|
@@ -347,6 +470,69 @@ export default function AgentEdit() {
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
+ {/* ── 验收 & 安全(Guard) ── */}
|
|
|
+ {tab === 'guard' && (
|
|
|
+ <div className="space-y-6 max-w-lg">
|
|
|
+ <label className="flex items-center gap-3 cursor-pointer">
|
|
|
+ <input type="checkbox" checked={guardEnabled} onChange={e => setGuardEnabled(e.target.checked)}
|
|
|
+ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
|
|
+ <span className="text-sm font-medium text-gray-800">启用验收 Guard(对智能体输出做校验 + 工具安全护栏)</span>
|
|
|
+ </label>
|
|
|
+
|
|
|
+ {guardEnabled && (
|
|
|
+ <>
|
|
|
+ <div>
|
|
|
+ <h3 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-3">输出验收</h3>
|
|
|
+ <div className="space-y-3">
|
|
|
+ <Textarea
|
|
|
+ label="验收表达式(validator)"
|
|
|
+ rows={2}
|
|
|
+ value={guardValidator}
|
|
|
+ onChange={e => setGuardValidator(e.target.value)}
|
|
|
+ placeholder={'例:\'评审结论\' in x 或 \'acceptance_probability\' in x'}
|
|
|
+ hint="对输出 x 求值的安全表达式(AST 白名单),返回真才算通过;留空 = 不校验内容"
|
|
|
+ />
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <Input label="失败重试次数" type="number" value={guardRetry}
|
|
|
+ onChange={e => setGuardRetry(e.target.value)} hint="校验不过时重跑的次数" />
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs font-medium text-gray-700 mb-1">重试耗尽后</label>
|
|
|
+ <select value={guardFallback} onChange={e => setGuardFallback(e.target.value)}
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
|
|
+ <option value="error">报错中止(error)</option>
|
|
|
+ <option value="last">返回最后一次输出(last)</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <h3 className="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-3">工具安全护栏</h3>
|
|
|
+ <div className="space-y-3">
|
|
|
+ <label className="flex items-center gap-3 cursor-pointer">
|
|
|
+ <input type="checkbox" checked={guardBlockDangerous} onChange={e => setGuardBlockDangerous(e.target.checked)}
|
|
|
+ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
|
|
+ <span className="text-sm text-gray-700">拦截危险命令(rm -rf、sudo、curl|sh 等)</span>
|
|
|
+ </label>
|
|
|
+ <label className="flex items-center gap-3 cursor-pointer">
|
|
|
+ <input type="checkbox" checked={guardHighRiskConfirm} onChange={e => setGuardHighRiskConfirm(e.target.checked)}
|
|
|
+ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
|
|
+ <span className="text-sm text-gray-700">高危操作需人工确认</span>
|
|
|
+ </label>
|
|
|
+ <Input label="工具输出最大长度(字节,0 = 不限)" type="number" value={guardMaxOutput}
|
|
|
+ onChange={e => setGuardMaxOutput(e.target.value)} hint="超长则截断,防止上下文爆掉" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p className="text-xs text-gray-400 bg-gray-50 rounded-lg px-3 py-2">
|
|
|
+ 验收 Guard 在编译期生效(lambdagent Guard 构造)。审稿场景常用:让结论必须含「评审结论」或「接受概率」,否则自动重审。
|
|
|
+ </p>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
{/* ── 工具列表 ── */}
|
|
|
{tab === 'tools' && (
|
|
|
<div className="max-w-2xl space-y-3">
|
|
|
@@ -360,6 +546,84 @@ export default function AgentEdit() {
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
+ {/* ── 子智能体 ── */}
|
|
|
+ {tab === 'subagents' && (
|
|
|
+ <div className="max-w-2xl space-y-3">
|
|
|
+ <p className="text-xs text-gray-500">
|
|
|
+ 该智能体是<strong>多智能体协调者</strong>。下列子智能体在运行时被编译为
|
|
|
+ <code className="mx-1 px-1 bg-gray-100 rounded">call_*</code>工具,由协调者按需调度。
|
|
|
+ 子智能体配置在 Pack 的 YAML 中定义,此处为只读视图。
|
|
|
+ </p>
|
|
|
+ <div className="space-y-2">
|
|
|
+ {Object.entries(subAgents).map(([key, raw]) => {
|
|
|
+ const v = (raw ?? {}) as Record<string, unknown>
|
|
|
+ const tool = (v.tool as string) || `call_${key}`
|
|
|
+ const config = (v.config as string) || ''
|
|
|
+ const model = (v.model as Record<string, unknown>) || undefined
|
|
|
+ const desc = (v.description as string) || ''
|
|
|
+ const open = openSub === key
|
|
|
+ const sc = subConfigs[key]
|
|
|
+ return (
|
|
|
+ <div key={key} className="rounded-lg border border-gray-200 bg-white overflow-hidden">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => toggleSub(key)}
|
|
|
+ className="w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors"
|
|
|
+ >
|
|
|
+ <div className="flex items-center justify-between gap-2">
|
|
|
+ <span className="flex items-center gap-1.5 text-sm font-semibold text-gray-900">
|
|
|
+ <span className={clsx('text-gray-400 transition-transform', open && 'rotate-90')}>▶</span>
|
|
|
+ {key}
|
|
|
+ </span>
|
|
|
+ <span className="inline-flex items-center text-xs font-mono bg-indigo-50 text-indigo-600 border border-indigo-200 px-1.5 py-0.5 rounded">
|
|
|
+ {tool}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ {desc && <p className="text-xs text-gray-500 mt-1 ml-5">{desc}</p>}
|
|
|
+ {config && (
|
|
|
+ <p className="text-xs text-gray-400 mt-1 ml-5 font-mono break-all">📄 {config}</p>
|
|
|
+ )}
|
|
|
+ {model && (
|
|
|
+ <p className="text-xs text-gray-400 mt-1 ml-5">
|
|
|
+ 模型:{(model.provider as string) ?? '—'} · {(model.name as string) ?? '—'}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ {open && (
|
|
|
+ <div className="border-t border-gray-100 bg-gray-50">
|
|
|
+ {sc?.loading && (
|
|
|
+ <p className="text-xs text-gray-400 px-4 py-3">加载配置文件中…</p>
|
|
|
+ )}
|
|
|
+ {sc?.error && (
|
|
|
+ <p className="text-xs text-red-600 px-4 py-3">
|
|
|
+ 无法读取配置文件:{sc.error}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ {sc?.content !== undefined && (
|
|
|
+ <div>
|
|
|
+ {sc.path && (
|
|
|
+ <p className="text-[11px] text-gray-400 font-mono break-all px-4 pt-2">
|
|
|
+ {sc.path}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ <pre className="text-xs leading-relaxed text-gray-800 font-mono whitespace-pre-wrap px-4 py-3 max-h-[480px] overflow-auto">
|
|
|
+ {sc.content}
|
|
|
+ </pre>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ <p className="text-xs text-gray-400">
|
|
|
+ 共 {Object.keys(subAgents).length} 个子智能体。如需修改,请编辑对应 Pack 的 YAML 后重新安装。
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
{/* ── 系统提示词 ── */}
|
|
|
{tab === 'prompt' && (
|
|
|
<div className="max-w-2xl">
|
|
|
@@ -468,6 +732,70 @@ export default function AgentEdit() {
|
|
|
</Button>
|
|
|
</div>
|
|
|
</Card>
|
|
|
+
|
|
|
+ {dirPickerFor === 'work' && (
|
|
|
+ <DirPickerModal
|
|
|
+ initial={workDir || undefined}
|
|
|
+ onSelect={(path) => setWorkDir(path)}
|
|
|
+ onClose={() => setDirPickerFor(null)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {dirPickerFor === 'source' && (
|
|
|
+ <DirPickerModal
|
|
|
+ initial={sourceDir || undefined}
|
|
|
+ onSelect={(path) => setSourceDir(path)}
|
|
|
+ onClose={() => setDirPickerFor(null)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {dirPickerFor === 'run' && (
|
|
|
+ <DirPickerModal
|
|
|
+ initial={runDir || undefined}
|
|
|
+ onSelect={(path) => setRunDir(path)}
|
|
|
+ onClose={() => setDirPickerFor(null)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ── Reusable dir input with picker button ─────────────────────────
|
|
|
+
|
|
|
+function DirField({
|
|
|
+ label, hint, value, onChange, onBrowse, placeholder,
|
|
|
+ color = 'amber',
|
|
|
+}: {
|
|
|
+ label: string
|
|
|
+ hint: string
|
|
|
+ value: string
|
|
|
+ onChange: (v: string) => void
|
|
|
+ onBrowse: () => void
|
|
|
+ placeholder: string
|
|
|
+ color?: 'amber' | 'blue' | 'green'
|
|
|
+}) {
|
|
|
+ const iconColor = color === 'amber' ? 'text-amber-500'
|
|
|
+ : color === 'blue' ? 'text-blue-500'
|
|
|
+ : 'text-emerald-500'
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs font-medium text-gray-700 mb-1">{label}</label>
|
|
|
+ <div className="flex gap-2">
|
|
|
+ <input
|
|
|
+ value={value}
|
|
|
+ onChange={e => onChange(e.target.value)}
|
|
|
+ placeholder={placeholder}
|
|
|
+ className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onBrowse}
|
|
|
+ className="flex items-center gap-1.5 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50 hover:border-indigo-400 transition-colors shrink-0"
|
|
|
+ title="浏览文件夹"
|
|
|
+ >
|
|
|
+ <FolderOpen size={15} className={iconColor} />
|
|
|
+ 浏览
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <p className="text-xs text-gray-400 mt-1">{hint}</p>
|
|
|
</div>
|
|
|
)
|
|
|
}
|