初始化模版工程
This commit is contained in:
18
app/api/chat/event/route.ts
Normal file
18
app/api/chat/event/route.ts
Normal 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)
|
||||
}
|
||||
14
app/api/chat/oss_url/route.ts
Normal file
14
app/api/chat/oss_url/route.ts
Normal 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)
|
||||
}
|
||||
11
app/api/chat/stop/route.ts
Normal file
11
app/api/chat/stop/route.ts
Normal 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)
|
||||
}
|
||||
7
app/api/conversation/info/route.ts
Normal file
7
app/api/conversation/info/route.ts
Normal 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)
|
||||
}
|
||||
12
app/api/conversation/route.ts
Normal file
12
app/api/conversation/route.ts
Normal 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)
|
||||
}
|
||||
7
app/api/file/record/route.ts
Normal file
7
app/api/file/record/route.ts
Normal 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)
|
||||
}
|
||||
12
app/api/file/sign/route.ts
Normal file
12
app/api/file/sign/route.ts
Normal 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)
|
||||
}
|
||||
8
app/api/file/upload/route.ts
Normal file
8
app/api/file/upload/route.ts
Normal 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
24
app/api/health/route.ts
Normal 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
68
app/api/info/route.ts
Normal 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
6
app/api/llm-client.ts
Normal 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
38
app/api/nova-config.ts
Normal 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
85
app/api/oapi-client.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
55
app/api/oapi-wrapper-client.ts
Normal file
55
app/api/oapi-wrapper-client.ts
Normal 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))
|
||||
},
|
||||
}
|
||||
6
app/api/oss/upload-sts/route.ts
Normal file
6
app/api/oss/upload-sts/route.ts
Normal 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)
|
||||
}
|
||||
7
app/api/plugins/skill/upload/route.ts
Normal file
7
app/api/plugins/skill/upload/route.ts
Normal 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)
|
||||
}
|
||||
41
app/api/remote-control/agent-info/route.ts
Normal file
41
app/api/remote-control/agent-info/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
109
app/api/remote-control/config/route.ts
Normal file
109
app/api/remote-control/config/route.ts
Normal 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
|
||||
}
|
||||
33
app/api/remote-control/logs/route.ts
Normal file
33
app/api/remote-control/logs/route.ts
Normal 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: '日志已清空',
|
||||
})
|
||||
}
|
||||
71
app/api/remote-control/status/route.ts
Normal file
71
app/api/remote-control/status/route.ts
Normal 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)
|
||||
}
|
||||
147
app/api/remote-control/test/route.ts
Normal file
147
app/api/remote-control/test/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
11
app/api/team/[teamId]/plugins/route.ts
Normal file
11
app/api/team/[teamId]/plugins/route.ts
Normal 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)
|
||||
}
|
||||
133
app/api/v1/[...path]/route.ts
Normal file
133
app/api/v1/[...path]/route.ts
Normal 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
570
app/api/websocket/index.ts
Normal 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
|
||||
Reference in New Issue
Block a user