mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 08:57:30 +08:00
webui chat: lock to main agent, remove session picker, load full persisted history
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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<Record<string, ChatItem[]>>({ 'webui:default': [] });
|
||||
const { q } = useAppContext();
|
||||
const [chat, setChat] = useState<ChatItem[]>([]);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [fileSelected, setFileSelected] = useState(false);
|
||||
const chatEndRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex h-full">
|
||||
<div className="w-64 border-r border-zinc-800 bg-zinc-900/20 flex flex-col shrink-0">
|
||||
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h2 className="font-medium text-zinc-200">{t('sessions')}</h2>
|
||||
<button onClick={addSession} className="p-1.5 hover:bg-zinc-800 rounded-md transition-colors text-zinc-400 hover:text-zinc-200">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
{sessions.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setActive(s.key)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 ${
|
||||
s.key === active
|
||||
? 'bg-indigo-500/15 text-indigo-300 font-medium shadow-sm'
|
||||
: 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col bg-zinc-950/50">
|
||||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h2 className="text-sm text-zinc-300 font-medium">Main Agent</h2>
|
||||
<button onClick={loadHistory} className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-zinc-800 hover:bg-zinc-700"><RefreshCw className="w-3 h-3"/>Reload History</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{activeChat.length === 0 ? (
|
||||
{chat.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-zinc-500 space-y-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-zinc-900 flex items-center justify-center border border-zinc-800">
|
||||
<MessageSquare className="w-8 h-8 text-zinc-600" />
|
||||
@@ -130,16 +120,16 @@ const Chat: React.FC = () => {
|
||||
<p className="text-sm font-medium">{t('startConversation')}</p>
|
||||
</div>
|
||||
) : (
|
||||
activeChat.map((m, i) => (
|
||||
<motion.div
|
||||
chat.map((m, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
key={i}
|
||||
key={i}
|
||||
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[80%] rounded-2xl px-5 py-3.5 shadow-sm ${
|
||||
m.role === 'user'
|
||||
? 'bg-indigo-600 text-white rounded-br-sm'
|
||||
<div className={`max-w-[90%] rounded-2xl px-5 py-3.5 shadow-sm ${
|
||||
m.role === 'user'
|
||||
? 'bg-indigo-600 text-white rounded-br-sm'
|
||||
: 'bg-zinc-800/80 text-zinc-200 rounded-bl-sm border border-zinc-700/50'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{m.text}</p>
|
||||
@@ -149,31 +139,31 @@ const Chat: React.FC = () => {
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-4 bg-zinc-950 border-t border-zinc-800">
|
||||
<div className="max-w-4xl mx-auto relative flex items-center">
|
||||
<input
|
||||
type="file"
|
||||
id="file"
|
||||
className="hidden"
|
||||
<input
|
||||
type="file"
|
||||
id="file"
|
||||
className="hidden"
|
||||
onChange={(e) => setFileSelected(!!e.target.files?.[0])}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file"
|
||||
<label
|
||||
htmlFor="file"
|
||||
className={`absolute left-3 p-2 rounded-full cursor-pointer transition-colors ${
|
||||
fileSelected ? 'text-indigo-400 bg-indigo-500/10' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</label>
|
||||
<input
|
||||
value={msg}
|
||||
onChange={(e) => setMsg(e.target.value)}
|
||||
<input
|
||||
value={msg}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!msg.trim() && !fileSelected}
|
||||
className="absolute right-2 p-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-full transition-colors shadow-sm"
|
||||
|
||||
Reference in New Issue
Block a user