初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import { NextRequest } from 'next/server'
import { oapiClient, sendResponse } from '../../oapi-client'
import { getDefaultAgentId } from '../../nova-config'
export async function GET(req: NextRequest) {
const conversationId = req.nextUrl.searchParams.get('conversation_id')
const pageNo = req.nextUrl.searchParams.get('page_no')
const pageSize = req.nextUrl.searchParams.get('page_size')
const res = await oapiClient.get('/v1/oapi/super_agent/chat/event_list', {
agent_id: getDefaultAgentId(),
conversation_id: conversationId,
page_no: pageNo,
page_size: pageSize,
})
return sendResponse(res)
}

View File

@@ -0,0 +1,14 @@
import { NextRequest } from 'next/server'
import { oapiClient, sendResponse } from '../../oapi-client'
export async function POST(req: NextRequest) {
const body = req.body ? await req.json() : {}
const { file_path, task_id } = body
const res = await oapiClient.post('/v1/super_agent/chat/oss_url', {
file_path: file_path,
task_id: task_id,
})
return sendResponse(res)
}

View File

@@ -0,0 +1,11 @@
import { NextRequest } from 'next/server'
import { oapiClient, sendResponse } from '../../oapi-client'
export async function GET(req: NextRequest) {
const conversationId = req.nextUrl.searchParams.get('conversation_id')
const res = await oapiClient.post('/v1/oapi/super_agent/stop_chat', {
conversation_id: conversationId,
})
return sendResponse(res)
}

View File

@@ -0,0 +1,7 @@
import { oapiClient, sendResponse } from '../../oapi-client'
export async function POST(req: Request) {
const body = req.body ? await req.json() : {}
const res = await oapiClient.post('/v1/super_agent/chat/get_conversation_info_list', body)
return sendResponse(res)
}

View File

@@ -0,0 +1,12 @@
import { oapiClient, sendResponse } from '../oapi-client'
import { getDefaultAgentId } from '../nova-config'
export async function GET() {
const res = await oapiClient.get('/v1/oapi/super_agent/chat/conversation_list', {
page_no: 1,
page_size: 10,
agent_id: getDefaultAgentId(),
})
return sendResponse(res)
}

View File

@@ -0,0 +1,7 @@
import { oapiClient, sendResponse } from '../../oapi-client'
export async function POST(req: Request) {
const body = req.body ? await req.json() : {}
const res = await oapiClient.post('/v1/super_agent/file_upload_record/create', body)
return sendResponse(res)
}

View File

@@ -0,0 +1,12 @@
import { oapiClient, sendResponse } from '../../oapi-client'
export async function POST(req: Request) {
const body = req.body ? await req.json() : {}
const key = body.file_path || body.key
const res = await oapiClient.post('v1/oss/sign_url', {
key,
params: body.params,
})
return sendResponse(res)
}

View File

@@ -0,0 +1,8 @@
import { oapiClient, sendResponse } from '../../oapi-client'
export async function POST(req: Request) {
const body = await req.formData()
const res = await oapiClient.post('/v1/oapi/super_agent/chat/file_upload', body)
return sendResponse(res)
}

24
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
export async function GET() {
return NextResponse.json(
{ success: true, message: 'ok' },
{
status: 200,
headers: corsHeaders,
}
)
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: corsHeaders,
})
}

68
app/api/info/route.ts Normal file
View File

@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { oapiClient } from '../oapi-client'
import { getDefaultAgentId, getDefaultAgentName } from '../nova-config'
import { getProjectId, getUserId } from '@/utils/getAuth'
const buildWssUrl = () => {
const baseUrl = process.env.NOVA_BASE_URL!
const wssBase = baseUrl.replace('https://', 'wss://').replace('http://', 'ws://')
const authorization = process.env.NOVA_ACCESS_KEY
const tenantId = process.env.NOVA_TENANT_ID
return `${wssBase}/v1/super_agent/chat/completions?Authorization=${authorization}&X-Locale=zh&X-Region=CN&Tenant-Id=${tenantId}`
}
export async function GET() {
const agentId = getDefaultAgentId()
const agentName = getDefaultAgentName()
const list = await oapiClient.get('/v1/oapi/super_agent/chat/conversation_list', {
page_no: 1,
page_size: 10,
agent_id: agentId,
})
const conversationId = list.data?.[0]?.conversation_id
if (!conversationId) {
const res = await oapiClient.post('/v1/oapi/super_agent/chat/create_conversation', {
agent_id: agentId,
title: 'new conversation',
conversation_type: 'REACTUS',
external_app_id: getProjectId(),
external_user_id: getUserId(),
})
return NextResponse.json(
{
code: 0,
message: 'ok',
data: {
apiBaseUrl: '/api',
agent_id: agentId,
agent_name: agentName,
conversation_id: res.conversation_id,
wssUrl: buildWssUrl(),
token: process.env.NOVA_ACCESS_KEY,
tenantId: process.env.NOVA_TENANT_ID,
},
},
{ status: 200 }
)
}
return NextResponse.json(
{
success: true,
data: {
apiBaseUrl: '/api',
agent_id: agentId,
agent_name: agentName,
conversation_id: conversationId,
wssUrl: buildWssUrl(),
token: process.env.NOVA_ACCESS_KEY,
tenantId: process.env.NOVA_TENANT_ID,
},
},
{ status: 200 }
)
}

