Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
174 lines
3.8 KiB
Go
174 lines
3.8 KiB
Go
package sse
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// Client 是一个 SSE 连接客户端
|
||
type Client struct {
|
||
ID string
|
||
AgentName string // 非空 = Agent 侧连接
|
||
UserName string // 非空 = 已登录人类用户的前端连接
|
||
Res http.ResponseWriter
|
||
Flusher http.Flusher
|
||
done chan struct{}
|
||
}
|
||
|
||
// Manager 管理所有 SSE 客户端连接
|
||
type Manager struct {
|
||
mu sync.RWMutex
|
||
clients map[string]*Client
|
||
}
|
||
|
||
var Default = &Manager{
|
||
clients: make(map[string]*Client),
|
||
}
|
||
|
||
// AddClient 注册一个新 SSE 客户端(agentName 与 userName 二者恰其一)
|
||
func (m *Manager) AddClient(res http.ResponseWriter, agentName, userName string) *Client {
|
||
flusher, ok := res.(http.Flusher)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
|
||
id := uuid.New().String()[:8]
|
||
client := &Client{
|
||
ID: id,
|
||
AgentName: agentName,
|
||
UserName: userName,
|
||
Res: res,
|
||
Flusher: flusher,
|
||
done: make(chan struct{}),
|
||
}
|
||
|
||
// 设置 SSE 响应头
|
||
res.Header().Set("Content-Type", "text/event-stream")
|
||
res.Header().Set("Cache-Control", "no-cache")
|
||
res.Header().Set("Connection", "keep-alive")
|
||
res.Header().Set("X-Accel-Buffering", "no")
|
||
|
||
m.mu.Lock()
|
||
m.clients[id] = client
|
||
m.mu.Unlock()
|
||
|
||
// 发送连接确认
|
||
client.Send("connected", map[string]string{"id": id})
|
||
|
||
// 启动心跳
|
||
go m.heartbeat(client)
|
||
|
||
fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q)\n", id, agentName, userName)
|
||
return client
|
||
}
|
||
|
||
// RemoveClient 移除一个客户端
|
||
func (m *Manager) RemoveClient(id string) {
|
||
m.mu.Lock()
|
||
if c, ok := m.clients[id]; ok {
|
||
close(c.done)
|
||
delete(m.clients, id)
|
||
fmt.Printf("[SSE] Client disconnected: %s\n", id)
|
||
}
|
||
m.mu.Unlock()
|
||
}
|
||
|
||
// SendToAgent 向指定 Agent 名的所有客户端推送事件
|
||
func (m *Manager) SendToAgent(agentName, eventType string, data interface{}) {
|
||
if agentName == "" {
|
||
return
|
||
}
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
|
||
for _, c := range m.clients {
|
||
if c.AgentName == agentName {
|
||
c.Send(eventType, data)
|
||
}
|
||
}
|
||
}
|
||
|
||
// SendToUser 向指定人类用户的所有前端连接推送事件
|
||
func (m *Manager) SendToUser(userName, eventType string, data interface{}) {
|
||
if userName == "" {
|
||
return
|
||
}
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
|
||
for _, c := range m.clients {
|
||
if c.UserName == userName {
|
||
c.Send(eventType, data)
|
||
}
|
||
}
|
||
}
|
||
|
||
// SendToRecipient 根据收件人名同时尝试 Agent 通道与人类用户通道
|
||
// (三维地址的 name 位共享命名空间,投递时不必先判断对方是人还是 Agent)
|
||
func (m *Manager) SendToRecipient(name, eventType string, data interface{}) {
|
||
if name == "" {
|
||
return
|
||
}
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
|
||
for _, c := range m.clients {
|
||
if c.AgentName == name || c.UserName == name {
|
||
c.Send(eventType, data)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Broadcast 向所有客户端广播事件
|
||
func (m *Manager) Broadcast(eventType string, data interface{}) {
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
|
||
for _, c := range m.clients {
|
||
c.Send(eventType, data)
|
||
}
|
||
}
|
||
|
||
// ClientCount 返回当前连接数
|
||
func (m *Manager) ClientCount() int {
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
return len(m.clients)
|
||
}
|
||
|
||
// Send 向单个客户端发送事件
|
||
func (c *Client) Send(eventType string, data interface{}) {
|
||
defer func() { recover() }() // 防止向已关闭的连接写入 panic
|
||
|
||
jsonData, err := json.Marshal(data)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
|
||
c.Flusher.Flush()
|
||
}
|
||
|
||
// heartbeat 定期发送心跳保活
|
||
func (m *Manager) heartbeat(client *Client) {
|
||
ticker := time.NewTicker(30 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-client.done:
|
||
return
|
||
case <-ticker.C:
|
||
// SSE 注释行作为心跳
|
||
defer func() { recover() }()
|
||
fmt.Fprintf(client.Res, ": heartbeat\n\n")
|
||
client.Flusher.Flush()
|
||
}
|
||
}
|
||
}
|