webui chat: lock to main agent, remove session picker, load full persisted history

This commit is contained in:
DBT
2026-02-26 02:04:54 +00:00
parent 6b6c0e9ef3
commit 2f5cf6338f
4 changed files with 113 additions and 88 deletions

View File

@@ -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)
})

View File

@@ -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()

View File

@@ -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 != "" {

View File

@@ -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"