mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 为claude类型账号增加测试功能
This commit is contained in:
@@ -7,6 +7,7 @@ const express = require('express')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
const claudeAccountService = require('../../services/claudeAccountService')
|
const claudeAccountService = require('../../services/claudeAccountService')
|
||||||
|
const claudeRelayService = require('../../services/claudeRelayService')
|
||||||
const accountGroupService = require('../../services/accountGroupService')
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
const apiKeyService = require('../../services/apiKeyService')
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
@@ -787,4 +788,17 @@ router.put(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 测试Claude OAuth账户连通性(流式响应)- 复用 claudeRelayService
|
||||||
|
router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 直接调用服务层的测试方法
|
||||||
|
await claudeRelayService.testAccountConnection(accountId, res)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to test Claude OAuth account:`, error)
|
||||||
|
// 错误已在服务层处理,这里仅做日志记录
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const express = require('express')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||||
|
const claudeConsoleRelayService = require('../../services/claudeConsoleRelayService')
|
||||||
const accountGroupService = require('../../services/accountGroupService')
|
const accountGroupService = require('../../services/accountGroupService')
|
||||||
const apiKeyService = require('../../services/apiKeyService')
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
@@ -466,4 +467,17 @@ router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试Claude Console账户连通性(流式响应)- 复用 claudeConsoleRelayService
|
||||||
|
router.post('/claude-console-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 直接调用服务层的测试方法
|
||||||
|
await claudeConsoleRelayService.testAccountConnection(accountId, res)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to test Claude Console account:`, error)
|
||||||
|
// 错误已在服务层处理,这里仅做日志记录
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -104,18 +104,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const isStream = req.body.stream === true
|
const isStream = req.body.stream === true
|
||||||
|
|
||||||
// 临时修复新版本客户端,删除context_management字段,避免报错
|
// 临时修复新版本客户端,删除context_management字段,避免报错
|
||||||
if (req.body.context_management) {
|
// if (req.body.context_management) {
|
||||||
delete req.body.context_management
|
// delete req.body.context_management
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 遍历tools数组,删除input_examples字段
|
// 遍历tools数组,删除input_examples字段
|
||||||
if (req.body.tools && Array.isArray(req.body.tools)) {
|
// if (req.body.tools && Array.isArray(req.body.tools)) {
|
||||||
req.body.tools.forEach((tool) => {
|
// req.body.tools.forEach((tool) => {
|
||||||
if (tool && typeof tool === 'object' && tool.input_examples) {
|
// if (tool && typeof tool === 'object' && tool.input_examples) {
|
||||||
delete tool.input_examples
|
// delete tool.input_examples
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
logger.api(
|
logger.api(
|
||||||
`🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}`
|
`🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}`
|
||||||
|
|||||||
@@ -812,7 +812,9 @@ class ClaudeConsoleRelayService {
|
|||||||
'🎯 [Console] Complete usage data collected:',
|
'🎯 [Console] Complete usage data collected:',
|
||||||
JSON.stringify(collectedUsageData)
|
JSON.stringify(collectedUsageData)
|
||||||
)
|
)
|
||||||
usageCallback({ ...collectedUsageData, accountId })
|
if (usageCallback && typeof usageCallback === 'function') {
|
||||||
|
usageCallback({ ...collectedUsageData, accountId })
|
||||||
|
}
|
||||||
finalUsageReported = true
|
finalUsageReported = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,14 +832,21 @@ class ClaudeConsoleRelayService {
|
|||||||
error
|
error
|
||||||
)
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (!responseStream.destroyed) {
|
||||||
responseStream.write('event: error\n')
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
responseStream.write(
|
if (streamTransformer) {
|
||||||
`data: ${JSON.stringify({
|
responseStream.write(
|
||||||
error: 'Stream processing error',
|
`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`
|
||||||
message: error.message,
|
)
|
||||||
timestamp: new Date().toISOString()
|
} else {
|
||||||
})}\n\n`
|
responseStream.write('event: error\n')
|
||||||
)
|
responseStream.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
error: 'Stream processing error',
|
||||||
|
message: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -882,7 +891,9 @@ class ClaudeConsoleRelayService {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`📊 [Console] Saving incomplete usage data via fallback: ${JSON.stringify(collectedUsageData)}`
|
`📊 [Console] Saving incomplete usage data via fallback: ${JSON.stringify(collectedUsageData)}`
|
||||||
)
|
)
|
||||||
usageCallback({ ...collectedUsageData, accountId })
|
if (usageCallback && typeof usageCallback === 'function') {
|
||||||
|
usageCallback({ ...collectedUsageData, accountId })
|
||||||
|
}
|
||||||
finalUsageReported = true
|
finalUsageReported = true
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -910,14 +921,21 @@ class ClaudeConsoleRelayService {
|
|||||||
error
|
error
|
||||||
)
|
)
|
||||||
if (!responseStream.destroyed) {
|
if (!responseStream.destroyed) {
|
||||||
responseStream.write('event: error\n')
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
responseStream.write(
|
if (streamTransformer) {
|
||||||
`data: ${JSON.stringify({
|
responseStream.write(
|
||||||
error: 'Stream error',
|
`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`
|
||||||
message: error.message,
|
)
|
||||||
timestamp: new Date().toISOString()
|
} else {
|
||||||
})}\n\n`
|
responseStream.write('event: error\n')
|
||||||
)
|
responseStream.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
error: 'Stream error',
|
||||||
|
message: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
reject(error)
|
reject(error)
|
||||||
@@ -958,14 +976,21 @@ class ClaudeConsoleRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!responseStream.destroyed) {
|
if (!responseStream.destroyed) {
|
||||||
responseStream.write('event: error\n')
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
responseStream.write(
|
if (streamTransformer) {
|
||||||
`data: ${JSON.stringify({
|
responseStream.write(
|
||||||
error: error.message,
|
`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`
|
||||||
code: error.code,
|
)
|
||||||
timestamp: new Date().toISOString()
|
} else {
|
||||||
})}\n\n`
|
responseStream.write('event: error\n')
|
||||||
)
|
responseStream.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,6 +1054,133 @@ class ClaudeConsoleRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧪 创建测试用的流转换器,将 Claude API SSE 格式转换为前端期望的格式
|
||||||
|
_createTestStreamTransformer() {
|
||||||
|
let testStartSent = false
|
||||||
|
|
||||||
|
return (rawData) => {
|
||||||
|
const lines = rawData.split('\n')
|
||||||
|
const outputLines = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) {
|
||||||
|
// 保留空行用于 SSE 分隔
|
||||||
|
if (line.trim() === '') {
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStr = line.substring(6).trim()
|
||||||
|
if (!jsonStr || jsonStr === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
|
||||||
|
// 发送 test_start 事件(只在第一次 message_start 时发送)
|
||||||
|
if (data.type === 'message_start' && !testStartSent) {
|
||||||
|
testStartSent = true
|
||||||
|
outputLines.push(`data: ${JSON.stringify({ type: 'test_start' })}`)
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 content_block_delta 为 content
|
||||||
|
if (data.type === 'content_block_delta' && data.delta && data.delta.text) {
|
||||||
|
outputLines.push(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}`)
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 message_stop 为 test_complete
|
||||||
|
if (data.type === 'message_stop') {
|
||||||
|
outputLines.push(`data: ${JSON.stringify({ type: 'test_complete', success: true })}`)
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理错误事件
|
||||||
|
if (data.type === 'error') {
|
||||||
|
const errorMsg = data.error?.message || data.message || '未知错误'
|
||||||
|
outputLines.push(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}`)
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputLines.length > 0 ? outputLines.join('\n') : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeConsoleStreamRequest)
|
||||||
|
async testAccountConnection(accountId, responseStream) {
|
||||||
|
const testRequestBody = {
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
max_tokens: 100,
|
||||||
|
stream: true,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'hi'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`)
|
||||||
|
|
||||||
|
// 创建代理agent
|
||||||
|
const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy)
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
responseStream.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建流转换器,将 Claude API 格式转换为前端测试页面期望的格式
|
||||||
|
const streamTransformer = this._createTestStreamTransformer()
|
||||||
|
|
||||||
|
// 直接复用现有的流式请求方法
|
||||||
|
await this._makeClaudeConsoleStreamRequest(
|
||||||
|
testRequestBody,
|
||||||
|
account,
|
||||||
|
proxyAgent,
|
||||||
|
{}, // clientHeaders - 测试不需要客户端headers
|
||||||
|
responseStream,
|
||||||
|
accountId,
|
||||||
|
null, // usageCallback - 测试不需要统计
|
||||||
|
streamTransformer, // 使用转换器将 Claude API 格式转为前端期望格式
|
||||||
|
{} // requestOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(`✅ Test request completed for account: ${account.name}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Test account connection failed:`, error)
|
||||||
|
// 发送错误事件给前端
|
||||||
|
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||||
|
try {
|
||||||
|
const errorMsg = error.message || '测试失败'
|
||||||
|
responseStream.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||||
|
} catch {
|
||||||
|
// 忽略写入错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 健康检查
|
// 🎯 健康检查
|
||||||
async healthCheck() {
|
async healthCheck() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,6 +25,47 @@ class ClaudeRelayService {
|
|||||||
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header
|
||||||
|
// 规则:
|
||||||
|
// 1. 如果客户端传递了 anthropic-beta,检查是否包含 oauth-2025-04-20
|
||||||
|
// 2. 如果没有 oauth-2025-04-20,则添加到 claude-code-20250219 后面(如果有的话),否则放在第一位
|
||||||
|
// 3. 如果客户端没传递,则根据模型判断:haiku 不需要 claude-code,其他模型需要
|
||||||
|
_getBetaHeader(modelId, clientBetaHeader) {
|
||||||
|
const OAUTH_BETA = 'oauth-2025-04-20'
|
||||||
|
const CLAUDE_CODE_BETA = 'claude-code-20250219'
|
||||||
|
|
||||||
|
// 如果客户端传递了 anthropic-beta
|
||||||
|
if (clientBetaHeader) {
|
||||||
|
// 检查是否已包含 oauth-2025-04-20
|
||||||
|
if (clientBetaHeader.includes(OAUTH_BETA)) {
|
||||||
|
return clientBetaHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要添加 oauth-2025-04-20
|
||||||
|
const parts = clientBetaHeader.split(',').map((p) => p.trim())
|
||||||
|
|
||||||
|
// 找到 claude-code-20250219 的位置
|
||||||
|
const claudeCodeIndex = parts.findIndex((p) => p === CLAUDE_CODE_BETA)
|
||||||
|
|
||||||
|
if (claudeCodeIndex !== -1) {
|
||||||
|
// 在 claude-code-20250219 后面插入
|
||||||
|
parts.splice(claudeCodeIndex + 1, 0, OAUTH_BETA)
|
||||||
|
} else {
|
||||||
|
// 放在第一位
|
||||||
|
parts.unshift(OAUTH_BETA)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端没有传递,根据模型判断
|
||||||
|
const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku')
|
||||||
|
if (isHaikuModel) {
|
||||||
|
return 'oauth-2025-04-20,interleaved-thinking-2025-05-14'
|
||||||
|
}
|
||||||
|
return 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||||
|
}
|
||||||
|
|
||||||
_buildStandardRateLimitMessage(resetTime) {
|
_buildStandardRateLimitMessage(resetTime) {
|
||||||
if (!resetTime) {
|
if (!resetTime) {
|
||||||
return '此专属账号已触发 Anthropic 限流控制。'
|
return '此专属账号已触发 Anthropic 限流控制。'
|
||||||
@@ -1018,12 +1059,10 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
||||||
|
|
||||||
// 使用自定义的 betaHeader 或默认值
|
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||||
const betaHeader =
|
const modelId = requestPayload?.model || body?.model
|
||||||
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||||
if (betaHeader) {
|
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||||
options.headers['anthropic-beta'] = betaHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
let responseData = Buffer.alloc(0)
|
let responseData = Buffer.alloc(0)
|
||||||
@@ -1229,7 +1268,9 @@ class ClaudeRelayService {
|
|||||||
responseStream,
|
responseStream,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 在usageCallback中添加accountId
|
// 在usageCallback中添加accountId
|
||||||
usageCallback({ ...usageData, accountId })
|
if (usageCallback && typeof usageCallback === 'function') {
|
||||||
|
usageCallback({ ...usageData, accountId })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
accountId,
|
accountId,
|
||||||
accountType,
|
accountType,
|
||||||
@@ -1333,12 +1374,10 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
||||||
// 使用自定义的 betaHeader 或默认值
|
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||||
const betaHeader =
|
const modelId = body?.model
|
||||||
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||||
if (betaHeader) {
|
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||||
options.headers['anthropic-beta'] = betaHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
const req = https.request(options, async (res) => {
|
const req = https.request(options, async (res) => {
|
||||||
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
|
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
|
||||||
@@ -1509,16 +1548,36 @@ class ClaudeRelayService {
|
|||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
if (!responseStream.destroyed) {
|
if (!responseStream.destroyed) {
|
||||||
// 发送错误事件
|
// 解析 Claude API 返回的错误详情
|
||||||
responseStream.write('event: error\n')
|
let errorMessage = `Claude API error: ${res.statusCode}`
|
||||||
responseStream.write(
|
try {
|
||||||
`data: ${JSON.stringify({
|
const parsedError = JSON.parse(errorData)
|
||||||
error: 'Claude API error',
|
if (parsedError.error?.message) {
|
||||||
status: res.statusCode,
|
errorMessage = parsedError.error.message
|
||||||
details: errorData,
|
} else if (parsedError.message) {
|
||||||
timestamp: new Date().toISOString()
|
errorMessage = parsedError.message
|
||||||
})}\n\n`
|
}
|
||||||
)
|
} catch {
|
||||||
|
// 使用默认错误消息
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
|
if (streamTransformer) {
|
||||||
|
responseStream.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 标准错误格式
|
||||||
|
responseStream.write('event: error\n')
|
||||||
|
responseStream.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
error: 'Claude API error',
|
||||||
|
status: res.statusCode,
|
||||||
|
details: errorData,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
reject(new Error(`Claude API error: ${res.statusCode}`))
|
reject(new Error(`Claude API error: ${res.statusCode}`))
|
||||||
@@ -1758,7 +1817,9 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用一次usageCallback记录合并后的数据
|
// 调用一次usageCallback记录合并后的数据
|
||||||
usageCallback(finalUsage)
|
if (usageCallback && typeof usageCallback === 'function') {
|
||||||
|
usageCallback(finalUsage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取5小时会话窗口状态
|
// 提取5小时会话窗口状态
|
||||||
@@ -2129,6 +2190,151 @@ class ClaudeRelayService {
|
|||||||
return 0 // 两个版本号相等
|
return 0 // 两个版本号相等
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧪 创建测试用的流转换器,将 Claude API SSE 格式转换为前端期望的格式
|
||||||
|
_createTestStreamTransformer() {
|
||||||
|
let testStartSent = false
|
||||||
|
|
||||||
|
return (rawData) => {
|
||||||
|
const lines = rawData.split('\n')
|
||||||
|
const outputLines = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) {
|
||||||
|
// 保留空行用于 SSE 分隔
|
||||||
|
if (line.trim() === '') {
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStr = line.substring(6).trim()
|
||||||
|
if (!jsonStr || jsonStr === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
|
||||||
|
// 发送 test_start 事件(只在第一次 message_start 时发送)
|
||||||
|
if (data.type === 'message_start' && !testStartSent) {
|
||||||
|
testStartSent = true
|
||||||
|
outputLines.push(`data: ${JSON.stringify({ type: 'test_start' })}`)
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 content_block_delta 为 content
|
||||||
|
if (data.type === 'content_block_delta' && data.delta && data.delta.text) {
|
||||||
|
outputLines.push(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}`)
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 message_stop 为 test_complete
|
||||||
|
if (data.type === 'message_stop') {
|
||||||
|
outputLines.push(`data: ${JSON.stringify({ type: 'test_complete', success: true })}`)
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理错误事件
|
||||||
|
if (data.type === 'error') {
|
||||||
|
const errorMsg = data.error?.message || data.message || '未知错误'
|
||||||
|
outputLines.push(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}`)
|
||||||
|
outputLines.push('')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputLines.length > 0 ? outputLines.join('\n') : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
||||||
|
async testAccountConnection(accountId, responseStream) {
|
||||||
|
const testRequestBody = {
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
max_tokens: 100,
|
||||||
|
stream: true,
|
||||||
|
system: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: this.claudeCodeSystemPrompt,
|
||||||
|
cache_control: {
|
||||||
|
type: 'ephemeral'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'hi'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
|
||||||
|
|
||||||
|
// 获取有效的访问token
|
||||||
|
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Failed to get valid access token')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取代理配置
|
||||||
|
const proxyAgent = await this._getProxyAgent(accountId)
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
responseStream.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建流转换器,将 Claude API 格式转换为前端测试页面期望的格式
|
||||||
|
const streamTransformer = this._createTestStreamTransformer()
|
||||||
|
|
||||||
|
// 直接复用现有的流式请求方法
|
||||||
|
await this._makeClaudeStreamRequestWithUsageCapture(
|
||||||
|
testRequestBody,
|
||||||
|
accessToken,
|
||||||
|
proxyAgent,
|
||||||
|
{}, // clientHeaders - 测试不需要客户端headers
|
||||||
|
responseStream,
|
||||||
|
null, // usageCallback - 测试不需要统计
|
||||||
|
accountId,
|
||||||
|
'claude-official', // accountType
|
||||||
|
null, // sessionHash - 测试不需要会话
|
||||||
|
streamTransformer, // 使用转换器将 Claude API 格式转为前端期望格式
|
||||||
|
{}, // requestOptions
|
||||||
|
false // isDedicatedOfficialAccount
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(`✅ Test request completed for account: ${account.name}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Test account connection failed:`, error)
|
||||||
|
// 发送错误事件给前端
|
||||||
|
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||||
|
try {
|
||||||
|
const errorMsg = error.message || '测试失败'
|
||||||
|
responseStream.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||||
|
} catch {
|
||||||
|
// 忽略写入错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 健康检查
|
// 🎯 健康检查
|
||||||
async healthCheck() {
|
async healthCheck() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
491
web/admin-spa/src/components/accounts/AccountTestModal.vue
Normal file
491
web/admin-spa/src/components/accounts/AccountTestModal.vue
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0" @click="handleClose" />
|
||||||
|
<div
|
||||||
|
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
|
||||||
|
>
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-white shadow-lg',
|
||||||
|
testStatus === 'success'
|
||||||
|
? 'bg-gradient-to-br from-green-500 to-emerald-500'
|
||||||
|
: testStatus === 'error'
|
||||||
|
? 'bg-gradient-to-br from-red-500 to-pink-500'
|
||||||
|
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'fas',
|
||||||
|
testStatus === 'idle'
|
||||||
|
? 'fa-vial'
|
||||||
|
: testStatus === 'testing'
|
||||||
|
? 'fa-spinner fa-spin'
|
||||||
|
: testStatus === 'success'
|
||||||
|
? 'fa-check'
|
||||||
|
: 'fa-times'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">账户连通性测试</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ account?.name || '未知账户' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||||
|
:disabled="testStatus === 'testing'"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="px-5 py-4">
|
||||||
|
<!-- 测试信息 -->
|
||||||
|
<div class="mb-4 space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">平台类型</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
platformBadgeClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i :class="platformIcon" />
|
||||||
|
{{ platformLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态指示 -->
|
||||||
|
<div :class="['mb-4 rounded-xl border p-4 transition-all duration-300', statusCardClass]">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
:class="['flex h-8 w-8 items-center justify-center rounded-lg', statusIconBgClass]"
|
||||||
|
>
|
||||||
|
<i :class="['fas text-sm', statusIcon, statusIconClass]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p :class="['font-medium', statusTextClass]">{{ statusTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ statusDescription }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 响应内容区域 -->
|
||||||
|
<div
|
||||||
|
v-if="testStatus !== 'idle'"
|
||||||
|
class="mb-4 overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">AI 响应</span>
|
||||||
|
<span v-if="responseText" class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
{{ responseText.length }} 字符
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-40 overflow-y-auto p-3">
|
||||||
|
<p
|
||||||
|
v-if="responseText"
|
||||||
|
class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ responseText }}
|
||||||
|
<span
|
||||||
|
v-if="testStatus === 'testing'"
|
||||||
|
class="inline-block h-4 w-1 animate-pulse bg-blue-500"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="testStatus === 'testing'"
|
||||||
|
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-circle-notch fa-spin" />
|
||||||
|
等待响应中...
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="testStatus === 'error' && errorMessage"
|
||||||
|
class="text-sm text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试时间 -->
|
||||||
|
<div
|
||||||
|
v-if="testDuration > 0"
|
||||||
|
class="mb-4 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-clock" />
|
||||||
|
<span>耗时 {{ (testDuration / 1000).toFixed(2) }} 秒</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
:disabled="testStatus === 'testing'"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
|
||||||
|
testStatus === 'testing'
|
||||||
|
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||||
|
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
|
||||||
|
]"
|
||||||
|
:disabled="testStatus === 'testing'"
|
||||||
|
@click="startTest"
|
||||||
|
>
|
||||||
|
<i :class="['fas', testStatus === 'testing' ? 'fa-spinner fa-spin' : 'fa-play']" />
|
||||||
|
{{
|
||||||
|
testStatus === 'testing'
|
||||||
|
? '测试中...'
|
||||||
|
: testStatus === 'idle'
|
||||||
|
? '开始测试'
|
||||||
|
: '重新测试'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { API_PREFIX } from '@/config/api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const testStatus = ref('idle') // idle, testing, success, error
|
||||||
|
const responseText = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const testDuration = ref(0)
|
||||||
|
const testStartTime = ref(null)
|
||||||
|
const eventSource = ref(null)
|
||||||
|
|
||||||
|
// 测试模型
|
||||||
|
const testModel = ref('claude-sonnet-4-5-20250929')
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const platformLabel = computed(() => {
|
||||||
|
if (!props.account) return '未知'
|
||||||
|
const platform = props.account.platform
|
||||||
|
if (platform === 'claude') return 'Claude OAuth'
|
||||||
|
if (platform === 'claude-console') return 'Claude Console'
|
||||||
|
return platform
|
||||||
|
})
|
||||||
|
|
||||||
|
const platformIcon = computed(() => {
|
||||||
|
if (!props.account) return 'fas fa-question'
|
||||||
|
const platform = props.account.platform
|
||||||
|
if (platform === 'claude' || platform === 'claude-console') return 'fas fa-brain'
|
||||||
|
return 'fas fa-robot'
|
||||||
|
})
|
||||||
|
|
||||||
|
const platformBadgeClass = computed(() => {
|
||||||
|
if (!props.account) return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
const platform = props.account.platform
|
||||||
|
if (platform === 'claude') {
|
||||||
|
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||||
|
}
|
||||||
|
if (platform === 'claude-console') {
|
||||||
|
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusTitle = computed(() => {
|
||||||
|
switch (testStatus.value) {
|
||||||
|
case 'idle':
|
||||||
|
return '准备就绪'
|
||||||
|
case 'testing':
|
||||||
|
return '正在测试...'
|
||||||
|
case 'success':
|
||||||
|
return '测试成功'
|
||||||
|
case 'error':
|
||||||
|
return '测试失败'
|
||||||
|
default:
|
||||||
|
return '未知状态'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusDescription = computed(() => {
|
||||||
|
switch (testStatus.value) {
|
||||||
|
case 'idle':
|
||||||
|
return '点击下方按钮开始测试账户连通性'
|
||||||
|
case 'testing':
|
||||||
|
return '正在发送测试请求并等待响应'
|
||||||
|
case 'success':
|
||||||
|
return '账户可以正常访问 Claude API'
|
||||||
|
case 'error':
|
||||||
|
return errorMessage.value || '无法连接到 Claude API'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusCardClass = computed(() => {
|
||||||
|
switch (testStatus.value) {
|
||||||
|
case 'idle':
|
||||||
|
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
|
||||||
|
case 'testing':
|
||||||
|
return 'border-blue-200 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-900/20'
|
||||||
|
case 'success':
|
||||||
|
return 'border-green-200 bg-green-50 dark:border-green-500/30 dark:bg-green-900/20'
|
||||||
|
case 'error':
|
||||||
|
return 'border-red-200 bg-red-50 dark:border-red-500/30 dark:bg-red-900/20'
|
||||||
|
default:
|
||||||
|
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusIconBgClass = computed(() => {
|
||||||
|
switch (testStatus.value) {
|
||||||
|
case 'idle':
|
||||||
|
return 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
case 'testing':
|
||||||
|
return 'bg-blue-100 dark:bg-blue-500/30'
|
||||||
|
case 'success':
|
||||||
|
return 'bg-green-100 dark:bg-green-500/30'
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-100 dark:bg-red-500/30'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusIcon = computed(() => {
|
||||||
|
switch (testStatus.value) {
|
||||||
|
case 'idle':
|
||||||
|
return 'fa-hourglass-start'
|
||||||
|
case 'testing':
|
||||||
|
return 'fa-spinner fa-spin'
|
||||||
|
case 'success':
|
||||||
|
return 'fa-check-circle'
|
||||||
|
case 'error':
|
||||||
|
return 'fa-exclamation-circle'
|
||||||
|
default:
|
||||||
|
return 'fa-question-circle'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusIconClass = computed(() => {
|
||||||
|
switch (testStatus.value) {
|
||||||
|
case 'idle':
|
||||||
|
return 'text-gray-500 dark:text-gray-400'
|
||||||
|
case 'testing':
|
||||||
|
return 'text-blue-500 dark:text-blue-400'
|
||||||
|
case 'success':
|
||||||
|
return 'text-green-500 dark:text-green-400'
|
||||||
|
case 'error':
|
||||||
|
return 'text-red-500 dark:text-red-400'
|
||||||
|
default:
|
||||||
|
return 'text-gray-500 dark:text-gray-400'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusTextClass = computed(() => {
|
||||||
|
switch (testStatus.value) {
|
||||||
|
case 'idle':
|
||||||
|
return 'text-gray-700 dark:text-gray-300'
|
||||||
|
case 'testing':
|
||||||
|
return 'text-blue-700 dark:text-blue-300'
|
||||||
|
case 'success':
|
||||||
|
return 'text-green-700 dark:text-green-300'
|
||||||
|
case 'error':
|
||||||
|
return 'text-red-700 dark:text-red-300'
|
||||||
|
default:
|
||||||
|
return 'text-gray-700 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function getTestEndpoint() {
|
||||||
|
if (!props.account) return ''
|
||||||
|
const platform = props.account.platform
|
||||||
|
if (platform === 'claude') {
|
||||||
|
return `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test`
|
||||||
|
}
|
||||||
|
if (platform === 'claude-console') {
|
||||||
|
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTest() {
|
||||||
|
if (!props.account) return
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
testStatus.value = 'testing'
|
||||||
|
responseText.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
testDuration.value = 0
|
||||||
|
testStartTime.value = Date.now()
|
||||||
|
|
||||||
|
// 关闭之前的连接
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = getTestEndpoint()
|
||||||
|
if (!endpoint) {
|
||||||
|
testStatus.value = 'error'
|
||||||
|
errorMessage.value = '不支持的账户类型'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取认证token
|
||||||
|
const authToken = localStorage.getItem('authToken')
|
||||||
|
|
||||||
|
// 使用fetch发送POST请求并处理SSE
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model: testModel.value })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || `HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理SSE流
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let streamDone = false
|
||||||
|
|
||||||
|
while (!streamDone) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) {
|
||||||
|
streamDone = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value)
|
||||||
|
const lines = chunk.split('\n')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.substring(6))
|
||||||
|
handleSSEEvent(data)
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
testStatus.value = 'error'
|
||||||
|
errorMessage.value = err.message || '连接失败'
|
||||||
|
testDuration.value = Date.now() - testStartTime.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSSEEvent(data) {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'test_start':
|
||||||
|
// 测试开始
|
||||||
|
break
|
||||||
|
case 'content':
|
||||||
|
responseText.value += data.text
|
||||||
|
break
|
||||||
|
case 'message_stop':
|
||||||
|
// 消息结束
|
||||||
|
break
|
||||||
|
case 'test_complete':
|
||||||
|
testDuration.value = Date.now() - testStartTime.value
|
||||||
|
if (data.success) {
|
||||||
|
testStatus.value = 'success'
|
||||||
|
} else {
|
||||||
|
testStatus.value = 'error'
|
||||||
|
errorMessage.value = data.error || '测试失败'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
testStatus.value = 'error'
|
||||||
|
errorMessage.value = data.error || '未知错误'
|
||||||
|
testDuration.value = Date.now() - testStartTime.value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
if (testStatus.value === 'testing') return
|
||||||
|
|
||||||
|
// 关闭SSE连接
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close()
|
||||||
|
eventSource.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
testStatus.value = 'idle'
|
||||||
|
responseText.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
testDuration.value = 0
|
||||||
|
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听show变化,重置状态
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
testStatus.value = 'idle'
|
||||||
|
responseText.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
testDuration.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 组件卸载时清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1187,6 +1187,15 @@
|
|||||||
<i class="fas fa-chart-line" />
|
<i class="fas fa-chart-line" />
|
||||||
<span class="ml-1">详情</span>
|
<span class="ml-1">详情</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canTestAccount(account)"
|
||||||
|
class="rounded bg-cyan-100 px-2.5 py-1 text-xs font-medium text-cyan-700 transition-colors hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"
|
||||||
|
:title="'测试账户连通性'"
|
||||||
|
@click="openAccountTestModal(account)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-vial" />
|
||||||
|
<span class="ml-1">测试</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
||||||
:title="'编辑账户'"
|
:title="'编辑账户'"
|
||||||
@@ -1619,6 +1628,15 @@
|
|||||||
详情
|
详情
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canTestAccount(account)"
|
||||||
|
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-cyan-50 px-3 py-2 text-xs text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"
|
||||||
|
@click="openAccountTestModal(account)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-vial" />
|
||||||
|
测试
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
||||||
@click="editAccount(account)"
|
@click="editAccount(account)"
|
||||||
@@ -1784,6 +1802,13 @@
|
|||||||
@close="closeAccountExpiryEdit"
|
@close="closeAccountExpiryEdit"
|
||||||
@save="handleSaveAccountExpiry"
|
@save="handleSaveAccountExpiry"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 账户测试弹窗 -->
|
||||||
|
<AccountTestModal
|
||||||
|
:account="testingAccount"
|
||||||
|
:show="showAccountTestModal"
|
||||||
|
@close="closeAccountTestModal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1796,6 +1821,7 @@ import AccountForm from '@/components/accounts/AccountForm.vue'
|
|||||||
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||||
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
||||||
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
|
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
|
||||||
|
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||||
|
|
||||||
@@ -1858,6 +1884,10 @@ const supportedUsagePlatforms = [
|
|||||||
const editingExpiryAccount = ref(null)
|
const editingExpiryAccount = ref(null)
|
||||||
const expiryEditModalRef = ref(null)
|
const expiryEditModalRef = ref(null)
|
||||||
|
|
||||||
|
// 测试弹窗状态
|
||||||
|
const showAccountTestModal = ref(false)
|
||||||
|
const testingAccount = ref(null)
|
||||||
|
|
||||||
// 缓存状态标志
|
// 缓存状态标志
|
||||||
const apiKeysLoaded = ref(false) // 用于其他功能
|
const apiKeysLoaded = ref(false) // 用于其他功能
|
||||||
const bindingCountsLoaded = ref(false) // 轻量级绑定计数缓存
|
const bindingCountsLoaded = ref(false) // 轻量级绑定计数缓存
|
||||||
@@ -2021,6 +2051,27 @@ const closeAccountUsageModal = () => {
|
|||||||
selectedAccountForUsage.value = null
|
selectedAccountForUsage.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试账户连通性相关函数
|
||||||
|
const supportedTestPlatforms = ['claude', 'claude-console']
|
||||||
|
|
||||||
|
const canTestAccount = (account) => {
|
||||||
|
return !!account && supportedTestPlatforms.includes(account.platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAccountTestModal = (account) => {
|
||||||
|
if (!canTestAccount(account)) {
|
||||||
|
showToast('该账户类型暂不支持测试', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testingAccount.value = account
|
||||||
|
showAccountTestModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAccountTestModal = () => {
|
||||||
|
showAccountTestModal.value = false
|
||||||
|
testingAccount.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// 计算排序后的账户列表
|
// 计算排序后的账户列表
|
||||||
const sortedAccounts = computed(() => {
|
const sortedAccounts = computed(() => {
|
||||||
let sourceAccounts = accounts.value
|
let sourceAccounts = accounts.value
|
||||||
|
|||||||
Reference in New Issue
Block a user