6
app/api/llm-client.ts Normal file
View File

@@ -0,0 +1,6 @@
import { SkillsClient } from '@/llm'
export const llmClient = new SkillsClient({
apiKey: process.env.LLM_API_KEY!,
baseUrl: process.env.LLM_BASE_URL!,
})

38
app/api/nova-config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { readFileSync } from 'fs'
import { join } from 'path'
interface NovaAgent {
agent_id: string
agent_name: string
agent_description: string
}
interface NovaConfig {
agents: NovaAgent[]
}
let _config: NovaConfig | null = null
export function getNovaConfig(): NovaConfig {
if (!_config) {
const configPath = join(process.cwd(), '.nova', 'config.json')
_config = JSON.parse(readFileSync(configPath, 'utf-8'))
}
return _config!
}
export function getDefaultAgentId(): string {
const config = getNovaConfig()
if (!config.agents.length) {
throw new Error('No agents configured in .nova/config.json')
}
return config.agents[0].agent_id
}
export function getDefaultAgentName(): string {
const config = getNovaConfig()
if (!config.agents.length) {
throw new Error('No agents configured in .nova/config.json')
}
return config.agents[0].agent_name
}

85
app/api/oapi-client.ts Normal file
View File

@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server';
import { HTTPClient } from '@/http';
import { logger } from '@/utils/logger'
export const oapiClient = new HTTPClient({
baseURL: process.env.NOVA_BASE_URL,
headers: {
'Content-Type': 'application/json',
'Tenant-Id': process.env.NOVA_TENANT_ID,
'Authorization': process.env.NOVA_ACCESS_KEY,
}
}, {
onRequest: async (config) => {
logger.info('oapi request start', {
method: config.method,
url: config.url,
body: config.body,
query: config.query,
headers: config.headers,
})
return config
},
onResponse: async (response, config) => {
logger.info('oapi response received', {
method: config.method,
url: config.url,
})
},
onError: (error, config) => {
logger.error('oapi request error', {
method: config.method,
url: config.url,
error
})
}
})
export const sendResponse = (res: any) => {
// 兼容 HTTP 层 defaultGetResult 的解包结果:
// 若上游返回 { success: true, data: ... },此处可能拿到 data 本身(含 null
if (res == null || typeof res !== 'object' || !('success' in res)) {
return NextResponse.json(
{
success: true,
data: res,
},
{ status: 200 }
)
}
if (res.success === false) {
logger.error('oapi request failed', {
code: res.code,
message: res.message,
request_id: res.request_id,
})
return NextResponse.json(
{
code: res.code,
message: res.message,
request_id: res.request_id,
success: false,
},
{ status: 500 }
)
}
logger.info('oapi request success', {
code: res.code,
request_id: res.request_id,
})
return NextResponse.json(
{
code: res.code,
request_id: res.request_id,
success: true,
data: res,
},
{ status: 200 }
)
}

View File

@@ -0,0 +1,55 @@
import type { HttpDefine } from '@/http/type'
import { oapiClient } from './oapi-client'
export type DataWrapped<T> = { data: T }
async function dataInterceptor<T>(promise: Promise<T>): Promise<DataWrapped<T>> {
const result = await promise
return { data: result }
}
export const oapiDataClient = {
request<T = unknown>(config: HttpDefine): Promise<DataWrapped<T>> {
return dataInterceptor(oapiClient.request<T>(config))
},
get<T = unknown>(
url: string,
query?: Record<string, unknown>,
config?: HttpDefine,
): Promise<DataWrapped<T>> {
return dataInterceptor(oapiClient.get<T>(url, query, config))
},
post<T = unknown>(
url: string,
body?: Record<string, unknown> | FormData,
config?: HttpDefine,
): Promise<DataWrapped<T>> {
return dataInterceptor(oapiClient.post<T>(url, body, config))
},
put<T = unknown>(
url: string,
body?: Record<string, unknown> | FormData,
config?: HttpDefine,
): Promise<DataWrapped<T>> {
return dataInterceptor(oapiClient.put<T>(url, body, config))
},
patch<T = unknown>(
url: string,
body?: Record<string, unknown> | FormData,
config?: HttpDefine,
): Promise<DataWrapped<T>> {
return dataInterceptor(oapiClient.patch<T>(url, body, config))
},
delete<T = unknown>(
url: string,
query?: Record<string, unknown>,
config?: HttpDefine,
): Promise<DataWrapped<T>> {
return dataInterceptor(oapiClient.delete<T>(url, query, config))
},
}

