mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
fix: LLM 工具循环 400、中断消息注入、ConPTY 终端支持
- agent: 工具轮请求尾部补 user 占位(zen 网关强制),tool 消息正确配对 - agent: 工具提醒/中断以 system 角色注入并带 [中断消息] 前缀,不进用户履历;系统提示词说明中断消息格式 - agentcli: 基于 ConPTY 的交互式终端(ptywin fork),terminal_create/read/write/resize/close/watch - webui: server 输出通道适配器(保留 reasoning_content/disable_thinking) - GUI: 沉浸式标题栏、icon 圆角重制、mascot 等打磨
This commit is contained in:
@ -26,7 +26,35 @@ var dashboardFS embed.FS
|
||||
|
||||
var dashboardHTML string
|
||||
|
||||
const loginHTML = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>HomeAgent Login</title><style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:28px;width:360px}h1{margin:0 0 16px;font-size:20px;color:#38bdf8}label{display:block;font-size:12px;color:#94a3b8;margin:10px 0 4px}input{width:100%;padding:10px 12px;border-radius:8px;border:1px solid #334155;background:#0f172a;color:#e2e8f0}button{width:100%;margin-top:16px;padding:10px 12px;border:none;border-radius:8px;background:#2563eb;color:#fff;font-weight:600;cursor:pointer}.err{margin-top:12px;color:#fca5a5;font-size:13px}</style></head><body><div class="card"><h1>HomeAgent</h1><form id="login-form"><label>用户名</label><input id="username" autocomplete="username"><label>密码</label><input id="password" type="password" autocomplete="current-password"><button type="submit">登录</button><div id="err" class="err"></div></form></div><script>document.getElementById('login-form').addEventListener('submit',async(e)=>{e.preventDefault();const username=document.getElementById('username').value;const password=document.getElementById('password').value;const err=document.getElementById('err');err.textContent='';const r=await fetch('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password})});if(r.ok){location.href='/';return}let data={};try{data=await r.json()}catch(_){}err.textContent=data.error||'登录失败'})</script></body></html>`
|
||||
const loginHTML = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>HomeAgent 登录</title><style>
|
||||
:root{--sakura-300:#ffb3c8;--sakura-400:#ff7fac;--sakura-500:#f33b7c;--frost-300:#88c0d0;--text-primary:#e8e6ee;--text-secondary:#a0a3b5;--text-muted:#6e7284;--bg-primary:#0d0d16;--bg-card:rgba(24,24,38,0.72);--bg-input:rgba(13,13,22,0.6);--border-color:rgba(255,255,255,0.09);--glass-border:rgba(255,255,255,0.12);--glass-blur:20px;--radius-lg:18px;--radius-md:12px;--radius-pill:999px;--shadow-glow:0 0 18px rgba(243,59,124,0.35);--ease-out:cubic-bezier(.22,.61,.36,1)}
|
||||
[data-theme="light"]{--text-primary:#23252e;--text-secondary:#5b5f73;--text-muted:#9aa0b5;--bg-primary:#f6f3f8;--bg-card:rgba(255,255,255,0.72);--bg-input:rgba(255,255,255,0.8);--border-color:rgba(35,37,46,0.1);--glass-border:rgba(255,255,255,0.75);--shadow-glow:0 0 18px rgba(243,59,124,0.28)}
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;background:radial-gradient(1200px 800px at 15% 0%,rgba(243,59,124,.22),transparent 55%),radial-gradient(1000px 700px at 90% 10%,rgba(136,192,208,.18),transparent 55%),radial-gradient(900px 600px at 50% 110%,rgba(163,184,255,.14),transparent 60%),var(--bg-primary);background-attachment:fixed;color:var(--text-primary);display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:20px;transition:background .3s,color .2s;overflow:hidden}
|
||||
body::before{content:'';position:fixed;inset:0;pointer-events:none;background-image:radial-gradient(rgba(255,255,255,.05) 1px,transparent 1px);background-size:28px 28px}
|
||||
.login-wrap{width:100%;max-width:400px;position:relative;z-index:1}
|
||||
.login-card{background:var(--bg-card);backdrop-filter:blur(var(--glass-blur)) saturate(1.4);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(1.4);border:1px solid var(--glass-border);border-radius:var(--radius-lg);padding:36px 32px 28px;box-shadow:0 20px 60px rgba(0,0,0,.45)}
|
||||
.logo{width:84px;height:84px;margin:0 auto 16px;border-radius:50%;overflow:hidden;border:2px solid rgba(255,255,255,.25);box-shadow:0 8px 24px rgba(243,59,124,.35);background:#F8FAFC;display:flex;align-items:center;justify-content:center}
|
||||
.logo img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
h1{margin:0 0 6px;font-size:22px;font-weight:700;text-align:center;letter-spacing:-.01em;background:linear-gradient(120deg,var(--sakura-400),var(--frost-300));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.sub{margin:0 0 24px;font-size:12px;color:var(--text-muted);text-align:center}
|
||||
label{display:block;font-size:11px;color:var(--text-secondary);margin:14px 0 6px;font-weight:500;letter-spacing:.03em}
|
||||
input{width:100%;padding:11px 14px;border-radius:var(--radius-md);border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary);font-size:14px;outline:none;transition:border .15s,box-shadow .15s}
|
||||
input:focus{border-color:var(--sakura-400);box-shadow:var(--shadow-glow)}
|
||||
input::placeholder{color:var(--text-muted)}
|
||||
button{width:100%;margin-top:22px;padding:12px 14px;border:none;border-radius:var(--radius-md);background:linear-gradient(120deg,var(--sakura-500),var(--sakura-400));color:#fff;font-size:14px;font-weight:600;cursor:pointer;letter-spacing:.08em;transition:transform .15s var(--ease-out),box-shadow .2s,filter .2s}
|
||||
button:hover{transform:translateY(-1px);filter:brightness(1.08);box-shadow:0 8px 24px rgba(243,59,124,.4)}
|
||||
button:active{transform:translateY(0) scale(.98)}
|
||||
button:disabled{opacity:.6;cursor:not-allowed;transform:none}
|
||||
.err{margin-top:14px;color:#ff6b6b;font-size:13px;text-align:center;min-height:18px;transition:opacity .2s}
|
||||
.foot{margin-top:20px;font-size:11px;color:var(--text-muted);text-align:center}
|
||||
.foot svg{width:12px;height:12px;vertical-align:-2px}
|
||||
@media(max-width:480px){.login-card{padding:28px 22px 22px}}
|
||||
</style></head><body><div class="login-wrap"><div class="login-card"><div class="logo"><img src="/logo.svg" alt="HomeAgent"></div><h1>HomeAgent</h1><p class="sub">智能家居助手控制台</p><form id="login-form"><label>用户名</label><input id="username" autocomplete="username" placeholder="请输入用户名" required><label>密码</label><input id="password" type="password" autocomplete="current-password" placeholder="请输入密码" required><button type="submit" id="submit-btn">登 录</button><div id="err" class="err"></div></form></div><div class="foot">HomeAgent · NapCat Theme</div></div><script>
|
||||
(function(){var m=window.matchMedia('(prefers-color-scheme: light)');function apply(){document.documentElement.setAttribute('data-theme',m.matches?'light':'dark')}apply();m.addEventListener('change',apply)})();
|
||||
document.getElementById('login-form').addEventListener('submit',async(e)=>{e.preventDefault();const username=document.getElementById('username').value.trim();const password=document.getElementById('password').value;const err=document.getElementById('err');const btn=document.getElementById('submit-btn');err.textContent='';if(!username||!password){err.textContent='请输入用户名和密码';return}btn.disabled=true;btn.textContent='登录中...';try{const r=await fetch('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password})});if(r.ok){location.href='/';return}let data={};try{data=await r.json()}catch(_){}err.textContent=data.error||'登录失败'}catch(_){err.textContent='网络错误,请重试'}finally{btn.disabled=false;btn.textContent='登 录'}});
|
||||
document.getElementById('password').addEventListener('keydown',function(e){if(e.key==='Enter')document.getElementById('login-form').dispatchEvent(new Event('submit'))});
|
||||
</script></body></html>`
|
||||
|
||||
func init() {
|
||||
data, err := dashboardFS.ReadFile("dashboard.html")
|
||||
@ -56,6 +84,7 @@ type Handler struct {
|
||||
|
||||
chatMu sync.Mutex
|
||||
chatHistory []ChatMsg
|
||||
pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无
|
||||
cmdMu sync.Mutex
|
||||
cmdHistory []CmdExec
|
||||
termMu sync.Mutex
|
||||
@ -63,11 +92,21 @@ type Handler struct {
|
||||
}
|
||||
|
||||
type ChatMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Time string `json:"time"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type ChatToolCall struct {
|
||||
Tool string `json:"tool"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Args interface{} `json:"args,omitempty"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
type CmdExec struct {
|
||||
@ -132,15 +171,53 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
llm: llm,
|
||||
sessions: make(map[string]time.Time),
|
||||
termStates: make(map[string]*termState),
|
||||
pendingIdx: -1,
|
||||
}
|
||||
h.loadChatHistory()
|
||||
if s != nil {
|
||||
go h.trackToolEvents()
|
||||
h.subscribeChatEvents()
|
||||
h.subscribeTerminalStream()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// subscribeTerminalStream 常驻订阅终端实时画面推流(terminal_output 事件),
|
||||
// 维护 termStates 的 Running 状态与全量输出缓冲,供 /api/v1/terminals 与前端轮询使用。
|
||||
func (h *Handler) subscribeTerminalStream() {
|
||||
if h.sdk == nil {
|
||||
return
|
||||
}
|
||||
h.sdk.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) {
|
||||
id, _ := ev.Payload["terminal_id"].(string)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
output, _ := ev.Payload["output"].(string)
|
||||
running, _ := ev.Payload["running"].(bool)
|
||||
h.termMu.Lock()
|
||||
ts, ok := h.termStates[id]
|
||||
if !ok {
|
||||
ts = &termState{ID: id, created: time.Now()}
|
||||
h.termStates[id] = ts
|
||||
}
|
||||
ts.Running = running
|
||||
if output != "" {
|
||||
const maxTermOutput = 64 * 1024
|
||||
if len(ts.Output)+len(output) > maxTermOutput {
|
||||
excess := len(ts.Output) + len(output) - maxTermOutput
|
||||
if len(ts.Output) > excess {
|
||||
ts.Output = ts.Output[excess:]
|
||||
} else {
|
||||
ts.Output = ""
|
||||
}
|
||||
}
|
||||
ts.Output += output
|
||||
}
|
||||
h.termMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) loadChatHistory() {
|
||||
if h.settings == nil {
|
||||
return
|
||||
@ -183,6 +260,9 @@ func (h *Handler) subscribeChatEvents() {
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
h.chatMu.Lock()
|
||||
h.pendingIdx = -1
|
||||
h.chatMu.Unlock()
|
||||
h.addChatMsg(ChatMsg{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
@ -190,9 +270,91 @@ func (h *Handler) subscribeChatEvents() {
|
||||
Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) {
|
||||
tool, _ := ev.Payload["tool"].(string)
|
||||
if tool == "" {
|
||||
return
|
||||
}
|
||||
channel, _ := ev.Payload["channel"].(string)
|
||||
if channel == "_consolidation_" {
|
||||
return
|
||||
}
|
||||
plugin, _ := ev.Payload["plugin"].(string)
|
||||
status, _ := ev.Payload["status"].(string)
|
||||
if status == "" {
|
||||
status = "ok"
|
||||
}
|
||||
tc := ChatToolCall{
|
||||
Tool: tool,
|
||||
Name: tool,
|
||||
Args: ev.Payload["args"],
|
||||
Result: ev.Payload["result"],
|
||||
Status: status,
|
||||
Plugin: plugin,
|
||||
}
|
||||
h.chatMu.Lock()
|
||||
msg := h.pendingAssistantLocked()
|
||||
if msg == nil {
|
||||
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)})
|
||||
h.pendingIdx = len(h.chatHistory) - 1
|
||||
msg = &h.chatHistory[h.pendingIdx]
|
||||
}
|
||||
msg.ToolCalls = append(msg.ToolCalls, tc)
|
||||
h.persistChatLocked()
|
||||
h.chatMu.Unlock()
|
||||
})
|
||||
h.sdk.Subscribe(sdk.EventReasoning, func(ev *sdk.Event) {
|
||||
content, _ := ev.Payload["content"].(string)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
channel, _ := ev.Payload["channel"].(string)
|
||||
if channel == "_consolidation_" {
|
||||
return
|
||||
}
|
||||
h.chatMu.Lock()
|
||||
msg := h.pendingAssistantLocked()
|
||||
if msg == nil {
|
||||
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "assistant", Time: time.Now().Format(time.RFC3339)})
|
||||
h.pendingIdx = len(h.chatHistory) - 1
|
||||
msg = &h.chatHistory[h.pendingIdx]
|
||||
}
|
||||
msg.ReasoningContent += content
|
||||
h.persistChatLocked()
|
||||
h.chatMu.Unlock()
|
||||
})
|
||||
h.sdk.Subscribe(sdk.EventAgentOutput, func(ev *sdk.Event) {
|
||||
content, _ := ev.Payload["content"].(string)
|
||||
channel, _ := ev.Payload["channel"].(string)
|
||||
kind, _ := ev.Payload["kind"].(string)
|
||||
h.chatMu.Lock()
|
||||
// 输出通道主动输出(output_send__{通道})作为独立气泡,不并入最终回复
|
||||
if kind == "channel_output" {
|
||||
h.pendingIdx = -1
|
||||
h.chatMu.Unlock()
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
h.addChatMsg(ChatMsg{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Source: channel,
|
||||
Time: time.Unix(ev.Timestamp, 0).Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
if msg := h.pendingAssistantLocked(); msg != nil && content != "" {
|
||||
msg.Content = content
|
||||
if channel != "" {
|
||||
msg.Source = channel
|
||||
}
|
||||
h.pendingIdx = -1
|
||||
h.persistChatLocked()
|
||||
h.chatMu.Unlock()
|
||||
return
|
||||
}
|
||||
h.pendingIdx = -1
|
||||
h.chatMu.Unlock()
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
@ -205,6 +367,27 @@ func (h *Handler) subscribeChatEvents() {
|
||||
})
|
||||
}
|
||||
|
||||
// pendingAssistantLocked 返回 chatHistory 中当前进行中的 assistant 消息(已持有 chatMu)。
|
||||
// 仅当最后一条是 assistant 且尚未产出最终内容时视为进行中,避免跨轮次误合并。
|
||||
func (h *Handler) pendingAssistantLocked() *ChatMsg {
|
||||
if h.pendingIdx < 0 || h.pendingIdx >= len(h.chatHistory) {
|
||||
return nil
|
||||
}
|
||||
msg := &h.chatHistory[h.pendingIdx]
|
||||
if msg.Role != "assistant" || msg.Content != "" {
|
||||
return nil
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func (h *Handler) persistChatLocked() {
|
||||
if h.settings == nil {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(h.chatHistory)
|
||||
_ = h.settings.Set("chathistory", string(b))
|
||||
}
|
||||
|
||||
func (h *Handler) handleToolEvent(ev *sdk.Event) {
|
||||
payload := ev.Payload
|
||||
tool, _ := payload["tool"].(string)
|
||||
@ -228,7 +411,21 @@ func (h *Handler) handleToolEvent(ev *sdk.Event) {
|
||||
|
||||
case "terminal_create":
|
||||
id := getStr(args, "id")
|
||||
if id == "" {
|
||||
// agent 调用时不知道生成的 id,从工具结果中回填
|
||||
if res, ok := payload["result"].(map[string]interface{}); ok {
|
||||
id = getStr(res, "id")
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
break
|
||||
}
|
||||
cmd := getStr(args, "command")
|
||||
if cmd == "" {
|
||||
if res, ok := payload["result"].(map[string]interface{}); ok {
|
||||
cmd = getStr(res, "command")
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
term := &termState{
|
||||
ID: id,
|
||||
@ -238,7 +435,13 @@ func (h *Handler) handleToolEvent(ev *sdk.Event) {
|
||||
created: now,
|
||||
}
|
||||
h.termMu.Lock()
|
||||
h.termStates[id] = term
|
||||
if old, ok := h.termStates[id]; ok {
|
||||
old.Command = cmd
|
||||
old.Running = true
|
||||
old.created = now
|
||||
} else {
|
||||
h.termStates[id] = term
|
||||
}
|
||||
if len(h.termStates) > maxTerminals {
|
||||
for k := range h.termStates {
|
||||
delete(h.termStates, k)
|
||||
@ -608,7 +811,7 @@ func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agen
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": fmt.Sprintf("%s_requested", action), "agent": string(agentID)})
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": fmt.Sprintf("agent %s action not implemented by supervisor", action)})
|
||||
}
|
||||
|
||||
func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) {
|
||||
@ -905,18 +1108,16 @@ func (h *Handler) addChatMsg(msg ChatMsg) {
|
||||
h.chatMu.Lock()
|
||||
h.chatHistory = append(h.chatHistory, msg)
|
||||
if len(h.chatHistory) > maxChatHistory {
|
||||
h.chatHistory = h.chatHistory[len(h.chatHistory)-maxChatHistory:]
|
||||
}
|
||||
// 只持 latest 50 条做持久化(写放大防护),全量仍保留在内存
|
||||
if h.settings != nil {
|
||||
persistLen := len(h.chatHistory)
|
||||
if persistLen > 50 {
|
||||
persistLen = 50
|
||||
drop := len(h.chatHistory) - maxChatHistory
|
||||
h.chatHistory = h.chatHistory[drop:]
|
||||
if h.pendingIdx >= 0 {
|
||||
h.pendingIdx -= drop
|
||||
if h.pendingIdx < 0 {
|
||||
h.pendingIdx = -1
|
||||
}
|
||||
}
|
||||
toPersist := h.chatHistory[len(h.chatHistory)-persistLen:]
|
||||
b, _ := json.Marshal(toPersist)
|
||||
_ = h.settings.Set("chathistory", string(b))
|
||||
}
|
||||
h.persistChatLocked()
|
||||
h.chatMu.Unlock()
|
||||
}
|
||||
|
||||
@ -968,7 +1169,6 @@ func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
// 带超时的上下文,防止 InjectTextSync 长时间阻塞 HTTP 请求
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
@ -1052,8 +1252,9 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[SSE] client reported Last-Event-ID: %s", lastEventID)
|
||||
}
|
||||
|
||||
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain"}
|
||||
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output"}
|
||||
var unsubs []func()
|
||||
var seq int64
|
||||
for _, t := range subTypes {
|
||||
t2 := t
|
||||
unsub := h.sdk.Subscribe(sdk.EventType(t2), func(evt *sdk.Event) {
|
||||
@ -1062,8 +1263,9 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[SSE] received tool_call event: tool=%s", toolName)
|
||||
}
|
||||
data, _ := json.Marshal(evt)
|
||||
seq++
|
||||
select {
|
||||
case writeCh <- fmt.Sprintf("event: %s\ndata: %s\n", evt.Type, string(data)):
|
||||
case writeCh <- fmt.Sprintf("id: %d-%d\nevent: %s\ndata: %s\n", evt.Timestamp, seq, evt.Type, string(data)):
|
||||
if evt.Type == sdk.EventToolCall {
|
||||
toolName, _ := evt.Payload["tool"].(string)
|
||||
log.Printf("[SSE] wrote tool_call to writeCh: tool=%s", toolName)
|
||||
|
||||
Reference in New Issue
Block a user