File size: 11,903 Bytes
e5faeaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
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>
  )
}