agent / src /components /AgentInterface.jsx
shutajb's picture
Upload 22 files
e5faeaf verified
Raw History Blame Contribute Delete
11.9 kB
import { useState } from 'react'
import { Play, Brain, Plus, Trash2, Settings2, Loader2, Wrench } from 'lucide-react'
import { useAgentStore } from '../store/agentStore'
import { tools as builtinTools } from '../utils/tools'
import { agents } from '../utils/agents'
const EMPTY_PARAMS = '{"type":"object","properties":{}}'
function ToolChip({ call }) {
return (
<div className="mt-2 p-3 rounded-2xl bg-app border border-edge text-sm">
<div className="flex items-center gap-2 text-amber-400 font-medium">
<Wrench className="w-4 h-4" /> {call?.function?.name || '未知工具'}
</div>
<pre className="mt-1 text-xs text-muted whitespace-pre-wrap break-words">{call?.function?.arguments || ''}</pre>
</div>
)
}
function ToolModal({ toolsList, onChange, onClose }) {
const [draft, setDraft] = useState({ name: '', description: '', parameters: EMPTY_PARAMS })
const [err, setErr] = useState('')
const addTool = () => {
const name = draft.name.trim()
if (!name) return setErr('請輸入工具名稱')
if (!/^[a-zA-Z0-9_-]+$/.test(name)) return setErr('名稱只能用英數字、底線與連字號')
if (toolsList.some((t) => t.name === name)) return setErr('名稱重複')
let parameters
try {
parameters = JSON.parse(draft.parameters || '{}')
} catch {
return setErr('parameters 不是合法 JSON')
}
onChange([
...toolsList,
{ name, description: draft.description.trim() || '自訂工具', parameters },
])
setDraft({ name: '', description: '', parameters: EMPTY_PARAMS })
setErr('')
}
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-panel p-8 rounded-3xl w-full max-w-lg border border-edge">
<h3 className="text-2xl font-bold mb-6">管理工具</h3>
<div className="space-y-3 mb-6">
{toolsList.length === 0 && <p className="text-sm text-muted">目前沒有工具</p>}
{toolsList.map((t) => (
<div key={t.name} className="flex items-start justify-between gap-3 bg-field p-3 rounded-2xl">
<div className="min-w-0">
<div className="font-medium break-words">{t.name}</div>
<div className="text-xs text-muted break-words">{t.description}</div>
</div>
<button
onClick={() => onChange(toolsList.filter((x) => x.name !== t.name))}
className="text-red-500 shrink-0"
title="移除工具"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div className="space-y-3 p-4 bg-field rounded-2xl">
<div className="text-sm font-medium">新增工具</div>
<input
type="text"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="name,例如 get_weather"
className="w-full bg-app px-4 py-2 rounded-xl focus:outline-none"
/>
<input
type="text"
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
placeholder="description(模型靠它決定何時呼叫)"
className="w-full bg-app px-4 py-2 rounded-xl focus:outline-none"
/>
<textarea
value={draft.parameters}
onChange={(e) => setDraft({ ...draft, parameters: e.target.value })}
placeholder='parameters,例如 {"type":"object","properties":{"city":{"type":"string"}}}'
rows={3}
className="w-full bg-app px-4 py-2 rounded-xl font-mono text-xs focus:outline-none"
/>
{err && <p className="text-xs text-red-400">{err}</p>}
<p className="text-xs text-muted">
注意:自訂工具只送進 API 讓模型呼叫,本機沒有對應的執行器,會回傳「未實作」。
</p>
<button
onClick={addTool}
className="w-full flex items-center justify-center gap-2 bg-purple-600 py-3 rounded-2xl font-medium"
>
<Plus className="w-4 h-4" /> 新增工具
</button>
</div>
<button onClick={onClose} className="mt-6 w-full py-3 bg-field rounded-2xl">關閉</button>
</div>
</div>
)
}
export default function AgentInterface({ apiConfig, onOpenSettings }) {
const [input, setInput] = useState('')
const [toolsList, setToolsList] = useState(builtinTools)
const [currentAgent, setCurrentAgent] = useState(agents[0])
const [showToolModal, setShowToolModal] = useState(false)
const [showAgentModal, setShowAgentModal] = useState(false)
const { messages, running, step, error, runAgent, reset } = useAgentStore()
const handleRun = async () => {
const text = input.trim()
if (!text || running) return
setInput('')
await runAgent(text, toolsList, apiConfig, currentAgent)
}
const clearAll = () => {
reset()
setInput('')
}
const ready = !!(apiConfig.apiKey?.trim() && apiConfig.supplier?.trim() && apiConfig.model?.trim())
return (
<div className="flex flex-col h-[80vh] border border-edge rounded-3xl overflow-hidden bg-panel">
<div className="p-4 border-b border-edge flex items-center justify-between gap-3 flex-wrap bg-app">
<div className="flex items-center gap-3">
<Brain className="w-6 h-6 text-purple-500" />
<div>
<h2 className="text-xl font-semibold leading-tight">Agent Studio</h2>
<p className="text-xs text-muted">
自訂多步工具呼叫迴圈(最多 6 步)· 目前 Agent:{currentAgent.icon} {currentAgent.name}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onOpenSettings}
className="flex items-center gap-2 px-4 py-2 bg-field rounded-xl"
title="API 連線設定"
>
<Settings2 className="w-4 h-4" />
<span className={`text-xs ${ready ? 'text-emerald-400' : 'text-red-400'}`}>
{ready ? `${apiConfig.model.slice(0, 24)}…` : '未設定金鑰'}
</span>
</button>
<button
onClick={clearAll}
disabled={running || messages.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-field rounded-xl disabled:opacity-40"
title="清空對話"
>
<Trash2 className="w-4 h-4" /> 清空
</button>
<button
onClick={() => setShowToolModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-field rounded-xl"
>
<Plus className="w-4 h-4" /> 工具 ({toolsList.length})
</button>
<button
onClick={() => setShowAgentModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-field rounded-xl"
>
<Brain className="w-4 h-4" /> 切換 Agent
</button>
<button
onClick={handleRun}
disabled={running || !input.trim()}
className="flex items-center gap-2 bg-purple-600 hover:bg-purple-700 px-6 py-2 rounded-2xl disabled:opacity-50"
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? `執行中 ${step}/6` : 'Run'}
</button>
</div>
</div>
{error && !running && (
<div className="px-4 py-2 bg-red-900/50 text-red-200 text-sm border-b border-red-900">{error}</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && !running && (
<div className="h-full flex flex-col items-center justify-center text-center text-muted gap-2">
<span className="text-4xl">{currentAgent.icon}</span>
<p className="text-sm">{currentAgent.description}</p>
<p className="text-xs">輸入任務後按 Enter 執行</p>
</div>
)}
{messages.map((msg, i) => {
if (msg.role === 'user') {
return (
<div key={i} className="bg-blue-600 p-4 rounded-3xl mr-12 whitespace-pre-wrap break-words">
{msg.content}
</div>
)
}
if (msg.role === 'tool') {
return (
<div key={i} className="p-4 rounded-3xl bg-field border-l-4 border-amber-500 ml-12 text-sm">
<div className="flex items-center gap-2 text-amber-400 font-medium mb-1">
<Wrench className="w-4 h-4" /> {msg.name || msg.tool_call_id}
</div>
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
</div>
)
}
const isError = typeof msg.content === 'string' && msg.content.startsWith('⚠️')
return (
<div key={i} className={`p-4 rounded-3xl ${isError ? 'bg-red-900/60 text-red-200' : 'bg-field'} ml-12`}>
<div className="whitespace-pre-wrap break-words">
{typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)}
</div>
{(msg.tool_calls || []).map((call, j) => <ToolChip key={j} call={call} />)}
</div>
)
})}
{running && step === 0 && (
<div className="p-4 rounded-3xl bg-field ml-12 flex items-center gap-2 text-muted text-sm">
<Loader2 className="w-4 h-4 animate-spin" /> 等待模型回應...
</div>
)}
</div>
<div className="p-4 border-t border-edge">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleRun()
}
}}
disabled={running}
placeholder={`輸入任務(目前使用 ${currentAgent.name})...`}
className="w-full bg-field px-6 py-4 rounded-3xl focus:outline-none disabled:opacity-50"
/>
</div>
{showToolModal && (
<ToolModal toolsList={toolsList} onChange={setToolsList} onClose={() => setShowToolModal(false)} />
)}
{showAgentModal && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-panel p-8 rounded-3xl w-full max-w-md border border-edge">
<h3 className="text-2xl font-bold mb-6">選擇 Agent</h3>
<div className="space-y-3">
{agents.map((agent) => (
<button
key={agent.id}
onClick={() => {
setCurrentAgent(agent)
setShowAgentModal(false)
}}
className={`w-full text-left px-5 py-4 rounded-2xl flex items-center gap-3 ${
currentAgent.id === agent.id ? 'bg-purple-600' : 'bg-field hover:bg-app'
}`}
>
<div className="text-2xl">{agent.icon}</div>
<div className="min-w-0">
<div className="font-medium">{agent.name}</div>
<div className="text-xs text-muted break-words">{agent.description}</div>
</div>
</button>
))}
</div>
<p className="text-xs text-muted mt-4">切換後會以該 Agent 的 system prompt 執行下一輪。</p>
<button onClick={() => setShowAgentModal(false)} className="mt-6 w-full py-3 bg-field rounded-2xl">
關閉
</button>
</div>
</div>
)}
</div>
)
}