View File

@@ -0,0 +1,6 @@
import { oapiClient, sendResponse } from '../../oapi-client'
export async function GET() {
const res = await oapiClient.get('/v1/oss/upload_sts')
return sendResponse(res)
}

View File

@@ -0,0 +1,7 @@
import { oapiClient, sendResponse } from '../../../oapi-client'
export async function POST(req: Request) {
const body = await req.formData()
const res = await oapiClient.post('/v1/plugins/skill/upload', body)
return sendResponse(res)
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server'
import { getDefaultAgentId } from '@/app/api/nova-config'
export async function GET() {
const baseUrl = process.env.NOVA_BASE_URL || ''
const agentId = getDefaultAgentId()
const tenantId = process.env.NOVA_TENANT_ID || ''
let stats = {
activeConnections: 0,
totalMessages: 0,
averageResponseTime: 0,
}
try {
const { getStats } = await import('@/remote-control/shared/nova-bridge')
stats = getStats()
} catch {
// nova-bridge 未加载
}
let status: 'connected' | 'disconnected' = 'disconnected'
try {
const response = await fetch(`${baseUrl}/health`, {
signal: AbortSignal.timeout(5000),
})
if (response.ok) {
status = 'connected'
}
} catch {
// 连接失败
}
return NextResponse.json({
baseUrl,
agentId,
tenantId,
status,
...stats,
})
}

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server'
import { ConfigManager, reconnectAllPlatforms } from '@/remote-control/config/manager'
export async function GET() {
const configManager = ConfigManager.getInstance()
await configManager.ensureLoaded()
const config = configManager.getMasked()
return NextResponse.json(config)
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const configManager = ConfigManager.getInstance()
await configManager.ensureLoaded()
// Replace masked values with existing real values so we don't overwrite secrets
const merged = stripMaskedValues(body, configManager.get())
const validationError = validateConfig(merged)
if (validationError) {
return NextResponse.json(
{ success: false, error: validationError },
{ status: 400 }
)
}
// skipEmit: lifecycle is managed explicitly below, avoid double-triggering
await configManager.update(merged, { skipEmit: true })
// After saving, explicitly manage bot lifecycle:
// - Stop all disabled bots
// - Reconnect (stop + start) all enabled bots
const config = configManager.get()
const errors: string[] = []
await reconnectAllPlatforms(config, errors)
return NextResponse.json({
success: true,
message: errors.length > 0
? `配置已保存,部分渠道连接异常: ${errors.join('; ')}`
: '配置已保存并生效',
})
} catch (error) {
return NextResponse.json(
{ success: false, error: '保存配置失败' },
{ status: 500 }
)
}
}
const MASK_PREFIX = '••••'
/**
* If the frontend sends back a masked value (e.g. "••••pbYI"), replace it
* with the real value from the current config so we never overwrite secrets.
*/
function stripMaskedValues(
incoming: Record<string, Record<string, unknown>>,
current: Record<string, Record<string, unknown>>,
): Record<string, Record<string, unknown>> {
const result: Record<string, Record<string, unknown>> = {}
for (const platform of Object.keys(incoming)) {
const incomingPlatform = incoming[platform]
const currentPlatform = current[platform] ?? {}
const merged: Record<string, unknown> = {}
for (const key of Object.keys(incomingPlatform)) {
const val = incomingPlatform[key]
if (typeof val === 'string' && val.startsWith(MASK_PREFIX)) {
// Keep the real value
merged[key] = currentPlatform[key] ?? ''
} else {
merged[key] = val
}
}
result[platform] = merged
}
return result
}
function validateConfig(config: Record<string, unknown>): string | null {
const discord = config.discord as Record<string, unknown> | undefined
const dingtalk = config.dingtalk as Record<string, unknown> | undefined
const lark = config.lark as Record<string, unknown> | undefined
if (discord?.enabled && !discord?.botToken) {
return 'Discord Bot Token 不能为空'
}
if (dingtalk?.enabled && (!dingtalk?.clientId || !dingtalk?.clientSecret)) {
return '钉钉 Client ID 和 Client Secret 不能为空'
}
if (lark?.enabled) {
if (!lark?.appId || !lark?.appSecret) {
return '飞书 App ID 和 App Secret 不能为空'
}
}
const telegram = config.telegram as Record<string, unknown> | undefined
const slack = config.slack as Record<string, unknown> | undefined
if (telegram?.enabled && !telegram?.botToken) {
return 'Telegram Bot Token 不能为空'
}
if (slack?.enabled && (!slack?.botToken || !slack?.appToken)) {
return 'Slack Bot Token 和 App Token 不能为空'
}
return null
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { BotLogger } from '@/remote-control/shared/logger'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const limit = parseInt(searchParams.get('limit') || '100')
const offset = parseInt(searchParams.get('offset') || '0')
const platform = searchParams.get('platform') as 'discord' | 'dingtalk' | null
const eventType = searchParams.get('eventType') || null
const severity = searchParams.get('severity') as 'info' | 'warning' | 'error' | null
const { logs, total } = BotLogger.getLogs({
limit,
offset,
platform: platform || undefined,
eventType: eventType || undefined,
severity: severity || undefined,
})
return NextResponse.json({
logs,
total,
hasMore: offset + logs.length < total,
})
}
export async function DELETE() {
BotLogger.clear()
return NextResponse.json({
success: true,
message: '日志已清空',
})
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server'
import { ConfigManager } from '@/remote-control/config/manager'
export async function GET() {
const mgr = ConfigManager.getInstance()
await mgr.ensureLoaded()
const config = mgr.get()
const statuses: Record<string, unknown> = {}
// Discord Bot 状态
if (config.discord.enabled) {
try {
const discordBot = await import('@/remote-control/bots/discord')
statuses.discord = discordBot.getStatus()
} catch {
statuses.discord = { platform: 'discord', status: 'disconnected', error: 'Bot 模块未加载' }
}
} else {
statuses.discord = { platform: 'discord', status: 'disconnected' }
}
// 钉钉 Bot 状态
if (config.dingtalk.enabled) {
try {
const dingtalkBot = await import('@/remote-control/bots/dingtalk')
statuses.dingtalk = dingtalkBot.getStatus()
} catch {
statuses.dingtalk = { platform: 'dingtalk', status: 'disconnected', error: 'Bot 模块未加载' }
}
} else {
statuses.dingtalk = { platform: 'dingtalk', status: 'disconnected' }
}
// 飞书 Bot 状态
if (config.lark.enabled) {
try {
const larkBot = await import('@/remote-control/bots/lark')
statuses.lark = larkBot.getStatus()
} catch {
statuses.lark = { platform: 'lark', status: 'disconnected', error: 'Bot 模块未加载' }
}
} else {
statuses.lark = { platform: 'lark', status: 'disconnected' }
}
// Telegram Bot 状态
if (config.telegram.enabled) {
try {
const telegramBot = await import('@/remote-control/bots/telegram')
statuses.telegram = telegramBot.getStatus()
} catch {
statuses.telegram = { platform: 'telegram', status: 'disconnected', error: 'Bot 模块未加载' }
}
} else {
statuses.telegram = { platform: 'telegram', status: 'disconnected' }
}
// Slack Bot 状态
if (config.slack.enabled) {
try {
const slackBot = await import('@/remote-control/bots/slack')
statuses.slack = slackBot.getStatus()
} catch {
statuses.slack = { platform: 'slack', status: 'disconnected', error: 'Bot 模块未加载' }
}
} else {
statuses.slack = { platform: 'slack', status: 'disconnected' }
}
return NextResponse.json(statuses)
}

View File

@@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server'
import { ConfigManager } from '@/remote-control/config/manager'
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Poll a bot's getStatus() until it leaves 'connecting' state,
* or until timeout (default 10s). Returns the final status.
*/
async function waitForConnection(
getStatus: () => { status: string; error?: string },
timeoutMs = 10000,
intervalMs = 500,
): Promise<{ status: string; error?: string }> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const s = getStatus()
// 'connected' or 'disconnected' (with error) means we have a definitive answer
if (s.status !== 'connecting') return s
await sleep(intervalMs)
}
return getStatus()
}
export async function POST(request: NextRequest) {
try {
const { platform } = await request.json()
if (platform !== 'discord' && platform !== 'dingtalk' && platform !== 'lark' && platform !== 'telegram' && platform !== 'slack') {
return NextResponse.json({ success: false, error: '无效的平台' }, { status: 400 })
}
const mgr = ConfigManager.getInstance()
await mgr.ensureLoaded()
const config = mgr.get()
// Reject test for disabled platforms
const platformConfig = config[platform as keyof typeof config] as { enabled: boolean }
if (!platformConfig?.enabled) {
return NextResponse.json({ success: false, error: '该渠道已禁用,请先启用后再测试' }, { status: 400 })
}
if (platform === 'discord') {
if (!config.discord.botToken) {
return NextResponse.json({ success: false, error: 'Discord Bot Token 未配置' })
}
try {
const bot = await import('@/remote-control/bots/discord')
await bot.stopBot()
await bot.startBot(config.discord.botToken)
const status = await waitForConnection(() => bot.getStatus())
return NextResponse.json({
success: status.status === 'connected',
status,
error: status.status !== 'connected' ? (status.error || '连接超时,请检查 Token 是否正确') : undefined,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return NextResponse.json({ success: false, error: `Discord 连接失败: ${msg}` })
}
}
if (platform === 'dingtalk') {
if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) {
return NextResponse.json({ success: false, error: '钉钉 Client ID 或 Client Secret 未配置' })
}
try {
const bot = await import('@/remote-control/bots/dingtalk')
await bot.stopBot()
await bot.startBot(config.dingtalk.clientId, config.dingtalk.clientSecret)
const status = await waitForConnection(() => bot.getStatus())
return NextResponse.json({
success: status.status === 'connected',
status,
error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return NextResponse.json({ success: false, error: `钉钉连接失败: ${msg}` })
}
}
if (platform === 'lark') {
if (!config.lark.appId || !config.lark.appSecret) {
return NextResponse.json({ success: false, error: '飞书 App ID 或 App Secret 未配置' })
}
try {
const bot = await import('@/remote-control/bots/lark')
await bot.stopBot()
await bot.startBot(config.lark.appId, config.lark.appSecret)
const status = await waitForConnection(() => bot.getStatus())
return NextResponse.json({
success: status.status === 'connected',
status,
error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return NextResponse.json({ success: false, error: `飞书连接失败: ${msg}` })
}
}
if (platform === 'telegram') {
if (!config.telegram.botToken) {
return NextResponse.json({ success: false, error: 'Telegram Bot Token 未配置' })
}
try {
const bot = await import('@/remote-control/bots/telegram')
await bot.stopBot()
await bot.startBot(config.telegram.botToken)
const status = await waitForConnection(() => bot.getStatus())
return NextResponse.json({
success: status.status === 'connected',
status,
error: status.status !== 'connected' ? (status.error || '连接超时,请检查 Token 是否正确') : undefined,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return NextResponse.json({ success: false, error: `Telegram 连接失败: ${msg}` })
}
}
if (platform === 'slack') {
if (!config.slack.botToken || !config.slack.appToken) {
return NextResponse.json({ success: false, error: 'Slack Bot Token 或 App Token 未配置' })
}
try {
const bot = await import('@/remote-control/bots/slack')
await bot.stopBot()
await bot.startBot(config.slack.botToken, config.slack.appToken)
const status = await waitForConnection(() => bot.getStatus())
return NextResponse.json({
success: status.status === 'connected',
status,
error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return NextResponse.json({ success: false, error: `Slack 连接失败: ${msg}` })
}
}
} catch {
return NextResponse.json({ success: false, error: '请求解析失败' }, { status: 400 })
}
}

View File

@@ -0,0 +1,11 @@
import { oapiClient, sendResponse } from '@/app/api/oapi-client'
export async function POST(
req: Request,
{ params }: { params: Promise<{ teamId: string }> },
) {
const body = req.body ? await req.json() : {}
const { teamId } = await params
const res = await oapiClient.post(`/v1/team/${teamId}/plugins`, body)
return sendResponse(res)
}

View File

@@ -0,0 +1,133 @@
import { NextRequest } from 'next/server'
import { oapiClient, sendResponse } from '../../oapi-client'
function buildUrl(path: string[]) {
return `/v1/${path.join('/')}`
}
function buildFullForwardUrl(path: string[], query?: Record<string, string>) {
const base = process.env.NOVA_BASE_URL || ''
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base
const pathname = buildUrl(path)
const url = new URL(`${normalizedBase}${pathname}`)
Object.entries(query || {}).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
return url.toString()
}
function logForwardRequest(args: {
method: string
path: string[]
query?: Record<string, string>
body?: unknown
}) {
const { method, path, query, body } = args
console.log('[api/v1 proxy] forward', {
method,
url: buildFullForwardUrl(path, query),
query: query || {},
body: body ?? null,
})
}
function logForwardResponse(method: string, path: string[], res: unknown) {
const url = buildUrl(path)
try {
console.log('[api/v1 proxy] response', {
method,
url,
raw: JSON.stringify(res, null, 2),
})
} catch {
console.log('[api/v1 proxy] response', {
method,
url,
raw: res,
})
}
}
function normalizeQuery(searchParams: URLSearchParams) {
const query: Record<string, string> = {}
searchParams.forEach((value, key) => {
// params[task_id] -> task_id
if (key.startsWith('params[') && key.endsWith(']')) {
const realKey = key.slice(7, -1)
if (realKey) query[realKey] = value
return
}
query[key] = value
})
return query
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params
const query = normalizeQuery(req.nextUrl.searchParams)
logForwardRequest({ method: 'GET', path, query })
const res = await oapiClient.get(buildUrl(path), query)
logForwardResponse('GET', path, res)
return sendResponse(res)
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
const body = req.body ? await req.json() : {}
const { path } = await params
logForwardRequest({ method: 'POST', path, body })
const res = await oapiClient.post(buildUrl(path), body)
logForwardResponse('POST', path, res)
return sendResponse(res)
}
export async function PUT(
req: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
const body = req.body ? await req.json() : {}
const { path } = await params
logForwardRequest({ method: 'PUT', path, body })
const res = await oapiClient.request({
url: buildUrl(path),
method: 'put',
body,
})
logForwardResponse('PUT', path, res)
return sendResponse(res)
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
const body = req.body ? await req.json() : {}
const { path } = await params
logForwardRequest({ method: 'PATCH', path, body })
const res = await oapiClient.request({
url: buildUrl(path),
method: 'patch',
body,
})
logForwardResponse('PATCH', path, res)
return sendResponse(res)
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params
const query = normalizeQuery(req.nextUrl.searchParams)
logForwardRequest({ method: 'DELETE', path, query })
const res = await oapiClient.delete(buildUrl(path), query)
logForwardResponse('DELETE', path, res)
return sendResponse(res)
}

570
app/api/websocket/index.ts Normal file
View File

@@ -0,0 +1,570 @@
/**
* WebSocket 客户端封装
*
* 提供自动重连、心跳检测、网络状态监听等功能
*/
function latest<T>(value: T) {
const ref = { current: value }
return ref
}
export const ReadyState = {
Connecting: 0,
Open: 1,
Closing: 2,
Closed: 3,
} as const
export type ReadyState = (typeof ReadyState)[keyof typeof ReadyState]
export interface Result {
sendMessage: WebSocket['send']
disconnect: () => void
connect: () => void
readyState: ReadyState
webSocketIns?: WebSocket
clearHeartbeat: () => void
switchConversation: (conversationId: string) => void
cleanup: () => void
}
export interface HeartbeatOptions {
/** 心跳间隔,默认 20000ms */
heartbeatInterval?: number
/** 心跳超时,默认 22000ms */
heartbeatTimeout?: number
/** 心跳消息,默认 { message_type: 'ping' } */
heartbeatMessage?: string | object | (() => string | object)
/** 心跳响应类型,默认 'pong' */
heartbeatResponseType?: string
}
export interface ApiEvent {
event_id: string
event_type?: string
role?: 'user' | 'assistant' | 'system'
content?: {
text?: string
content?: string
[key: string]: unknown
}
created_at?: string
task_id?: string
is_display?: boolean
metadata?: Record<string, unknown>
stream?: boolean
event_status?: string
[key: string]: unknown
}
// WebSocket 事件类型定义
type WSOpenEvent = Event
interface WSCloseEvent {
code?: number
reason?: string
wasClean?: boolean
}
interface WSMessageEvent {
data: string | ArrayBuffer | Blob
}
type WSErrorEvent = Event
export interface Options {
/** 重连次数限制,默认 3 */
reconnectLimit?: number
/** 重连间隔,默认 3000ms */
reconnectInterval?: number
/** 是否手动连接,默认 false */
manual?: boolean
/** 连接成功回调 */
onOpen?: (event: WSOpenEvent, instance: WebSocket) => void
/** 连接关闭回调 */
onClose?: (event: WSCloseEvent, instance: WebSocket) => void
/** 收到消息回调 */
onMessage?: (message: ApiEvent, instance: WebSocket) => void
/** 连接错误回调 */
onError?: (event: WSErrorEvent, instance: WebSocket) => void
/** WebSocket 协议 */
protocols?: string | string[]
/** 心跳配置,传 false 禁用心跳 */
heartbeat?: HeartbeatOptions | boolean
/** 是否监听网络状态变化,默认 true */
enableNetworkListener?: boolean
/** 是否监听文档可见性变化,默认 true */
enableVisibilityListener?: boolean
/** 重连延迟,默认 300ms */
reconnectDelay?: number
/** 重连防抖时间,默认 1000ms */
reconnectDebounce?: number
/** 获取认证 Token */
getToken?: () => string | undefined
/** 获取租户 ID */
getTenantId?: () => string | undefined
}
/**
* 创建 WebSocket 客户端
*/
export function createWebSocketClient(
socketUrl: string,
options: Options = {},
): Result {
const {
reconnectLimit = 3,
reconnectInterval = 3 * 1000,
manual = false,
onOpen,
onClose,
onMessage,
onError,
protocols,
heartbeat: enableHeartbeat = true,
enableNetworkListener = true,
enableVisibilityListener = true,
reconnectDelay = 300,
reconnectDebounce = 1000,
getToken,
getTenantId,
} = options
const heartbeatOptions: HeartbeatOptions =
typeof enableHeartbeat === 'object' ? enableHeartbeat : {}
const {
heartbeatInterval = 20000,
heartbeatTimeout = 22000,
heartbeatMessage = { message_type: 'ping' },
heartbeatResponseType = 'pong',
} = heartbeatOptions
// 提前声明函数,避免使用前未定义
let disconnectFn: () => void = () => {
throw new Error('disconnectFn not initialized')
}
let connectWsFn: () => void = () => {
throw new Error('connectWsFn not initialized')
}
const onOpenRef = latest(onOpen)
const onCloseRef = latest(onClose)
const onMessageRef = latest(onMessage)
const onErrorRef = latest(onError)
// 确保 ref 始终指向最新的回调
if (onMessage) {
onMessageRef.current = onMessage
}
const reconnectTimesRef = latest(0)
const reconnectTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
const websocketRef = latest<WebSocket | undefined>(undefined)
const readyStateRef = latest<ReadyState>(ReadyState.Closed)
// 心跳相关
const heartbeatTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
const heartbeatTimeoutTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
const waitingForPongRef = latest(false)
// 网络和可见性状态
const isOnlineRef = latest(typeof navigator !== 'undefined' ? navigator.onLine : true)
const isVisibleRef = latest(typeof document !== 'undefined' ? !document.hidden : true)
// 重连防抖定时器
const reconnectDebounceTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
// 更新 readyState 的辅助函数
const setReadyState = (state: ReadyState) => {
readyStateRef.current = state
}
// 获取当前 readyState
const getReadyState = (): ReadyState => {
if (websocketRef.current) {
const wsState = websocketRef.current.readyState
if (wsState === WebSocket.CONNECTING) return ReadyState.Connecting
if (wsState === WebSocket.OPEN) return ReadyState.Open
if (wsState === WebSocket.CLOSING) return ReadyState.Closing
if (wsState === WebSocket.CLOSED) return ReadyState.Closed
}
return readyStateRef.current
}
// 清除心跳相关定时器
const clearHeartbeat = () => {
if (heartbeatTimerRef.current) {
clearTimeout(heartbeatTimerRef.current)
heartbeatTimerRef.current = undefined
}
if (heartbeatTimeoutTimerRef.current) {
clearTimeout(heartbeatTimeoutTimerRef.current)
heartbeatTimeoutTimerRef.current = undefined
}
waitingForPongRef.current = false
}
// 处理心跳超时
const handlePingTimeout = () => {
if (!isOnlineRef.current) {
waitingForPongRef.current = false
clearHeartbeat()
return
}
waitingForPongRef.current = false
clearHeartbeat()
disconnectFn()
}
// 发送心跳
const sendHeartbeat = () => {
if (!isOnlineRef.current) {
return
}
if (waitingForPongRef.current) {
return
}
if (websocketRef.current && getReadyState() === ReadyState.Open) {
try {
const message =
typeof heartbeatMessage === 'function'
? heartbeatMessage()
: heartbeatMessage
websocketRef.current.send(
typeof message === 'string' ? message : JSON.stringify(message),
)
waitingForPongRef.current = true
heartbeatTimeoutTimerRef.current = setTimeout(
handlePingTimeout,
heartbeatTimeout,
)
} catch {
clearHeartbeat()
}
} else {
clearHeartbeat()
}
}
// 处理心跳响应
const handlePongReceived = () => {
if (!waitingForPongRef.current) {
return
}
waitingForPongRef.current = false
if (heartbeatTimeoutTimerRef.current) {
clearTimeout(heartbeatTimeoutTimerRef.current)
heartbeatTimeoutTimerRef.current = undefined
}
heartbeatTimerRef.current = setTimeout(sendHeartbeat, heartbeatInterval)
}
// 启动心跳
const startHeartbeat = () => {
if (!enableHeartbeat) return
clearHeartbeat()
heartbeatTimerRef.current = setTimeout(sendHeartbeat, 1000)
}
// 处理原始消息,检查是否是心跳响应
const handleRawMessage = (messageData: string): boolean => {
try {
const rawMessage = JSON.parse(messageData)
if (
rawMessage.data?.message_type === heartbeatResponseType ||
rawMessage.message_type === heartbeatResponseType
) {
handlePongReceived()
return true
}
return false
} catch {
return false
}
}
const reconnect = () => {
if (
reconnectTimesRef.current < reconnectLimit &&
getReadyState() !== ReadyState.Open
) {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
}
reconnectTimerRef.current = setTimeout(() => {
connectWsFn()
reconnectTimesRef.current++
}, reconnectInterval)
}
}
connectWsFn = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
}
if (websocketRef.current) {
websocketRef.current.close()
}
// 构建 WebSocket URL
const url = new URL(socketUrl)
// 添加认证信息
const token = getToken?.()
const tenantId = getTenantId?.()
if (token) {
url.searchParams.set('Authorization', token)
}
if (tenantId) {
url.searchParams.set('Tenant-Id', tenantId)
}
const ws = new WebSocket(url.toString(), protocols)
setReadyState(ReadyState.Connecting)
ws.onerror = event => {
if (websocketRef.current !== ws) {
return
}
reconnect()
onErrorRef.current?.(event, ws)
setReadyState(ReadyState.Closed)
}
ws.onopen = event => {
if (websocketRef.current !== ws) {
return
}
onOpenRef.current?.(event, ws)
reconnectTimesRef.current = 0
setReadyState(ReadyState.Open)
startHeartbeat()
}
ws.onmessage = (message: WSMessageEvent) => {
if (websocketRef.current !== ws) {
return
}
const messageData =
typeof message.data === 'string' ? message.data : String(message.data)
// 先检查是否是心跳响应
if (enableHeartbeat && handleRawMessage(messageData)) {
return
}
// 解析消息并触发回调
try {
const parsedMessage: ApiEvent = JSON.parse(messageData)
onMessageRef.current?.(parsedMessage, ws)
} catch {
// 如果解析失败,尝试作为原始数据传递
console.warn('[WebSocket] Failed to parse message:', messageData)
}
}
ws.onclose = event => {
onCloseRef.current?.(event, ws)
clearHeartbeat()
// closed by server
if (websocketRef.current === ws) {
reconnect()
}
// closed by disconnect or closed by server
if (!websocketRef.current || websocketRef.current === ws) {
setReadyState(ReadyState.Closed)
}
}
websocketRef.current = ws
}
const sendMessage: WebSocket['send'] = message => {
const currentState = getReadyState()
if (currentState === ReadyState.Open) {
websocketRef.current?.send(message)
} else {
throw new Error('WebSocket disconnected')
}
}
// 切换会话
const switchConversation = (conversationId: string) => {
// 检查网络状态
if (!isOnlineRef.current) {
throw new Error('网络连接异常,无法切换会话')
}
// 检查 WebSocket 连接状态
const currentState = getReadyState()
if (!websocketRef.current || currentState !== ReadyState.Open) {
throw new Error('WebSocket 未连接,无法切换会话')
}
try {
const message = JSON.stringify({
message_type: 'switch_conversation',
conversation_id: conversationId,
})
websocketRef.current.send(message)
} catch (error) {
throw new Error(`切换会话失败: ${error}`)
}
}
const connect = () => {
reconnectTimesRef.current = 0
connectWsFn()
}
disconnectFn = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
}
if (reconnectDebounceTimerRef.current) {
clearTimeout(reconnectDebounceTimerRef.current)
}
reconnectTimesRef.current = reconnectLimit
clearHeartbeat()
websocketRef.current?.close(1000, '手动断开')
websocketRef.current = undefined
setReadyState(ReadyState.Closed)
}
// 处理网络断开
const handleNetworkOffline = () => {
clearHeartbeat()
const currentState = getReadyState()
if (
currentState === ReadyState.Open ||
currentState === ReadyState.Connecting
) {
disconnectFn()
}
}
// 重连函数 - 统一处理重连逻辑
const attemptReconnect = () => {
// 清除之前的防抖定时器
if (reconnectDebounceTimerRef.current) {
clearTimeout(reconnectDebounceTimerRef.current)
}
reconnectDebounceTimerRef.current = setTimeout(() => {
const currentState = getReadyState()
// 已连接或正在连接时跳过
if (
currentState === ReadyState.Open ||
currentState === ReadyState.Connecting
) {
return
}
clearHeartbeat()
const isClosed =
currentState === ReadyState.Closed ||
currentState === ReadyState.Closing
if (isClosed) {
connect()
} else {
disconnectFn()
setTimeout(() => {
if (
isOnlineRef.current &&
getReadyState() !== ReadyState.Open &&
getReadyState() !== ReadyState.Connecting
) {
connect()
}
}, reconnectDelay)
}
}, reconnectDebounce)
}
// 网络状态监听
let cleanupNetworkListener: (() => void) | undefined
if (enableNetworkListener && typeof window !== 'undefined') {
const handleOnline = () => {
isOnlineRef.current = true
attemptReconnect()
}
const handleOffline = () => {
isOnlineRef.current = false
handleNetworkOffline()
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
cleanupNetworkListener = () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}
// 文档可见性监听
let cleanupVisibilityListener: (() => void) | undefined
if (enableVisibilityListener && typeof document !== 'undefined') {
const handleVisibilityChange = () => {
const isVisible = !document.hidden
isVisibleRef.current = isVisible
if (isVisible && isOnlineRef.current) {
const currentState = getReadyState()
if (
currentState === ReadyState.Closed ||
currentState === ReadyState.Closing
) {
attemptReconnect()
}
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
cleanupVisibilityListener = () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}
// 自动连接
if (!manual && socketUrl) {
connect()
}
// 清理函数
const cleanup = () => {
disconnectFn()
cleanupNetworkListener?.()
cleanupVisibilityListener?.()
}
const result: Result = {
sendMessage,
connect,
disconnect: disconnectFn,
get readyState() {
return getReadyState()
},
get webSocketIns() {
return websocketRef.current
},
clearHeartbeat,
switchConversation,
cleanup,
}
return result
}
export default createWebSocketClient