Files
2026-08-29 18:58:29 +08:00

256 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Agent 的 WebSocket 通道
*
* 复用 createSecureWs 的统一鉴权,与 /terminal、/sftp-v2 等保持一致。
*
* 客户端 → 服务端:
* ws_agent_run 发起一个 turn { sessionId?, input, modelId, permission, hostIds }
* 只发本轮输入,历史由后端持有
* ws_agent_approve 回传审批结果 { requestId, approved, scope }
* ws_agent_stop 中断当前 turn
*
* 服务端 → 客户端:统一用 agent_event 事件,payload.type 区分种类。
* 单一事件通道比十几个具名事件更好维护,前端一个 switch 就能分发。
*/
import { createSecureWs } from '../utils/ws-tool.js'
import { parseCookies } from '../utils/verify-auth.js'
import { getClientIP } from '../utils/tools.js'
import { RuntimeState } from '../utils/runtime-state.js'
import { runTurn } from '../ai/runtime.js'
import { resolveApproval, clearSession, listPending } from '../ai/approval.js'
import { clearBySession } from '../ai/output-store.js'
import { disconnect as disconnectHost } from '../ai/ssh.js'
import { PRESETS, DEFAULT_PRESET } from '../ai/policy.js'
import { listConfiguredModels } from '../ai/provider.js'
import { listToolMetadata } from '../ai/tools/registry.js'
// 会话的读取走 REST(controller/agent-session.js),socket 只负责跑 turn。
// 同一件事只留一条路径,避免两边行为漂移。
import { createSession, getSession, appendTurn } from '../ai/session-store.js'
import { resolveTerminalDispatch, reportTerminalDispatchProgress, clearTerminalDispatchBySession } from '../ai/terminal-dispatch.js'
import { claimSessionRun, releaseSessionRun } from '../ai/session-run-lock.js'
const runtimeState = new RuntimeState().getInstance()
/** Plus 是否可用,判定方式与 utils/decrypt-file.js 保持一致 */
function isPlusAvailable() {
return Boolean(runtimeState.getDecryptKey()) && !runtimeState.getPlusKicked()
}
/**
* 审计用的操作者标识。
*
* verifyWsAuthSync 只做校验、不往 socket 上挂用户信息,而 easynode 是
* 单用户面板,所以用 session 前缀 + 客户端 IP 作为操作者标识 —— 事后
* 至少能对上是哪个登录会话、从哪个 IP 发起的。
*/
function resolveOperator(socket) {
const ip = getClientIP(socket.conn.remoteAddress, socket.handshake.headers['x-forwarded-for'])
const { session } = parseCookies(socket.handshake.headers.cookie || '')
const sessionTag = session ? String(session).slice(0, 8) : 'unknown'
return `${ sessionTag }@${ ip }`
}
/** socket.id -> { controller, sessionId, running } */
const active = new Map()
const starting = new Set()
function createEmitter(socket) {
return (payload) => {
if (socket.disconnected) return
socket.emit('agent_event', payload)
}
}
/** 断开该会话用过的 agent SSH 连接 */
function releaseHosts(hostIds) {
for (const hostId of hostIds || []) disconnectHost(hostId)
}
function abortActive(socketId) {
const entry = active.get(socketId)
if (!entry) return
try {
entry.controller.abort()
} catch {
// 忽略
}
}
export default (httpServer) => {
const serverIo = createSecureWs(httpServer, '/ai-agent')
serverIo.on('connection', (socket) => {
const operator = resolveOperator(socket)
logger.info(`ai-agent websocket 已连接: ${ operator }`)
const emit = createEmitter(socket)
// 连接建立后先告诉前端可用的模型与权限预设,避免前端再发一轮 HTTP
Promise.all([listConfiguredModels(), listToolMetadata()])
.then(([config, tools]) => {
emit({
type: 'ready',
models: config.models,
defaultModel: config.defaultModel,
presets: Object.values(PRESETS).map(({ key, label, desc }) => ({ key, label, desc })),
defaultPreset: DEFAULT_PRESET,
plusAvailable: isPlusAvailable(),
tools
})
})
.catch((error) => emit({ type: 'error', message: error.message }))
socket.on('ws_agent_run', async (payload = {}) => {
const { input, modelId, permission, hostIds, hostId, terminalContext, terminalPermission } = payload
const scope = payload.scope === 'terminal' ? 'terminal' : 'ops'
// 目标主机必须由本次请求显式携带。不能在空数组或缺省时回退到
// 历史会话的 hostIds,否则用户取消选择主机后仍可能继续操作旧主机。
const selectedHostIds = Array.isArray(hostIds) ? hostIds : []
if (active.get(socket.id)?.running || starting.has(socket.id)) {
return emit({ type: 'error', message: '当前会话仍有任务在执行,请先停止' })
}
if (!input || typeof input !== 'string' || !input.trim()) {
return emit({ type: 'error', message: '消息内容不能为空' })
}
starting.add(socket.id)
let session
try {
// 会话由后端持有:前端只发本轮输入,历史不经过网络来回搬,
// 也就不存在前端改写历史绕过权限的可能
session = payload.sessionId ? await getSession(payload.sessionId) : null
if (session && (session.scope || 'ops') !== scope) {
starting.delete(socket.id)
return emit({ type: 'error', message: '不能在不同类型的 AI 会话之间混用历史' })
}
if (scope === 'terminal' && (!hostId || !terminalContext || typeof terminalContext.output !== 'string' || terminalContext.output.length > 16 * 1024 || selectedHostIds.length !== 1 || selectedHostIds[0] !== hostId)) {
starting.delete(socket.id)
return emit({ type: 'error', message: '终端 AI 缺少当前终端上下文或目标主机' })
}
if (session && scope === 'terminal' && session.hostId !== hostId) {
starting.delete(socket.id)
return emit({ type: 'error', message: '当前终端与历史会话主机不一致' })
}
if (!session) {
session = await createSession({
hostIds: selectedHostIds,
modelId,
permission: permission || DEFAULT_PRESET,
scope,
hostId: scope === 'terminal' ? hostId : ''
})
emit({ type: 'session_created', session: { id: session.id, title: session.title } })
}
} catch (error) {
starting.delete(socket.id)
return emit({ type: 'error', message: `会话初始化失败: ${ error.message }` })
}
if (socket.disconnected) {
starting.delete(socket.id)
return
}
if (!claimSessionRun(session.id, socket.id)) {
starting.delete(socket.id)
return emit({ type: 'error', message: '该会话正在其他客户端执行,请稍后再试' })
}
const controller = new AbortController()
const targetHosts = selectedHostIds
active.set(socket.id, { controller, sessionId: session.id, running: true, hostIds: targetHosts, scope })
starting.delete(socket.id)
// 前端重连后把仍在挂起的审批重放一遍,否则用户看不到待确认项
const pendingApprovals = listPending(session.id)
if (pendingApprovals.length) {
emit({ type: 'pending_approvals', items: pendingApprovals })
}
const turnCreatedAt = Date.now()
const userMessage = { role: 'user', content: input }
let turnResult = null
try {
// 历史的读取(含按需压缩)由 runtime 负责 —— 压缩要用到模型,
// 而模型是 runtime 解析的,放在这里会多解析一次、也容易两处不一致。
// 写入仍由本层负责,见 finally 里的 appendTurn。
turnResult = await runTurn({
sessionId: session.id,
userId: operator,
userMessage,
modelId: modelId || session.modelId,
permission: permission || session.permission || DEFAULT_PRESET,
hostIds: selectedHostIds,
scope,
terminalHostId: scope === 'terminal' ? hostId : undefined,
terminalPermission: scope === 'terminal' ? terminalPermission : undefined,
terminalContext: scope === 'terminal' ? terminalContext : undefined,
signal: controller.signal,
emit
})
} catch (error) {
logger.error(`[ai-agent] turn 失败: ${ error.message }`)
emit({ type: 'error', message: error.message })
} finally {
// 即使 turn 失败或被中断也要落盘:用户的输入不能丢,
// 已产生的工具调用也必须连同结果一起存,否则历史会残缺
try {
const saved = await appendTurn(session.id, {
newMessages: [userMessage, ...(turnResult?.responseMessages || [])],
createdAt: turnCreatedAt,
toolMeta: turnResult?.toolMeta,
usage: turnResult?.usage
})
emit({
type: 'session_saved',
session: { id: saved.id, title: saved.title, usage: saved.usage, updatedAt: saved.updatedAt }
})
} catch (error) {
logger.error(`[ai-agent] 会话落盘失败: ${ error.message }`)
}
const entry = active.get(socket.id)
if (entry?.controller === controller) active.delete(socket.id)
releaseSessionRun(session.id, socket.id)
}
})
socket.on('ws_agent_approve', (payload = {}) => {
const { requestId, approved, scope } = payload
if (!requestId) return emit({ type: 'error', message: '缺少 requestId' })
const result = resolveApproval(requestId, { approved, scope })
if (!result.ok) emit({ type: 'error', message: result.error })
})
socket.on('ws_agent_stop', () => {
abortActive(socket.id)
emit({ type: 'stopped' })
})
socket.on('ws_terminal_command_result', (payload = {}) => {
const result = resolveTerminalDispatch(payload.requestId, payload)
if (!result.ok) emit({ type: 'error', message: result.error })
})
socket.on('ws_terminal_command_progress', (payload = {}) => {
reportTerminalDispatchProgress(payload.requestId, payload)
})
socket.on('disconnect', () => {
starting.delete(socket.id)
const entry = active.get(socket.id)
abortActive(socket.id)
if (entry?.sessionId) {
clearSession(entry.sessionId)
clearTerminalDispatchBySession(entry.sessionId)
clearBySession(entry.sessionId)
}
// agent 建立的 SSH 连接虽有空闲超时兜底,但用户关掉面板后没必要
// 再占着连接,主动断开
if (entry?.scope !== 'terminal') releaseHosts(entry?.hostIds)
logger.info('ai-agent websocket 已断开')
})
})
}