feat: add web UI with SSE streaming on port 8081

This commit is contained in:
大森 2026-05-10 15:10:39 +08:00
parent 9fec5df6f7
commit b80efea64a
3 changed files with 586 additions and 5 deletions

View File

@ -1,6 +1,7 @@
package main
import (
"flag"
"fmt"
"log"
"os"
@ -8,10 +9,14 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/orca/orca/internal/config"
"github.com/orca/orca/internal/tui"
"github.com/orca/orca/internal/web"
"github.com/orca/orca/pkg/kernel"
)
func main() {
webMode := flag.Bool("web", false, "Run in web mode on port 8081")
flag.Parse()
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
@ -26,6 +31,13 @@ func main() {
log.Printf("Warning: failed to load skills: %v", err)
}
if *webMode {
server := web.NewServer(k, 8081)
fmt.Println("Starting web server on http://localhost:8081")
if err := server.Start(); err != nil {
log.Fatalf("Failed to start web server: %v", err)
}
} else {
m := tui.NewModel(k)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
@ -33,6 +45,7 @@ func main() {
fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
os.Exit(1)
}
}
if err := k.Stop(); err != nil {
log.Printf("Warning: error stopping kernel: %v", err)

365
internal/web/html.go Normal file
View File

@ -0,0 +1,365 @@
package web
const indexHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>orca.agent</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1b26;
color: #a9b1d6;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
height: 100vh;
overflow: hidden;
}
.header {
background: #1a1b26;
border-bottom: 1px solid #414868;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 8px;
}
.header h1 {
font-size: 16px;
color: #7aa2f7;
font-weight: 600;
}
.header .version {
font-size: 12px;
color: #565f89;
}
.container {
display: flex;
height: calc(100vh - 37px);
}
.left-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-box {
flex: 1;
overflow-y: auto;
padding: 16px;
border-right: 1px solid #414868;
}
.message {
margin-bottom: 16px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.message-user .label {
color: #7dcfff;
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.message-assistant .label {
color: #9ece6a;
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.message-system .label {
color: #e0af68;
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.message-content {
color: #c0caf5;
line-height: 1.6;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
}
.input-box {
padding: 12px 16px;
border-right: 1px solid #414868;
border-top: 1px solid #414868;
display: flex;
gap: 8px;
}
.input-box input {
flex: 1;
background: #24283b;
border: 1px solid #414868;
border-radius: 6px;
padding: 8px 12px;
color: #c0caf5;
font-size: 14px;
outline: none;
}
.input-box input:focus {
border-color: #7aa2f7;
}
.input-box input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-box button {
background: #7aa2f7;
color: #1a1b26;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.input-box button:hover {
background: #bb9af7;
}
.input-box button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.right-panel {
width: 280px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.panel-box {
background: #24283b;
border: 1px solid #414868;
border-radius: 8px;
padding: 12px;
}
.panel-box h3 {
color: #7aa2f7;
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #414868;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 13px;
}
.stat-label { color: #565f89; }
.stat-value { color: #7aa2f7; font-weight: 600; }
.agent-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
font-size: 13px;
border-bottom: 1px solid #2f3349;
}
.agent-row:last-child { border-bottom: none; }
.agent-name { color: #c0caf5; }
.agent-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
}
.status-idle { background: #2f3349; color: #565f89; }
.status-running { background: #2b3a2b; color: #9ece6a; }
.typing-indicator {
display: none;
padding: 8px 0;
color: #e0af68;
font-size: 13px;
font-style: italic;
}
.typing-indicator.active { display: block; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #414868; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #565f89; }
</style>
</head>
<body>
<div class="header">
<h1>orca.agent</h1>
<span class="version">v0.1.0</span>
</div>
<div class="container">
<div class="left-panel">
<div class="chat-box" id="chatBox"></div>
<div class="typing-indicator" id="typingIndicator">Processing...</div>
<div class="input-box">
<input type="text" id="messageInput" placeholder="Type a message and press Enter..." autocomplete="off">
<button id="sendBtn" onclick="sendMessage()">Send</button>
</div>
</div>
<div class="right-panel">
<div class="panel-box">
<h3>Statistics</h3>
<div class="stat-row">
<span class="stat-label">Tools:</span>
<span class="stat-value" id="statTools">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Skills:</span>
<span class="stat-value" id="statSkills">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Agents:</span>
<span class="stat-value" id="statAgents">0</span>
</div>
</div>
<div class="panel-box">
<h3>Active Agents</h3>
<div id="agentsList"></div>
</div>
</div>
</div>
<script>
const chatBox = document.getElementById('chatBox');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator');
const statTools = document.getElementById('statTools');
const statSkills = document.getElementById('statSkills');
const statAgents = document.getElementById('statAgents');
const agentsList = document.getElementById('agentsList');
let currentAssistantDiv = null;
let eventSource = null;
function connectSSE() {
eventSource = new EventSource('/api/stream');
eventSource.addEventListener('connected', function(e) {
console.log('SSE connected:', e.data);
});
eventSource.onmessage = function(e) {
if (currentAssistantDiv) {
const content = currentAssistantDiv.querySelector('.message-content');
content.textContent += e.data;
chatBox.scrollTop = chatBox.scrollHeight;
}
};
eventSource.onerror = function(e) {
console.log('SSE error, reconnecting...');
setTimeout(connectSSE, 3000);
};
}
connectSSE();
function addMessage(role, content, agent) {
const div = document.createElement('div');
div.className = 'message message-' + role;
const label = document.createElement('div');
label.className = 'label';
if (role === 'user') {
label.textContent = 'You';
} else if (role === 'assistant' && agent) {
label.textContent = '[' + agent + ']';
} else if (role === 'assistant') {
label.textContent = 'Assistant';
} else {
label.textContent = 'System';
}
const text = document.createElement('div');
text.className = 'message-content';
text.textContent = content;
div.appendChild(label);
div.appendChild(text);
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
return div;
}
async function sendMessage() {
const message = messageInput.value.trim();
if (!message) return;
messageInput.value = '';
messageInput.disabled = true;
sendBtn.disabled = true;
typingIndicator.classList.add('active');
addMessage('user', message);
currentAssistantDiv = addMessage('assistant', '', '');
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message })
});
const data = await response.json();
if (data.error) {
addMessage('system', 'Error: ' + data.error);
}
} catch (err) {
addMessage('system', 'Error: ' + err.message);
} finally {
messageInput.disabled = false;
sendBtn.disabled = false;
typingIndicator.classList.remove('active');
messageInput.focus();
currentAssistantDiv = null;
updateStats();
updateAgents();
}
}
async function updateStats() {
try {
const response = await fetch('/api/stats');
const data = await response.json();
statTools.textContent = data.tools;
statSkills.textContent = data.skills;
statAgents.textContent = data.agents;
} catch (e) {
console.log('Failed to update stats');
}
}
async function updateAgents() {
try {
const response = await fetch('/api/agents');
const data = await response.json();
agentsList.innerHTML = '';
data.forEach(agent => {
const div = document.createElement('div');
div.className = 'agent-row';
const statusClass = agent.status === 'running' ? 'status-running' : 'status-idle';
div.innerHTML = '<span class="agent-name">' + agent.id + '</span><span class="agent-status ' + statusClass + '">' + agent.status + '</span>';
agentsList.appendChild(div);
});
} catch (e) {
console.log('Failed to update agents');
}
}
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
updateStats();
updateAgents();
setInterval(updateStats, 5000);
setInterval(updateAgents, 2000);
</script>
</body>
</html>`

203
internal/web/server.go Normal file
View File

@ -0,0 +1,203 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/orca/orca/pkg/actor"
"github.com/orca/orca/pkg/kernel"
)
type Server struct {
kernel *kernel.Kernel
port int
clients map[string]*SSEClient
clientsMu sync.RWMutex
msgCounter int
}
type SSEClient struct {
ID string
Writer http.ResponseWriter
Flusher http.Flusher
Done chan bool
}
type sseWriter struct {
client *SSEClient
mu sync.Mutex
}
func (w *sseWriter) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
select {
case <-w.client.Done:
return len(p), nil
default:
}
data := strings.ReplaceAll(string(p), "\n", "")
if data == "" {
return len(p), nil
}
fmt.Fprintf(w.client.Writer, "data: %s\n\n", data)
w.client.Flusher.Flush()
return len(p), nil
}
func NewServer(k *kernel.Kernel, port int) *Server {
if port <= 0 {
port = 8081
}
return &Server{
kernel: k,
port: port,
clients: make(map[string]*SSEClient),
}
}
func (s *Server) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/api/stream", s.handleStream)
mux.HandleFunc("/api/chat", s.handleChat)
mux.HandleFunc("/api/stats", s.handleStats)
mux.HandleFunc("/api/agents", s.handleAgents)
mux.HandleFunc("/", s.handleIndex)
addr := fmt.Sprintf(":%d", s.port)
return http.ListenAndServe(addr, mux)
}
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
s.msgCounter++
clientID := fmt.Sprintf("client-%d", s.msgCounter)
client := &SSEClient{
ID: clientID,
Writer: w,
Flusher: flusher,
Done: make(chan bool),
}
s.clientsMu.Lock()
s.clients[clientID] = client
s.clientsMu.Unlock()
fmt.Fprintf(w, "event: connected\ndata: %s\n\n", clientID)
flusher.Flush()
<-r.Context().Done()
s.clientsMu.Lock()
delete(s.clients, clientID)
s.clientsMu.Unlock()
close(client.Done)
}
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.clientsMu.RLock()
var client *SSEClient
for _, c := range s.clients {
client = c
break
}
s.clientsMu.RUnlock()
if client != nil {
writer := &sseWriter{client: client}
s.kernel.SetStreamWriter(writer)
}
resp, err := s.kernel.SendMessage("user", "llm", req.Message)
if err != nil {
json.NewEncoder(w).Encode(map[string]string{
"error": err.Error(),
})
return
}
json.NewEncoder(w).Encode(map[string]string{
"response": resp,
})
}
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
stats := map[string]interface{}{
"tools": 0,
"skills": 0,
"agents": 0,
}
if tm := s.kernel.ToolManager(); tm != nil {
stats["tools"] = tm.Count()
}
if sm := s.kernel.SkillManager(); sm != nil {
stats["skills"] = len(sm.ListSkills())
}
if as := s.kernel.ActorSystem(); as != nil {
stats["agents"] = as.AgentCount()
}
json.NewEncoder(w).Encode(stats)
}
func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var agents []map[string]string
if as := s.kernel.ActorSystem(); as != nil {
for _, info := range as.AgentInfos() {
status := "idle"
if info.Status == actor.StatusProcessing {
status = "running"
}
agents = append(agents, map[string]string{
"id": info.ID,
"status": status,
})
}
}
json.NewEncoder(w).Encode(agents)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(indexHTML))
}