初始化模版工程

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

20
app/RouteChange.tsx Normal file
View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

594
app/globals.css Normal file
View 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
View 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
View 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;

View 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
View 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>
)
}