diff --git a/cmd/clawgo/cmd_gateway.go b/cmd/clawgo/cmd_gateway.go index 04e1457..4bb8caa 100644 --- a/cmd/clawgo/cmd_gateway.go +++ b/cmd/clawgo/cmd_gateway.go @@ -188,6 +188,21 @@ func gatewayCmd() { } return agentLoop.ProcessDirect(cctx, content, sessionKey) }) + registryServer.SetChatHistoryHandler(func(sessionKey string) []map[string]interface{} { + h := agentLoop.GetSessionHistory(sessionKey) + out := make([]map[string]interface{}, 0, len(h)) + for _, m := range h { + entry := map[string]interface{}{"role": m.Role, "content": m.Content} + if strings.TrimSpace(m.ToolCallID) != "" { + entry["tool_call_id"] = m.ToolCallID + } + if len(m.ToolCalls) > 0 { + entry["tool_calls"] = m.ToolCalls + } + out = append(out, entry) + } + return out + }) registryServer.SetConfigAfterHook(func() { _ = syscall.Kill(os.Getpid(), syscall.SIGHUP) }) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 8a144b5..e88f987 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -437,6 +437,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri return al.processMessage(ctx, msg) } +func (al *AgentLoop) GetSessionHistory(sessionKey string) []providers.Message { + return al.sessions.GetHistory(sessionKey) +} + func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { unlock := al.lockSessionRun(msg.SessionKey) defer unlock() diff --git a/pkg/nodes/registry_server.go b/pkg/nodes/registry_server.go index 98532ea..bf793f5 100644 --- a/pkg/nodes/registry_server.go +++ b/pkg/nodes/registry_server.go @@ -27,6 +27,7 @@ type RegistryServer struct { workspacePath string logFilePath string onChat func(ctx context.Context, sessionKey, content string) (string, error) + onChatHistory func(sessionKey string) []map[string]interface{} onConfigAfter func() onCron func(action string, args map[string]interface{}) (interface{}, error) webUIDir string @@ -49,6 +50,9 @@ func (s *RegistryServer) SetLogFilePath(path string) { s.logFilePath = strings.T func (s *RegistryServer) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) { s.onChat = fn } +func (s *RegistryServer) SetChatHistoryHandler(fn func(sessionKey string) []map[string]interface{}) { + s.onChatHistory = fn +} func (s *RegistryServer) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn } func (s *RegistryServer) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { s.onCron = fn @@ -70,6 +74,7 @@ func (s *RegistryServer) Start(ctx context.Context) error { mux.HandleFunc("/webui/", s.handleWebUIAsset) mux.HandleFunc("/webui/api/config", s.handleWebUIConfig) mux.HandleFunc("/webui/api/chat", s.handleWebUIChat) + mux.HandleFunc("/webui/api/chat/history", s.handleWebUIChatHistory) mux.HandleFunc("/webui/api/chat/stream", s.handleWebUIChatStream) mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload) mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes) @@ -336,10 +341,7 @@ func (s *RegistryServer) handleWebUIChat(w http.ResponseWriter, r *http.Request) http.Error(w, "invalid json", http.StatusBadRequest) return } - session := strings.TrimSpace(body.Session) - if session == "" { - session = "webui:default" - } + session := "main" prompt := strings.TrimSpace(body.Message) if strings.TrimSpace(body.Media) != "" { if prompt != "" { @@ -355,6 +357,23 @@ func (s *RegistryServer) handleWebUIChat(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reply": resp, "session": session}) } +func (s *RegistryServer) handleWebUIChatHistory(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + session := "main" + if s.onChatHistory == nil { + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "session": session, "messages": []interface{}{}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(session)}) +} + func (s *RegistryServer) handleWebUIChatStream(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -382,10 +401,7 @@ func (s *RegistryServer) handleWebUIChatStream(w http.ResponseWriter, r *http.Re http.Error(w, "invalid json", http.StatusBadRequest) return } - session := strings.TrimSpace(body.Session) - if session == "" { - session = "webui:default" - } + session := "main" prompt := strings.TrimSpace(body.Message) if strings.TrimSpace(body.Media) != "" { if prompt != "" { diff --git a/webui/src/pages/Chat.tsx b/webui/src/pages/Chat.tsx index 6d3c942..f9a6b20 100644 --- a/webui/src/pages/Chat.tsx +++ b/webui/src/pages/Chat.tsx @@ -1,5 +1,5 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; -import { Plus, MessageSquare, Paperclip, Send } from 'lucide-react'; +import React, { useState, useRef, useEffect } from 'react'; +import { Paperclip, Send, MessageSquare, RefreshCw } from 'lucide-react'; import { motion } from 'motion/react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; @@ -7,39 +7,59 @@ import { ChatItem } from '../types'; const Chat: React.FC = () => { const { t } = useTranslation(); - const { sessions, setSessions, q } = useAppContext(); - const [active, setActive] = useState('webui:default'); - const [chat, setChat] = useState>({ 'webui:default': [] }); + const { q } = useAppContext(); + const [chat, setChat] = useState([]); const [msg, setMsg] = useState(''); const [fileSelected, setFileSelected] = useState(false); const chatEndRef = useRef(null); - const activeChat = useMemo(() => chat[active] || [], [chat, active]); - useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [activeChat]); + }, [chat]); + + const loadHistory = async () => { + try { + const r = await fetch(`/webui/api/chat/history${q}`); + if (!r.ok) return; + const j = await r.json(); + const arr = Array.isArray(j.messages) ? j.messages : []; + const mapped: ChatItem[] = arr.map((m: any) => { + const role = (m.role === 'assistant' || m.role === 'user') ? m.role : 'assistant'; + let text = m.content || ''; + if (m.role === 'tool') { + text = `[tool output]\n${text}`; + } + if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) { + text = `${text}\n[tool calls: ${m.tool_calls.map((x: any) => x?.function?.name || x?.name).filter(Boolean).join(', ')}]`; + } + return { role, text }; + }); + setChat(mapped); + } catch (e) { + console.error(e); + } + }; async function send() { if (!msg.trim() && !fileSelected) return; - + let media = ''; const input = document.getElementById('file') as HTMLInputElement | null; const f = input?.files?.[0]; - + if (f) { const fd = new FormData(); fd.append('file', f); try { const ur = await fetch(`/webui/api/upload${q}`, { method: 'POST', body: fd }); const uj = await ur.json(); media = uj.path || ''; } catch (e) { - console.error("Upload failed", e); + console.error('Upload failed', e); } } - + const userText = msg + (media ? `\n[Attached File: ${f?.name}]` : ''); - setChat((prev) => ({ ...prev, [active]: [...(prev[active] || []), { role: 'user', text: userText }] })); - + setChat((prev) => [...prev, { role: 'user', text: userText }]); + const currentMsg = msg; setMsg(''); setFileSelected(false); @@ -49,80 +69,50 @@ const Chat: React.FC = () => { const response = await fetch(`/webui/api/chat/stream${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session: active, message: currentMsg, media }), + body: JSON.stringify({ session: 'main', message: currentMsg, media }), }); - if (!response.ok) throw new Error('Chat request failed'); - if (!response.body) throw new Error('No response body'); + if (!response.ok || !response.body) throw new Error('Chat request failed'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let assistantText = ''; - // Add placeholder for assistant response - setChat((prev) => ({ - ...prev, - [active]: [...(prev[active] || []), { role: 'assistant', text: '' }] - })); + setChat((prev) => [...prev, { role: 'assistant', text: '' }]); while (true) { const { value, done } = await reader.read(); if (done) break; - const chunk = decoder.decode(value, { stream: true }); assistantText += chunk; - setChat((prev) => { - const currentChat = [...(prev[active] || [])]; - if (currentChat.length > 0) { - currentChat[currentChat.length - 1] = { role: 'assistant', text: assistantText }; - } - return { ...prev, [active]: currentChat }; + const next = [...prev]; + next[next.length - 1] = { role: 'assistant', text: assistantText }; + return next; }); } + + // refresh full persisted history (includes tool/internal traces) + loadHistory(); } catch (e) { - setChat((prev) => ({ - ...prev, - [active]: [...(prev[active] || []), { role: 'assistant', text: 'Error: Failed to get response from server.' }] - })); + setChat((prev) => [...prev, { role: 'assistant', text: 'Error: Failed to get response from server.' }]); } } - function addSession() { - const n = `webui:${Date.now()}`; - const s = { key: n, title: `${t('sessions')} ${sessions.length + 1}` }; - setSessions((v) => [...v, s]); setActive(n); setChat((prev) => ({ ...prev, [n]: [] })); - } + useEffect(() => { + loadHistory(); + }, [q]); return (
-
-
-

{t('sessions')}

- -
-
- {sessions.map(s => ( - - ))} -
-
-
+
+

Main Agent

+ +
+
- {activeChat.length === 0 ? ( + {chat.length === 0 ? (
@@ -130,16 +120,16 @@ const Chat: React.FC = () => {

{t('startConversation')}

) : ( - activeChat.map((m, i) => ( - ( + -

{m.text}

@@ -149,31 +139,31 @@ const Chat: React.FC = () => { )}
- +
- setFileSelected(!!e.target.files?.[0])} /> - - setMsg(e.target.value)} + setMsg(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} placeholder={t('typeMessage')} className="w-full bg-zinc-900 border border-zinc-800 rounded-full pl-14 pr-14 py-3.5 text-[15px] focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-all placeholder:text-zinc-500 shadow-sm" /> -