初始化模版工程
This commit is contained in:
20
app/RouteChange.tsx
Normal file
20
app/RouteChange.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
// components/RouterListener.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function RouteChange() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const url = searchParams.toString()
|
||||
? `${pathname}?${searchParams.toString()}`
|
||||
: pathname;
|
||||
|
||||
window.parent.postMessage({ type: "ROUTE_CHANGE", path: url }, "*");
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
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
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
594
app/globals.css
Normal file
594
app/globals.css
Normal file
@@ -0,0 +1,594 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Avenir Next", "SF Pro Rounded", "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", sans-serif;
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 10px);
|
||||
--radius-3xl: calc(var(--radius) + 16px);
|
||||
--radius-4xl: calc(var(--radius) + 22px);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-foreground: var(--brand-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.875rem;
|
||||
|
||||
--background: #f5f1ea;
|
||||
--foreground: #171412;
|
||||
|
||||
--card: #fffdf8;
|
||||
--card-foreground: #171412;
|
||||
|
||||
--popover: #fffdf8;
|
||||
--popover-foreground: #171412;
|
||||
|
||||
--primary: #8a6742;
|
||||
--primary-foreground: #fffaf4;
|
||||
|
||||
--secondary: #ece4d7;
|
||||
--secondary-foreground: #2e241c;
|
||||
|
||||
--muted: #efe8dd;
|
||||
--muted-foreground: #7c6f62;
|
||||
|
||||
--accent: #e8dfd1;
|
||||
--accent-foreground: #4b3724;
|
||||
|
||||
--destructive: #cb5f74;
|
||||
|
||||
--border: #ddd2c2;
|
||||
--input: #cfbfaa;
|
||||
--ring: rgba(138, 103, 66, 0.22);
|
||||
|
||||
--brand: #8a6742;
|
||||
--brand-foreground: #fffaf4;
|
||||
--success: #5e856b;
|
||||
--warning: #c98a45;
|
||||
|
||||
--chart-1: #8a6742;
|
||||
--chart-2: #b79267;
|
||||
--chart-3: #6d7788;
|
||||
--chart-4: #5e856b;
|
||||
--chart-5: #c98253;
|
||||
|
||||
--sidebar: #fffdf8;
|
||||
--sidebar-foreground: #171412;
|
||||
--sidebar-primary: #8a6742;
|
||||
--sidebar-primary-foreground: #fffaf4;
|
||||
--sidebar-accent: #e8dfd1;
|
||||
--sidebar-accent-foreground: #2e241c;
|
||||
--sidebar-border: #ddd2c2;
|
||||
--sidebar-ring: rgba(138, 103, 66, 0.22);
|
||||
|
||||
--editor-surface: rgba(255, 251, 245, 0.92);
|
||||
--editor-surface-muted: rgba(239, 232, 221, 0.92);
|
||||
--editor-border: rgba(124, 111, 98, 0.22);
|
||||
--editor-border-strong: rgba(124, 111, 98, 0.36);
|
||||
--editor-shadow: rgba(23, 20, 18, 0.14);
|
||||
--editor-text: #2e241c;
|
||||
--editor-text-muted: rgba(46, 36, 28, 0.54);
|
||||
--editor-accent: #8a6742;
|
||||
--editor-accent-soft: rgba(138, 103, 66, 0.12);
|
||||
--editor-danger: #cb5f74;
|
||||
--editor-danger-soft: rgba(203, 95, 116, 0.12);
|
||||
|
||||
--page-glow-1: rgba(206, 185, 156, 0.32);
|
||||
--page-glow-2: rgba(170, 142, 96, 0.18);
|
||||
--page-gradient-top: #fcfaf6;
|
||||
--page-gradient-bottom: #f5f1ea;
|
||||
--gradient-subtle-1: #fcfaf6;
|
||||
--gradient-subtle-2: #f1e8db;
|
||||
--gradient-subtle-3: #e6dac8;
|
||||
--gradient-brand-1: #3a2b1e;
|
||||
--gradient-brand-2: #8a6742;
|
||||
--gradient-brand-3: #d0b08c;
|
||||
--text-gradient-1: #171412;
|
||||
--text-gradient-2: #8a6742;
|
||||
--text-gradient-3: #c7a173;
|
||||
--glass-bg: rgba(255, 251, 245, 0.84);
|
||||
--glass-border: rgba(221, 210, 194, 0.92);
|
||||
--glow-shadow: rgba(138, 103, 66, 0.2);
|
||||
--brand-shadow: rgba(138, 103, 66, 0.26);
|
||||
--terminal-background: #17171d;
|
||||
--terminal-surface: #121218;
|
||||
--terminal-border: rgba(255, 255, 255, 0.08);
|
||||
--terminal-text: #f3f4f6;
|
||||
--terminal-text-muted: rgba(243, 244, 246, 0.66);
|
||||
--terminal-prompt: #b79267;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #09090d;
|
||||
--foreground: #f5f7ff;
|
||||
|
||||
--card: #12131a;
|
||||
--card-foreground: #f5f7ff;
|
||||
|
||||
--popover: #141624;
|
||||
--popover-foreground: #f5f7ff;
|
||||
|
||||
--primary: #8f7cff;
|
||||
--primary-foreground: #f8f7ff;
|
||||
|
||||
--secondary: #171826;
|
||||
--secondary-foreground: #e8ebff;
|
||||
|
||||
--muted: #131422;
|
||||
--muted-foreground: #9da2bf;
|
||||
|
||||
--accent: #1b1d31;
|
||||
--accent-foreground: #d9ddff;
|
||||
|
||||
--destructive: #ff7ea8;
|
||||
|
||||
--border: rgba(126, 132, 173, 0.2);
|
||||
--input: rgba(126, 132, 173, 0.28);
|
||||
--ring: rgba(143, 124, 255, 0.36);
|
||||
|
||||
--brand: #a18cff;
|
||||
--brand-foreground: #f8f7ff;
|
||||
--success: #48d7c2;
|
||||
--warning: #ffb86b;
|
||||
|
||||
--chart-1: #8f7cff;
|
||||
--chart-2: #45d4ff;
|
||||
--chart-3: #5f7cff;
|
||||
--chart-4: #ff78b8;
|
||||
--chart-5: #ffb86b;
|
||||
|
||||
--sidebar: #0f1018;
|
||||
--sidebar-foreground: #f5f7ff;
|
||||
--sidebar-primary: #8f7cff;
|
||||
--sidebar-primary-foreground: #f8f7ff;
|
||||
--sidebar-accent: #1b1d31;
|
||||
--sidebar-accent-foreground: #f5f7ff;
|
||||
--sidebar-border: rgba(126, 132, 173, 0.2);
|
||||
--sidebar-ring: rgba(143, 124, 255, 0.36);
|
||||
|
||||
--editor-surface: rgba(18, 19, 26, 0.92);
|
||||
--editor-surface-muted: rgba(27, 29, 49, 0.86);
|
||||
--editor-border: rgba(126, 132, 173, 0.2);
|
||||
--editor-border-strong: rgba(126, 132, 173, 0.34);
|
||||
--editor-shadow: rgba(0, 0, 0, 0.32);
|
||||
--editor-text: #f5f7ff;
|
||||
--editor-text-muted: rgba(245, 247, 255, 0.58);
|
||||
--editor-accent: #8f7cff;
|
||||
--editor-accent-soft: rgba(143, 124, 255, 0.16);
|
||||
--editor-danger: #ff7ea8;
|
||||
--editor-danger-soft: rgba(255, 126, 168, 0.16);
|
||||
|
||||
--page-glow-1: rgba(143, 124, 255, 0.2);
|
||||
--page-glow-2: rgba(69, 212, 255, 0.12);
|
||||
--page-gradient-top: #151625;
|
||||
--page-gradient-bottom: #09090d;
|
||||
--gradient-subtle-1: #191b2d;
|
||||
--gradient-subtle-2: #10111b;
|
||||
--gradient-subtle-3: #09090d;
|
||||
--gradient-brand-1: #6156ff;
|
||||
--gradient-brand-2: #9a7cff;
|
||||
--gradient-brand-3: #45d4ff;
|
||||
--text-gradient-1: #f5f7ff;
|
||||
--text-gradient-2: #a18cff;
|
||||
--text-gradient-3: #45d4ff;
|
||||
--glass-bg: rgba(18, 19, 26, 0.84);
|
||||
--glass-border: rgba(126, 132, 173, 0.18);
|
||||
--glow-shadow: rgba(143, 124, 255, 0.22);
|
||||
--brand-shadow: rgba(143, 124, 255, 0.28);
|
||||
--terminal-background: #12131a;
|
||||
--terminal-surface: #0d0f16;
|
||||
--terminal-border: rgba(255, 255, 255, 0.08);
|
||||
--terminal-text: #f5f7ff;
|
||||
--terminal-text-muted: rgba(245, 247, 255, 0.64);
|
||||
--terminal-prompt: #a18cff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(circle at top left, var(--page-glow-1), transparent 34%),
|
||||
radial-gradient(circle at 85% 12%, var(--page-glow-2), transparent 22%),
|
||||
linear-gradient(180deg, var(--page-gradient-top) 0%, var(--page-gradient-bottom) 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--ring);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@keyframes artifact-shuffle-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(24px) scale(0.9) rotate(-4deg);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(-6px) scale(1.03) rotate(2deg);
|
||||
}
|
||||
70% {
|
||||
transform: translateY(3px) scale(0.98) rotate(-1deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-artifact-shuffle-in {
|
||||
animation-name: artifact-shuffle-in;
|
||||
animation-duration: 800ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-xl bg-card;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(16, 38, 52, 0.03),
|
||||
0 14px 34px rgba(16, 38, 52, 0.06);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
|
||||
box-shadow:
|
||||
0 1px 2px rgba(16, 38, 52, 0.04),
|
||||
0 18px 42px rgba(16, 38, 52, 0.08);
|
||||
}
|
||||
|
||||
.dark .card {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.28),
|
||||
0 18px 48px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.glow-effect {
|
||||
box-shadow: 0 14px 32px var(--glow-shadow);
|
||||
}
|
||||
|
||||
.dark .glow-effect {
|
||||
box-shadow: 0 14px 38px var(--glow-shadow);
|
||||
}
|
||||
|
||||
.btn-brand {
|
||||
@apply bg-primary text-primary-foreground transition-all duration-200;
|
||||
}
|
||||
|
||||
.btn-brand:hover {
|
||||
box-shadow: 0 12px 26px var(--brand-shadow);
|
||||
}
|
||||
|
||||
.surface-panel {
|
||||
background: color-mix(in srgb, var(--card) 92%, white 8%);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(16, 38, 52, 0.03),
|
||||
0 22px 52px rgba(16, 38, 52, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.surface-subtle {
|
||||
background: color-mix(in srgb, var(--card) 84%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||
}
|
||||
|
||||
.prose {
|
||||
@apply text-foreground leading-relaxed;
|
||||
}
|
||||
|
||||
.prose h1 { @apply mt-6 mb-4 text-2xl font-bold; }
|
||||
.prose h2 { @apply mt-5 mb-3 text-xl font-bold; }
|
||||
.prose h3 { @apply mt-4 mb-2 text-lg font-bold; }
|
||||
.prose p { @apply my-3; }
|
||||
.prose ul { @apply my-3 list-disc pl-6; }
|
||||
.prose ol { @apply my-3 list-decimal pl-6; }
|
||||
.prose li { @apply my-1; }
|
||||
.prose blockquote { @apply my-4 border-l-4 border-muted pl-4 italic; }
|
||||
.prose code { @apply rounded bg-muted px-1.5 py-0.5 font-mono text-sm; }
|
||||
.prose pre { @apply my-4 overflow-x-auto rounded-xl border border-border bg-card p-4; }
|
||||
.prose pre code { @apply bg-transparent p-0; }
|
||||
.prose table { @apply my-6 w-full border-collapse text-sm; }
|
||||
.prose th { @apply border border-border bg-muted/50 px-4 py-2 text-left font-bold; }
|
||||
.prose td { @apply border border-border px-4 py-2; }
|
||||
.prose tr:nth-child(even) { @apply bg-muted/20; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.transition-default {
|
||||
@apply transition-all duration-200 ease-out;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(97, 119, 133, 0.36);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(97, 119, 133, 0.5);
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(159, 180, 188, 0.24);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(159, 180, 188, 0.36);
|
||||
}
|
||||
|
||||
.bg-gradient-subtle {
|
||||
background: linear-gradient(180deg, var(--gradient-subtle-1) 0%, var(--gradient-subtle-2) 52%, var(--gradient-subtle-3) 100%);
|
||||
}
|
||||
|
||||
.dark .bg-gradient-subtle {
|
||||
background: linear-gradient(180deg, var(--gradient-subtle-1) 0%, var(--gradient-subtle-2) 50%, var(--gradient-subtle-3) 100%);
|
||||
}
|
||||
|
||||
.bg-gradient-brand {
|
||||
background: linear-gradient(135deg, var(--gradient-brand-1) 0%, var(--gradient-brand-2) 48%, var(--gradient-brand-3) 100%);
|
||||
}
|
||||
|
||||
.glass {
|
||||
@apply backdrop-blur-md;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
@apply transition-all duration-200 ease-out;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 10px 22px rgba(16, 38, 52, 0.08),
|
||||
0 4px 10px rgba(16, 38, 52, 0.04);
|
||||
}
|
||||
|
||||
.dark .hover-lift:hover {
|
||||
box-shadow:
|
||||
0 8px 16px rgba(0, 0, 0, 0.3),
|
||||
0 4px 8px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.press-effect {
|
||||
@apply transition-transform duration-100 ease-out;
|
||||
}
|
||||
|
||||
.press-effect:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.focus-ring {
|
||||
@apply outline-none ring-2 ring-primary/20 ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent;
|
||||
background-image: linear-gradient(135deg, var(--text-gradient-1) 0%, var(--text-gradient-2) 48%, var(--text-gradient-3) 100%);
|
||||
}
|
||||
|
||||
.dark .text-gradient {
|
||||
background-image: linear-gradient(135deg, var(--text-gradient-1) 0%, var(--text-gradient-2) 46%, var(--text-gradient-3) 100%);
|
||||
}
|
||||
|
||||
.text-brand {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.shadow-soft {
|
||||
box-shadow:
|
||||
0 4px 12px rgba(16, 38, 52, 0.04),
|
||||
0 10px 24px rgba(16, 38, 52, 0.05);
|
||||
}
|
||||
|
||||
.border-hover {
|
||||
@apply border border-border transition-colors duration-200;
|
||||
}
|
||||
|
||||
.border-hover:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border));
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
* 全局覆盖 slate/zinc 硬编码颜色 → 语义变量
|
||||
* slate 色系为冷色调(偏蓝),本项目为暖色调,
|
||||
* 统一替换确保亮色/深色模式下颜色风格一致
|
||||
* ================================================ */
|
||||
|
||||
/* 背景 */
|
||||
.bg-white {
|
||||
background-color: var(--card);
|
||||
}
|
||||
|
||||
.bg-slate-50,
|
||||
.bg-zinc-50,
|
||||
.bg-gray-50 {
|
||||
background-color: var(--card);
|
||||
}
|
||||
|
||||
.bg-slate-100,
|
||||
.bg-zinc-100 {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 文字 */
|
||||
.text-black,
|
||||
.text-slate-900,
|
||||
.text-zinc-900 {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.text-slate-800,
|
||||
.text-zinc-800,
|
||||
.text-slate-700,
|
||||
.text-zinc-700 {
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
}
|
||||
|
||||
.text-slate-600,
|
||||
.text-zinc-600 {
|
||||
color: color-mix(in srgb, var(--muted-foreground) 120%, transparent);
|
||||
}
|
||||
|
||||
.text-slate-500,
|
||||
.text-zinc-500,
|
||||
.text-gray-500,
|
||||
.text-slate-400,
|
||||
.text-zinc-400,
|
||||
.text-gray-400 {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.text-slate-300,
|
||||
.text-zinc-300 {
|
||||
color: color-mix(in srgb, var(--muted-foreground) 60%, transparent);
|
||||
}
|
||||
|
||||
/* 边框 */
|
||||
.border-slate-100,
|
||||
.border-slate-200,
|
||||
.border-zinc-100,
|
||||
.border-zinc-200 {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.border-slate-700,
|
||||
.border-slate-800,
|
||||
.border-zinc-700,
|
||||
.border-zinc-800 {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* hover 背景 */
|
||||
.hover\:bg-slate-50:hover,
|
||||
.hover\:bg-slate-100:hover,
|
||||
.hover\:bg-zinc-50:hover,
|
||||
.hover\:bg-zinc-100:hover {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.editor-floating-panel {
|
||||
background: var(--editor-surface);
|
||||
border: 1px solid var(--editor-border);
|
||||
box-shadow:
|
||||
0 18px 44px var(--editor-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.editor-floating-panel-soft {
|
||||
background: var(--editor-surface);
|
||||
border: 1px solid var(--editor-border);
|
||||
box-shadow:
|
||||
0 12px 34px var(--editor-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.editor-toolbar-chip {
|
||||
background: var(--editor-surface-muted);
|
||||
border: 1px solid transparent;
|
||||
color: var(--editor-text);
|
||||
}
|
||||
|
||||
.editor-toolbar-chip:hover {
|
||||
background: color-mix(in srgb, var(--editor-accent-soft) 55%, var(--editor-surface-muted));
|
||||
}
|
||||
|
||||
.editor-toolbar-chip-active {
|
||||
background: var(--editor-accent-soft);
|
||||
color: var(--editor-accent);
|
||||
}
|
||||
|
||||
.editor-toolbar-divider {
|
||||
background: var(--editor-border);
|
||||
}
|
||||
|
||||
.editor-toolbar-danger {
|
||||
background: var(--editor-danger-soft);
|
||||
color: var(--editor-danger);
|
||||
}
|
||||
|
||||
/* 深色背景(终端/代码块)保留不覆盖:bg-slate-900 bg-slate-950 */
|
||||
}
|
||||
34
app/layout.tsx
Normal file
34
app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import './globals.css';
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { ThemeProvider } from "@/components/provider/Theme/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AgentationGuard } from "@/components/AgentationGuard";
|
||||
import RouteChange from "./RouteChange";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Nova Chat',
|
||||
description: 'Generated by create Nova Chat',
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout(props: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="antialiased">
|
||||
<ThemeProvider>
|
||||
{props.children}
|
||||
<Toaster position="top-center" />
|
||||
</ThemeProvider>
|
||||
<AgentationGuard />
|
||||
<Suspense>
|
||||
<RouteChange />
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
33
app/page.tsx
Normal file
33
app/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { ImageEditor, ImageEditorHandle } from '@/components/image-editor';
|
||||
import { NovaChat } from '@/components/nova-sdk';
|
||||
import { useBuildConversationConnect } from '@/components/nova-sdk/hooks';
|
||||
import { useImages } from '@/components/nova-sdk/store/useImages';
|
||||
import { useRef } from 'react';
|
||||
|
||||
const ChatWithImageEditor = () => {
|
||||
const imageEditorRef = useRef<ImageEditorHandle>(null);
|
||||
const { conversationId, platformConfig } = useBuildConversationConnect();
|
||||
useImages(imageEditorRef)
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen flex">
|
||||
<div className="w-1/2 flex-shrink-0">
|
||||
{conversationId && (
|
||||
<ImageEditor taskId={conversationId} ref={imageEditorRef} />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<NovaChat
|
||||
platformConfig={platformConfig}
|
||||
conversationId={conversationId}
|
||||
agentId={platformConfig.agentId}
|
||||
panelMode={'dialog'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWithImageEditor;
|
||||
797
app/settings/remote-control/page.tsx
Normal file
797
app/settings/remote-control/page.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// PLATFORM:TYPE_UNION:START
|
||||
type Platform = 'discord' | 'dingtalk' | 'lark' | 'telegram' | 'slack'
|
||||
// PLATFORM:TYPE_UNION:END
|
||||
type ConnectionStatus = 'connected' | 'disconnected' | 'connecting'
|
||||
|
||||
type CSSVarName =
|
||||
| '--background'
|
||||
| '--foreground'
|
||||
| '--card'
|
||||
| '--card-foreground'
|
||||
| '--primary'
|
||||
| '--primary-foreground'
|
||||
| '--muted'
|
||||
| '--muted-foreground'
|
||||
| '--border'
|
||||
| '--input'
|
||||
| '--success'
|
||||
| '--warning'
|
||||
| '--destructive'
|
||||
|
||||
const themeVar = (name: CSSVarName) => `var(${name})`
|
||||
|
||||
interface RemoteControlConfig {
|
||||
// PLATFORM:DINGTALK:CONFIG_INTERFACE:START
|
||||
dingtalk: {
|
||||
enabled: boolean
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
// PLATFORM:DINGTALK:CONFIG_INTERFACE:END
|
||||
// PLATFORM:DISCORD:CONFIG_INTERFACE:START
|
||||
discord: {
|
||||
enabled: boolean
|
||||
botToken: string
|
||||
}
|
||||
// PLATFORM:DISCORD:CONFIG_INTERFACE:END
|
||||
// PLATFORM:LARK:CONFIG_INTERFACE:START
|
||||
lark: {
|
||||
enabled: boolean
|
||||
appId: string
|
||||
appSecret: string
|
||||
}
|
||||
// PLATFORM:LARK:CONFIG_INTERFACE:END
|
||||
// PLATFORM:TELEGRAM:CONFIG_INTERFACE:START
|
||||
telegram: {
|
||||
enabled: boolean
|
||||
botToken: string
|
||||
}
|
||||
// PLATFORM:TELEGRAM:CONFIG_INTERFACE:END
|
||||
// PLATFORM:SLACK:CONFIG_INTERFACE:START
|
||||
slack: {
|
||||
enabled: boolean
|
||||
botToken: string
|
||||
appToken: string
|
||||
}
|
||||
// PLATFORM:SLACK:CONFIG_INTERFACE:END
|
||||
}
|
||||
|
||||
interface PlatformStatus {
|
||||
platform?: Platform
|
||||
status: ConnectionStatus
|
||||
messagesProcessed?: number
|
||||
activeSessions?: number
|
||||
lastConnectedAt?: string
|
||||
uptime?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LogsResponse {
|
||||
logs: LogEntry[]
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id?: string
|
||||
timestamp: string
|
||||
platform: Platform
|
||||
eventType: string
|
||||
severity: 'info' | 'warning' | 'error'
|
||||
details?: string | Record<string, unknown>
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface AgentInfoResponse {
|
||||
agentId: string
|
||||
baseUrl: string
|
||||
stats?: {
|
||||
totalMessages?: number
|
||||
activeConnections?: number
|
||||
avgResponseTime?: number
|
||||
}
|
||||
totalMessages?: number
|
||||
activeConnections?: number
|
||||
averageResponseTime?: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: RemoteControlConfig = {
|
||||
// PLATFORM:DINGTALK:DEFAULT_CONFIG:START
|
||||
dingtalk: { enabled: false, clientId: '', clientSecret: '' },
|
||||
// PLATFORM:DINGTALK:DEFAULT_CONFIG:END
|
||||
// PLATFORM:DISCORD:DEFAULT_CONFIG:START
|
||||
discord: { enabled: false, botToken: '' },
|
||||
// PLATFORM:DISCORD:DEFAULT_CONFIG:END
|
||||
// PLATFORM:LARK:DEFAULT_CONFIG:START
|
||||
lark: { enabled: false, appId: '', appSecret: '' },
|
||||
// PLATFORM:LARK:DEFAULT_CONFIG:END
|
||||
// PLATFORM:TELEGRAM:DEFAULT_CONFIG:START
|
||||
telegram: { enabled: false, botToken: '' },
|
||||
// PLATFORM:TELEGRAM:DEFAULT_CONFIG:END
|
||||
// PLATFORM:SLACK:DEFAULT_CONFIG:START
|
||||
slack: { enabled: false, botToken: '', appToken: '' },
|
||||
// PLATFORM:SLACK:DEFAULT_CONFIG:END
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS: Record<Platform, PlatformStatus> = {
|
||||
// PLATFORM:DINGTALK:DEFAULT_STATUS:START
|
||||
dingtalk: { platform: 'dingtalk', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||
// PLATFORM:DINGTALK:DEFAULT_STATUS:END
|
||||
// PLATFORM:DISCORD:DEFAULT_STATUS:START
|
||||
discord: { platform: 'discord', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||
// PLATFORM:DISCORD:DEFAULT_STATUS:END
|
||||
// PLATFORM:LARK:DEFAULT_STATUS:START
|
||||
lark: { platform: 'lark', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||
// PLATFORM:LARK:DEFAULT_STATUS:END
|
||||
// PLATFORM:TELEGRAM:DEFAULT_STATUS:START
|
||||
telegram: { platform: 'telegram', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||
// PLATFORM:TELEGRAM:DEFAULT_STATUS:END
|
||||
// PLATFORM:SLACK:DEFAULT_STATUS:START
|
||||
slack: { platform: 'slack', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||
// PLATFORM:SLACK:DEFAULT_STATUS:END
|
||||
}
|
||||
|
||||
function statusMeta(status: ConnectionStatus) {
|
||||
if (status === 'connected') {
|
||||
return { label: '已连接', dot: 'var(--success)' }
|
||||
}
|
||||
if (status === 'connecting') {
|
||||
return { label: '连接中...', dot: 'var(--warning)' }
|
||||
}
|
||||
return { label: '断开连接', dot: 'var(--destructive)' }
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatDetails(log: LogEntry) {
|
||||
if (typeof log.details === 'string') return log.details
|
||||
if (log.message) return log.message
|
||||
if (log.details) return JSON.stringify(log.details)
|
||||
return '-'
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number) {
|
||||
if (!seconds || seconds <= 0) return '-'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (h > 0) return `${h}小时${m}分`
|
||||
if (m > 0) return `${m}分${s}秒`
|
||||
return `${s}秒`
|
||||
}
|
||||
|
||||
export default function RemoteControlPage() {
|
||||
const [config, setConfig] = useState<RemoteControlConfig>(DEFAULT_CONFIG)
|
||||
const [status, setStatus] = useState<Record<Platform, PlatformStatus>>(DEFAULT_STATUS)
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [agentInfo, setAgentInfo] = useState<AgentInfoResponse | null>(null)
|
||||
// PLATFORM:SHOW_SECRETS:START
|
||||
const [showSecrets, setShowSecrets] = useState({
|
||||
// PLATFORM:DINGTALK:SHOW_SECRETS:START
|
||||
dingtalkSecret: false,
|
||||
// PLATFORM:DINGTALK:SHOW_SECRETS:END
|
||||
// PLATFORM:DISCORD:SHOW_SECRETS:START
|
||||
discordToken: false,
|
||||
// PLATFORM:DISCORD:SHOW_SECRETS:END
|
||||
// PLATFORM:LARK:SHOW_SECRETS:START
|
||||
larkSecret: false,
|
||||
// PLATFORM:LARK:SHOW_SECRETS:END
|
||||
// PLATFORM:TELEGRAM:SHOW_SECRETS:START
|
||||
telegramToken: false,
|
||||
// PLATFORM:TELEGRAM:SHOW_SECRETS:END
|
||||
// PLATFORM:SLACK:SHOW_SECRETS:START
|
||||
slackBotToken: false,
|
||||
slackAppToken: false,
|
||||
// PLATFORM:SLACK:SHOW_SECRETS:END
|
||||
})
|
||||
// PLATFORM:SHOW_SECRETS:END
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// PLATFORM:TESTING:START
|
||||
const [testing, setTesting] = useState<Record<Platform, boolean>>({
|
||||
// PLATFORM:DINGTALK:TESTING:START
|
||||
dingtalk: false,
|
||||
// PLATFORM:DINGTALK:TESTING:END
|
||||
// PLATFORM:DISCORD:TESTING:START
|
||||
discord: false,
|
||||
// PLATFORM:DISCORD:TESTING:END
|
||||
// PLATFORM:LARK:TESTING:START
|
||||
lark: false,
|
||||
// PLATFORM:LARK:TESTING:END
|
||||
// PLATFORM:TELEGRAM:TESTING:START
|
||||
telegram: false,
|
||||
// PLATFORM:TELEGRAM:TESTING:END
|
||||
// PLATFORM:SLACK:TESTING:START
|
||||
slack: false,
|
||||
// PLATFORM:SLACK:TESTING:END
|
||||
})
|
||||
// PLATFORM:TESTING:END
|
||||
const [refreshingLogs, setRefreshingLogs] = useState(false)
|
||||
const [clearingLogs, setClearingLogs] = useState(false)
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
const response = await fetch('/api/remote-control/status', { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
throw new Error('状态加载失败')
|
||||
}
|
||||
const data = (await response.json()) as Partial<Record<Platform, PlatformStatus>>
|
||||
setStatus({
|
||||
// PLATFORM:DINGTALK:LOAD_STATUS:START
|
||||
dingtalk: { ...DEFAULT_STATUS.dingtalk, ...(data.dingtalk ?? {}) },
|
||||
// PLATFORM:DINGTALK:LOAD_STATUS:END
|
||||
// PLATFORM:DISCORD:LOAD_STATUS:START
|
||||
discord: { ...DEFAULT_STATUS.discord, ...(data.discord ?? {}) },
|
||||
// PLATFORM:DISCORD:LOAD_STATUS:END
|
||||
// PLATFORM:LARK:LOAD_STATUS:START
|
||||
lark: { ...DEFAULT_STATUS.lark, ...(data.lark ?? {}) },
|
||||
// PLATFORM:LARK:LOAD_STATUS:END
|
||||
// PLATFORM:TELEGRAM:LOAD_STATUS:START
|
||||
telegram: { ...DEFAULT_STATUS.telegram, ...(data.telegram ?? {}) },
|
||||
// PLATFORM:TELEGRAM:LOAD_STATUS:END
|
||||
// PLATFORM:SLACK:LOAD_STATUS:START
|
||||
slack: { ...DEFAULT_STATUS.slack, ...(data.slack ?? {}) },
|
||||
// PLATFORM:SLACK:LOAD_STATUS:END
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
const response = await fetch('/api/remote-control/logs?limit=50', { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
throw new Error('日志加载失败')
|
||||
}
|
||||
const data = (await response.json()) as LogsResponse
|
||||
setLogs(Array.isArray(data.logs) ? data.logs : [])
|
||||
}, [])
|
||||
|
||||
const loadAgentInfo = useCallback(async () => {
|
||||
const response = await fetch('/api/remote-control/agent-info', { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
throw new Error('Agent 信息加载失败')
|
||||
}
|
||||
const data = (await response.json()) as AgentInfoResponse
|
||||
setAgentInfo(data)
|
||||
}, [])
|
||||
|
||||
const loadInitialData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const configResponse = await fetch('/api/remote-control/config', { cache: 'no-store' })
|
||||
if (!configResponse.ok) {
|
||||
throw new Error('配置加载失败')
|
||||
}
|
||||
const configData = (await configResponse.json()) as Partial<RemoteControlConfig>
|
||||
setConfig({
|
||||
// PLATFORM:DINGTALK:LOAD_INITIAL:START
|
||||
dingtalk: { ...DEFAULT_CONFIG.dingtalk, ...(configData.dingtalk ?? {}) },
|
||||
// PLATFORM:DINGTALK:LOAD_INITIAL:END
|
||||
// PLATFORM:DISCORD:LOAD_INITIAL:START
|
||||
discord: { ...DEFAULT_CONFIG.discord, ...(configData.discord ?? {}) },
|
||||
// PLATFORM:DISCORD:LOAD_INITIAL:END
|
||||
// PLATFORM:LARK:LOAD_INITIAL:START
|
||||
lark: { ...DEFAULT_CONFIG.lark, ...(configData.lark ?? {}) },
|
||||
// PLATFORM:LARK:LOAD_INITIAL:END
|
||||
// PLATFORM:TELEGRAM:LOAD_INITIAL:START
|
||||
telegram: { ...DEFAULT_CONFIG.telegram, ...(configData.telegram ?? {}) },
|
||||
// PLATFORM:TELEGRAM:LOAD_INITIAL:END
|
||||
// PLATFORM:SLACK:LOAD_INITIAL:START
|
||||
slack: { ...DEFAULT_CONFIG.slack, ...(configData.slack ?? {}) },
|
||||
// PLATFORM:SLACK:LOAD_INITIAL:END
|
||||
})
|
||||
await Promise.all([loadStatus(), loadLogs(), loadAgentInfo()])
|
||||
} catch {
|
||||
toast.error('加载远程控制配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadAgentInfo, loadLogs, loadStatus])
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitialData()
|
||||
}, [loadInitialData])
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch('/api/remote-control/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
const data = (await response.json()) as { success?: boolean; message?: string; error?: string }
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '保存失败')
|
||||
}
|
||||
|
||||
toast.success(data.message || '配置已保存并生效')
|
||||
|
||||
window.setTimeout(() => {
|
||||
void Promise.all([loadStatus(), loadLogs(), loadAgentInfo()])
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async (platform: Platform) => {
|
||||
setTesting(prev => ({ ...prev, [platform]: true }))
|
||||
try {
|
||||
const response = await fetch('/api/remote-control/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform }),
|
||||
})
|
||||
|
||||
const data = (await response.json()) as { success?: boolean; message?: string; error?: string }
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || data.message || '连接测试失败')
|
||||
}
|
||||
|
||||
// PLATFORM:TEST_CONNECTION_SUCCESS:START
|
||||
const platformNames: Record<Platform, string> = {
|
||||
discord: 'Discord',
|
||||
dingtalk: '钉钉',
|
||||
lark: '飞书',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
}
|
||||
toast.success(`${platformNames[platform]}连接测试成功`)
|
||||
// PLATFORM:TEST_CONNECTION_SUCCESS:END
|
||||
await Promise.all([loadStatus(), loadLogs()])
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '连接测试失败'
|
||||
toast.error(message)
|
||||
await Promise.all([loadStatus(), loadLogs()])
|
||||
} finally {
|
||||
setTesting(prev => ({ ...prev, [platform]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const refreshLogs = async () => {
|
||||
setRefreshingLogs(true)
|
||||
try {
|
||||
await loadLogs()
|
||||
toast.success('日志已刷新')
|
||||
} catch {
|
||||
toast.error('刷新日志失败')
|
||||
} finally {
|
||||
setRefreshingLogs(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearLogs = async () => {
|
||||
setClearingLogs(true)
|
||||
try {
|
||||
const response = await fetch('/api/remote-control/logs', { method: 'DELETE' })
|
||||
if (!response.ok) {
|
||||
throw new Error('清空失败')
|
||||
}
|
||||
setLogs([])
|
||||
toast.success('日志已清空')
|
||||
} catch {
|
||||
toast.error('清空日志失败')
|
||||
} finally {
|
||||
setClearingLogs(false)
|
||||
}
|
||||
}
|
||||
|
||||
const mergedStats = useMemo(() => {
|
||||
const totalMessages = agentInfo?.stats?.totalMessages ?? agentInfo?.totalMessages ?? 0
|
||||
const activeConnections = agentInfo?.stats?.activeConnections ?? agentInfo?.activeConnections ?? 0
|
||||
const avgResponseTime = agentInfo?.stats?.avgResponseTime ?? agentInfo?.averageResponseTime ?? 0
|
||||
return { totalMessages, activeConnections, avgResponseTime }
|
||||
}, [agentInfo])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fanling-theme min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>加载远程控制配置中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fanling-theme min-h-screen px-4 py-6 md:px-6" style={{ backgroundColor: themeVar('--background') }}>
|
||||
<div className="mx-auto w-full max-w-5xl space-y-5">
|
||||
<div className="flex items-center justify-between rounded-2xl border bg-card px-4 py-3 shadow-sm" style={{ borderColor: themeVar('--border') }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm transition-colors"
|
||||
style={{ color: themeVar('--muted-foreground'), border: `1px solid ${themeVar('--border')}` }}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回主页
|
||||
</Link>
|
||||
<h1 className="text-lg font-semibold md:text-xl" style={{ color: themeVar('--foreground') }}>
|
||||
远程控制配置
|
||||
</h1>
|
||||
</div>
|
||||
<Bot className="h-5 w-5" style={{ color: themeVar('--primary') }} />
|
||||
</div>
|
||||
|
||||
{/* PLATFORM:DINGTALK:CARD:START */}
|
||||
<PlatformCard
|
||||
title="钉钉 Bot"
|
||||
status={status.dingtalk}
|
||||
enabled={config.dingtalk.enabled}
|
||||
onEnabledChange={enabled => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, enabled } }))}
|
||||
fields={[
|
||||
{
|
||||
label: 'Client ID',
|
||||
type: 'text',
|
||||
value: config.dingtalk.clientId,
|
||||
onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientId: value } })),
|
||||
placeholder: '请输入钉钉 Client ID',
|
||||
},
|
||||
{
|
||||
label: 'Client Secret',
|
||||
type: showSecrets.dingtalkSecret ? 'text' : 'password',
|
||||
value: config.dingtalk.clientSecret,
|
||||
onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientSecret: value } })),
|
||||
placeholder: '请输入钉钉 Client Secret',
|
||||
secretVisible: showSecrets.dingtalkSecret,
|
||||
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, dingtalkSecret: !prev.dingtalkSecret })),
|
||||
},
|
||||
]}
|
||||
onTest={() => void testConnection('dingtalk')}
|
||||
testing={testing.dingtalk}
|
||||
docUrl="https://open.dingtalk.com/document/robots/custom-robot-access"
|
||||
/>
|
||||
{/* PLATFORM:DINGTALK:CARD:END */}
|
||||
|
||||
{/* PLATFORM:DISCORD:CARD:START */}
|
||||
<PlatformCard
|
||||
title="Discord Bot"
|
||||
status={status.discord}
|
||||
enabled={config.discord.enabled}
|
||||
onEnabledChange={enabled => setConfig(prev => ({ ...prev, discord: { ...prev.discord, enabled } }))}
|
||||
fields={[
|
||||
{
|
||||
label: 'Bot Token',
|
||||
type: showSecrets.discordToken ? 'text' : 'password',
|
||||
value: config.discord.botToken,
|
||||
onChange: value => setConfig(prev => ({ ...prev, discord: { ...prev.discord, botToken: value } })),
|
||||
placeholder: '请输入 Discord Bot Token',
|
||||
secretVisible: showSecrets.discordToken,
|
||||
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, discordToken: !prev.discordToken })),
|
||||
},
|
||||
]}
|
||||
onTest={() => void testConnection('discord')}
|
||||
testing={testing.discord}
|
||||
docUrl="https://docs.discord.com/developers/topics/oauth2#bots"
|
||||
/>
|
||||
{/* PLATFORM:DISCORD:CARD:END */}
|
||||
|
||||
{/* PLATFORM:LARK:CARD:START */}
|
||||
<PlatformCard
|
||||
title="飞书 Bot"
|
||||
status={status.lark}
|
||||
enabled={config.lark.enabled}
|
||||
onEnabledChange={enabled => setConfig(prev => ({ ...prev, lark: { ...prev.lark, enabled } }))}
|
||||
fields={[
|
||||
{
|
||||
label: 'App ID',
|
||||
type: 'text',
|
||||
value: config.lark.appId,
|
||||
onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appId: value } })),
|
||||
placeholder: '请输入飞书 App ID',
|
||||
},
|
||||
{
|
||||
label: 'App Secret',
|
||||
type: showSecrets.larkSecret ? 'text' : 'password',
|
||||
value: config.lark.appSecret,
|
||||
onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appSecret: value } })),
|
||||
placeholder: '请输入飞书 App Secret',
|
||||
secretVisible: showSecrets.larkSecret,
|
||||
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, larkSecret: !prev.larkSecret })),
|
||||
},
|
||||
]}
|
||||
onTest={() => void testConnection('lark')}
|
||||
testing={testing.lark}
|
||||
docUrl="https://open.feishu.cn/document/home/introduction-to-custom-app-development/self-built-application-development-process"
|
||||
/>
|
||||
{/* PLATFORM:LARK:CARD:END */}
|
||||
|
||||
{/* PLATFORM:TELEGRAM:CARD:START */}
|
||||
<PlatformCard
|
||||
title="Telegram Bot"
|
||||
status={status.telegram}
|
||||
enabled={config.telegram.enabled}
|
||||
onEnabledChange={enabled => setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, enabled } }))}
|
||||
fields={[
|
||||
{
|
||||
label: 'Bot Token',
|
||||
type: showSecrets.telegramToken ? 'text' : 'password',
|
||||
value: config.telegram.botToken,
|
||||
onChange: value => setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, botToken: value } })),
|
||||
placeholder: '请输入 Telegram Bot Token (通过 @BotFather 获取)',
|
||||
secretVisible: showSecrets.telegramToken,
|
||||
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, telegramToken: !prev.telegramToken })),
|
||||
},
|
||||
]}
|
||||
onTest={() => void testConnection('telegram')}
|
||||
testing={testing.telegram}
|
||||
docUrl="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||
/>
|
||||
{/* PLATFORM:TELEGRAM:CARD:END */}
|
||||
|
||||
{/* PLATFORM:SLACK:CARD:START */}
|
||||
<PlatformCard
|
||||
title="Slack Bot"
|
||||
status={status.slack}
|
||||
enabled={config.slack.enabled}
|
||||
onEnabledChange={enabled => setConfig(prev => ({ ...prev, slack: { ...prev.slack, enabled } }))}
|
||||
fields={[
|
||||
{
|
||||
label: 'Bot Token (xoxb-)',
|
||||
type: showSecrets.slackBotToken ? 'text' : 'password',
|
||||
value: config.slack.botToken,
|
||||
onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, botToken: value } })),
|
||||
placeholder: '请输入 Slack Bot Token (xoxb-...)',
|
||||
secretVisible: showSecrets.slackBotToken,
|
||||
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackBotToken: !prev.slackBotToken })),
|
||||
},
|
||||
{
|
||||
label: 'App-Level Token (xapp-)',
|
||||
type: showSecrets.slackAppToken ? 'text' : 'password',
|
||||
value: config.slack.appToken,
|
||||
onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, appToken: value } })),
|
||||
placeholder: '请输入 Slack App Token (xapp-...)',
|
||||
secretVisible: showSecrets.slackAppToken,
|
||||
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackAppToken: !prev.slackAppToken })),
|
||||
},
|
||||
]}
|
||||
onTest={() => void testConnection('slack')}
|
||||
testing={testing.slack}
|
||||
docUrl="https://api.slack.com/start/quickstart"
|
||||
/>
|
||||
{/* PLATFORM:SLACK:CARD:END */}
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold" style={{ color: themeVar('--foreground') }}>Bot 事件日志(最近 50 条)</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshLogs()}
|
||||
disabled={refreshingLogs}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm"
|
||||
style={{ border: `1px solid ${themeVar('--border')}`, color: themeVar('--muted-foreground') }}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshingLogs ? 'animate-spin' : ''}`} />
|
||||
手动刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearLogs()}
|
||||
disabled={clearingLogs}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-white"
|
||||
style={{ backgroundColor: themeVar('--destructive') }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
清空日志
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[380px] overflow-auto rounded-xl border" style={{ borderColor: themeVar('--border') }}>
|
||||
{logs.length === 0 ? (
|
||||
<div className="p-4 text-sm" style={{ color: themeVar('--muted-foreground') }}>暂无日志数据</div>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: themeVar('--border') }}>
|
||||
{logs.map((log, index) => (
|
||||
<div key={log.id ?? `${log.timestamp}-${index}`} className="grid gap-1 px-4 py-3 text-sm md:grid-cols-[168px_88px_120px_80px_1fr]">
|
||||
<span style={{ color: themeVar('--muted-foreground') }}>{formatDate(log.timestamp)}</span>
|
||||
<span className="font-medium" style={{ color: themeVar('--foreground') }}>{log.platform}</span>
|
||||
<span style={{ color: themeVar('--muted-foreground') }}>{log.eventType}</span>
|
||||
<span>
|
||||
<SeverityTag severity={log.severity} />
|
||||
</span>
|
||||
<span className="break-all" style={{ color: themeVar('--card-foreground') }}>{formatDetails(log)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
|
||||
<h2 className="mb-3 text-base font-semibold" style={{ color: themeVar('--foreground') }}>Nova Agent 信息(只读)</h2>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2" style={{ color: themeVar('--card-foreground') }}>
|
||||
<div className="rounded-xl border p-3" style={{ borderColor: themeVar('--border') }}>
|
||||
<div>Agent ID: <span className="font-medium">{agentInfo?.agentId || '-'}</span></div>
|
||||
<div className="mt-1 break-all">Base URL: <span className="font-medium">{agentInfo?.baseUrl || '-'}</span></div>
|
||||
</div>
|
||||
<div className="rounded-xl border p-3" style={{ borderColor: themeVar('--border') }}>
|
||||
<div>活跃连接数: <span className="font-medium">{mergedStats.activeConnections}</span></div>
|
||||
<div className="mt-1">消息总数: <span className="font-medium">{mergedStats.totalMessages}</span></div>
|
||||
<div className="mt-1">平均响应时间: <span className="font-medium">{mergedStats.avgResponseTime} ms</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="sticky bottom-4 z-10 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors"
|
||||
style={{ backgroundColor: themeVar('--primary') }}
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlatformField {
|
||||
label: string
|
||||
type: 'text' | 'password'
|
||||
value: string
|
||||
placeholder: string
|
||||
onChange: (value: string) => void
|
||||
secretVisible?: boolean
|
||||
onToggleSecret?: () => void
|
||||
}
|
||||
|
||||
interface PlatformCardProps {
|
||||
title: string
|
||||
enabled: boolean
|
||||
onEnabledChange: (enabled: boolean) => void
|
||||
status: PlatformStatus
|
||||
fields: PlatformField[]
|
||||
onTest: () => void
|
||||
testing: boolean
|
||||
docUrl: string
|
||||
}
|
||||
|
||||
function PlatformCard({ title, enabled, onEnabledChange, status, fields, onTest, testing, docUrl }: PlatformCardProps) {
|
||||
const meta = statusMeta(status.status)
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold" style={{ color: themeVar('--foreground') }}>{title}</h2>
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex rounded-full p-1"
|
||||
title="查看官方配置文档"
|
||||
style={{ color: themeVar('--muted-foreground') }}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => onEnabledChange(!enabled)}
|
||||
className="inline-flex items-center gap-2 text-sm"
|
||||
style={{ color: themeVar('--card-foreground') }}
|
||||
>
|
||||
<span
|
||||
className="relative h-6 w-11 rounded-full transition-colors"
|
||||
style={{ backgroundColor: enabled ? themeVar('--primary') : themeVar('--muted') }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 h-5 w-5 rounded-full transition-all"
|
||||
style={{ backgroundColor: themeVar('--card'), left: enabled ? '22px' : '2px' }}
|
||||
/>
|
||||
</span>
|
||||
{enabled ? '已启用' : '已禁用'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm" style={{ color: themeVar('--card-foreground') }}>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: meta.dot }} />
|
||||
{meta.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map(field => (
|
||||
<label key={field.label} className="block text-sm">
|
||||
<span className="mb-1.5 block" style={{ color: themeVar('--card-foreground') }}>{field.label}</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={field.type}
|
||||
value={field.value}
|
||||
onChange={event => field.onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-10 w-full rounded-lg border px-3 pr-10 text-sm outline-none transition-colors focus:border-primary"
|
||||
style={{ borderColor: themeVar('--border'), color: themeVar('--foreground'), backgroundColor: themeVar('--card') }}
|
||||
/>
|
||||
{field.onToggleSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={field.onToggleSecret}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1"
|
||||
style={{ color: themeVar('--muted-foreground') }}
|
||||
aria-label={field.secretVisible ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
{field.secretVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="grid gap-1 text-sm md:grid-cols-2 md:gap-x-6" style={{ color: themeVar('--muted-foreground') }}>
|
||||
<span>最后连接时间: {formatDate(status.lastConnectedAt)}</span>
|
||||
<span>运行时长: {formatDuration(status.uptime)}</span>
|
||||
<span>已处理消息数: {status.messagesProcessed ?? 0}</span>
|
||||
<span>活跃会话数: {status.activeSessions ?? 0}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing || !enabled}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ backgroundColor: themeVar('--primary') }}
|
||||
title={!enabled ? '请先启用该渠道' : undefined}
|
||||
>
|
||||
{testing ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
测试连接
|
||||
</button>
|
||||
</div>
|
||||
{status.error && (
|
||||
<div className="mt-3 inline-flex items-center gap-1 text-sm" style={{ color: themeVar('--destructive') }}>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
{!status.error && status.status === 'connected' && (
|
||||
<div className="mt-3 inline-flex items-center gap-1 text-sm" style={{ color: themeVar('--success') }}>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
连接状态正常
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityTag({ severity }: { severity: LogEntry['severity'] }) {
|
||||
if (severity === 'error') {
|
||||
return <span className="rounded-full px-2 py-0.5 text-xs text-white" style={{ backgroundColor: themeVar('--destructive') }}>error</span>
|
||||
}
|
||||
if (severity === 'warning') {
|
||||
return <span className="rounded-full px-2 py-0.5 text-xs" style={{ backgroundColor: themeVar('--warning'), color: themeVar('--foreground') }}>warning</span>
|
||||
}
|
||||
return <span className="rounded-full px-2 py-0.5 text-xs" style={{ backgroundColor: themeVar('--success'), color: themeVar('--foreground') }}>info</span>
|
||||
}
|
||||
32
app/share/page.tsx
Normal file
32
app/share/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { NovaChat } from '@/components/nova-sdk/nova-chat'
|
||||
import { NovaState, useBuildConversationConnect } from '@/components/nova-sdk/hooks/useBuildConversationConnect'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function NovaChatPage() {
|
||||
const { chatEnabled, agentId, conversationId, platformConfig } = useBuildConversationConnect()
|
||||
|
||||
if (chatEnabled === NovaState.Failed) {
|
||||
return (
|
||||
<div className="w-full h-screen flex items-center justify-center">
|
||||
<p className="text-muted-foreground">Chat 不可用,请检查项目 .env 配置</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!agentId || !conversationId || !platformConfig) {
|
||||
return (
|
||||
<div className="w-full h-screen flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 text-primary animate-spin" />
|
||||
<p className="text-primary">正在连接中...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen">
|
||||
<NovaChat mode="share" conversationId={conversationId} platformConfig={platformConfig} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user