|
@@ -20,7 +20,7 @@ import {
|
|
|
saveProviderCircuitConfig,
|
|
saveProviderCircuitConfig,
|
|
|
deleteProviderCircuitConfig,
|
|
deleteProviderCircuitConfig,
|
|
|
} from "@/lib/redis/circuit-breaker-config";
|
|
} from "@/lib/redis/circuit-breaker-config";
|
|
|
-import { isValidProxyUrl } from "@/lib/proxy-agent";
|
|
|
|
|
|
|
+import { isValidProxyUrl, type ProviderProxyConfig } from "@/lib/proxy-agent";
|
|
|
import { CodexInstructionsCache } from "@/lib/codex-instructions-cache";
|
|
import { CodexInstructionsCache } from "@/lib/codex-instructions-cache";
|
|
|
import { isClientAbortError } from "@/app/v1/_lib/proxy/errors";
|
|
import { isClientAbortError } from "@/app/v1/_lib/proxy/errors";
|
|
|
|
|
|
|
@@ -606,6 +606,18 @@ export async function testProviderProxy(data: {
|
|
|
return { ok: false, error: "无权限执行此操作" };
|
|
return { ok: false, error: "无权限执行此操作" };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const providerUrlValidation = validateProviderUrlForConnectivity(data.providerUrl);
|
|
|
|
|
+ if (!providerUrlValidation.valid) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ message: providerUrlValidation.error.message,
|
|
|
|
|
+ details: providerUrlValidation.error.details,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 验证代理 URL 格式
|
|
// 验证代理 URL 格式
|
|
|
if (data.proxyUrl && !isValidProxyUrl(data.proxyUrl)) {
|
|
if (data.proxyUrl && !isValidProxyUrl(data.proxyUrl)) {
|
|
|
return {
|
|
return {
|
|
@@ -627,12 +639,13 @@ export async function testProviderProxy(data: {
|
|
|
const { createProxyAgentForProvider } = await import("@/lib/proxy-agent");
|
|
const { createProxyAgentForProvider } = await import("@/lib/proxy-agent");
|
|
|
|
|
|
|
|
// 构造临时 Provider 对象(用于创建代理 agent)
|
|
// 构造临时 Provider 对象(用于创建代理 agent)
|
|
|
- const tempProvider = {
|
|
|
|
|
|
|
+ // 使用类型安全的 ProviderProxyConfig 接口,避免 any
|
|
|
|
|
+ const tempProvider: ProviderProxyConfig = {
|
|
|
id: -1,
|
|
id: -1,
|
|
|
- proxyUrl: data.proxyUrl,
|
|
|
|
|
|
|
+ name: "test-connection",
|
|
|
|
|
+ proxyUrl: data.proxyUrl ?? null,
|
|
|
proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
|
|
proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
- } as any;
|
|
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
// 创建代理配置
|
|
// 创建代理配置
|
|
@@ -741,3 +754,412 @@ export async function getUnmaskedProviderKey(id: number): Promise<ActionResult<{
|
|
|
return { ok: false, error: message };
|
|
return { ok: false, error: message };
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+type ProviderApiTestArgs = {
|
|
|
|
|
+ providerUrl: string;
|
|
|
|
|
+ apiKey: string;
|
|
|
|
|
+ model?: string;
|
|
|
|
|
+ proxyUrl?: string | null;
|
|
|
|
|
+ proxyFallbackToDirect?: boolean;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type ProviderApiTestResult = ActionResult<
|
|
|
|
|
+ | {
|
|
|
|
|
+ success: true;
|
|
|
|
|
+ message: string;
|
|
|
|
|
+ details?: {
|
|
|
|
|
+ responseTime?: number;
|
|
|
|
|
+ model?: string;
|
|
|
|
|
+ usage?: Record<string, unknown>;
|
|
|
|
|
+ content?: string;
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ | {
|
|
|
|
|
+ success: false;
|
|
|
|
|
+ message: string;
|
|
|
|
|
+ details?: {
|
|
|
|
|
+ responseTime?: number;
|
|
|
|
|
+ error?: string;
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+>;
|
|
|
|
|
+
|
|
|
|
|
+type ProviderApiResponse = Record<string, unknown> & {
|
|
|
|
|
+ model?: string;
|
|
|
|
|
+ usage?: Record<string, unknown>;
|
|
|
|
|
+ content?: Array<{ type?: string; text?: string } | string | Record<string, unknown>>;
|
|
|
|
|
+ choices?: Array<{
|
|
|
|
|
+ message?: {
|
|
|
|
|
+ content?: string;
|
|
|
|
|
+ };
|
|
|
|
|
+ }>;
|
|
|
|
|
+ output?: Array<
|
|
|
|
|
+ | {
|
|
|
|
|
+ type?: string;
|
|
|
|
|
+ content?: Array<{
|
|
|
|
|
+ type?: string;
|
|
|
|
|
+ text?: string;
|
|
|
|
|
+ } | Record<string, unknown>>;
|
|
|
|
|
+ }
|
|
|
|
|
+ | Record<string, unknown>
|
|
|
|
|
+ >;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function extractFirstTextSnippet(
|
|
|
|
|
+ entries: ProviderApiResponse["content"],
|
|
|
|
|
+ maxLength = 100
|
|
|
|
|
+): string | undefined {
|
|
|
|
|
+ if (!Array.isArray(entries)) {
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const entry of entries) {
|
|
|
|
|
+ if (typeof entry === "string") {
|
|
|
|
|
+ return entry.substring(0, maxLength);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (entry && typeof entry === "object" && "text" in entry) {
|
|
|
|
|
+ const textValue = (entry as { text?: unknown }).text;
|
|
|
|
|
+ if (typeof textValue === "string") {
|
|
|
|
|
+ return textValue.substring(0, maxLength);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clipText(value: unknown, maxLength = 100): string | undefined {
|
|
|
|
|
+ return typeof value === "string" ? value.substring(0, maxLength) : undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ProviderUrlValidationError = {
|
|
|
|
|
+ message: string;
|
|
|
|
|
+ details: {
|
|
|
|
|
+ error: string;
|
|
|
|
|
+ errorType: "InvalidProviderUrl" | "BlockedUrl" | "BlockedPort";
|
|
|
|
|
+ };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function validateProviderUrlForConnectivity(
|
|
|
|
|
+ providerUrl: string
|
|
|
|
|
+): { valid: true; normalizedUrl: string } | { valid: false; error: ProviderUrlValidationError } {
|
|
|
|
|
+ const trimmedUrl = providerUrl.trim();
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsedProviderUrl = new URL(trimmedUrl);
|
|
|
|
|
+
|
|
|
|
|
+ if (!["https:", "http:"].includes(parsedProviderUrl.protocol)) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ valid: false,
|
|
|
|
|
+ error: {
|
|
|
|
|
+ message: "供应商地址格式无效",
|
|
|
|
|
+ details: {
|
|
|
|
|
+ error: "仅支持 HTTP 和 HTTPS 协议",
|
|
|
|
|
+ errorType: "InvalidProviderUrl",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const hostname = parsedProviderUrl.hostname.toLowerCase();
|
|
|
|
|
+ const blockedPatterns = [
|
|
|
|
|
+ /^localhost$/i,
|
|
|
|
|
+ /^127\.\d+\.\d+\.\d+$/,
|
|
|
|
|
+ /^10\.\d+\.\d+\.\d+$/,
|
|
|
|
|
+ /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
|
|
|
|
|
+ /^192\.168\.\d+\.\d+$/,
|
|
|
|
|
+ /^169\.254\.\d+\.\d+$/,
|
|
|
|
|
+ /^::1$/,
|
|
|
|
|
+ /^fe80:/i,
|
|
|
|
|
+ /^fc00:/i,
|
|
|
|
|
+ /^fd00:/i,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if (blockedPatterns.some((pattern) => pattern.test(hostname))) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ valid: false,
|
|
|
|
|
+ error: {
|
|
|
|
|
+ message: "供应商地址安全检查失败",
|
|
|
|
|
+ details: {
|
|
|
|
|
+ error: "不允许访问内部网络地址",
|
|
|
|
|
+ errorType: "BlockedUrl",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const port = parsedProviderUrl.port ? parseInt(parsedProviderUrl.port, 10) : null;
|
|
|
|
|
+ const dangerousPorts = [22, 23, 25, 3306, 5432, 6379, 27017, 9200];
|
|
|
|
|
+ if (port && dangerousPorts.includes(port)) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ valid: false,
|
|
|
|
|
+ error: {
|
|
|
|
|
+ message: "供应商地址端口检查失败",
|
|
|
|
|
+ details: {
|
|
|
|
|
+ error: "不允许访问内部服务端口",
|
|
|
|
|
+ errorType: "BlockedPort",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { valid: true, normalizedUrl: trimmedUrl };
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ valid: false,
|
|
|
|
|
+ error: {
|
|
|
|
|
+ message: "供应商地址格式无效",
|
|
|
|
|
+ details: {
|
|
|
|
|
+ error: error instanceof Error ? error.message : "URL 解析失败",
|
|
|
|
|
+ errorType: "InvalidProviderUrl",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function executeProviderApiTest(
|
|
|
|
|
+ data: ProviderApiTestArgs,
|
|
|
|
|
+ options: {
|
|
|
|
|
+ path: string;
|
|
|
|
|
+ defaultModel: string;
|
|
|
|
|
+ headers: (apiKey: string) => Record<string, string>;
|
|
|
|
|
+ body: (model: string) => unknown;
|
|
|
|
|
+ successMessage: string;
|
|
|
|
|
+ extract: (result: ProviderApiResponse) => {
|
|
|
|
|
+ model?: string;
|
|
|
|
|
+ usage?: Record<string, unknown>;
|
|
|
|
|
+ content?: string;
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+): Promise<ProviderApiTestResult> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const session = await getSession();
|
|
|
|
|
+ if (!session || session.user.role !== "admin") {
|
|
|
|
|
+ return { ok: false, error: "无权限执行此操作" };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (data.proxyUrl && !isValidProxyUrl(data.proxyUrl)) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ message: "代理地址格式无效",
|
|
|
|
|
+ details: {
|
|
|
|
|
+ error: "支持格式: http://, https://, socks5://, socks4://",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const providerUrlValidation = validateProviderUrlForConnectivity(data.providerUrl);
|
|
|
|
|
+ if (!providerUrlValidation.valid) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ message: providerUrlValidation.error.message,
|
|
|
|
|
+ details: providerUrlValidation.error.details,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const normalizedProviderUrl = providerUrlValidation.normalizedUrl.replace(/\/$/, "");
|
|
|
|
|
+
|
|
|
|
|
+ const startTime = Date.now();
|
|
|
|
|
+ const { createProxyAgentForProvider } = await import("@/lib/proxy-agent");
|
|
|
|
|
+
|
|
|
|
|
+ const tempProvider: ProviderProxyConfig = {
|
|
|
|
|
+ id: -1,
|
|
|
|
|
+ name: "api-test",
|
|
|
|
|
+ proxyUrl: data.proxyUrl ?? null,
|
|
|
|
|
+ proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const url = normalizedProviderUrl + options.path;
|
|
|
|
|
+ const model = data.model || options.defaultModel;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const proxyConfig = createProxyAgentForProvider(tempProvider, url);
|
|
|
|
|
+
|
|
|
|
|
+ interface UndiciFetchOptions extends RequestInit {
|
|
|
|
|
+ dispatcher?: unknown;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const init: UndiciFetchOptions = {
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: options.headers(data.apiKey),
|
|
|
|
|
+ body: JSON.stringify(options.body(model)),
|
|
|
|
|
+ signal: AbortSignal.timeout(30000),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (proxyConfig) {
|
|
|
|
|
+ init.dispatcher = proxyConfig.agent;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch(url, init);
|
|
|
|
|
+ const responseTime = Date.now() - startTime;
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ const errorText = await response.text();
|
|
|
|
|
+ let errorDetail: string | undefined;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const errorJson = JSON.parse(errorText);
|
|
|
|
|
+ errorDetail = errorJson.error?.message || errorJson.message;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ errorDetail = undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.error("Provider API test failed", {
|
|
|
|
|
+ providerUrl: normalizedProviderUrl,
|
|
|
|
|
+ path: options.path,
|
|
|
|
|
+ status: response.status,
|
|
|
|
|
+ errorDetail: errorDetail ?? errorText,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ message: `API 返回错误: HTTP ${response.status}`,
|
|
|
|
|
+ details: {
|
|
|
|
|
+ responseTime,
|
|
|
|
|
+ error: "API 请求失败,查看日志以获得更多信息",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
+ const extracted = options.extract(result);
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ message: options.successMessage,
|
|
|
|
|
+ details: {
|
|
|
|
|
+ responseTime,
|
|
|
|
|
+ ...extracted,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const responseTime = Date.now() - startTime;
|
|
|
|
|
+ const err = error as Error & { code?: string };
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ message: `连接失败: ${err.message}`,
|
|
|
|
|
+ details: {
|
|
|
|
|
+ responseTime,
|
|
|
|
|
+ error: err.message,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error("测试供应商 API 失败:", error);
|
|
|
|
|
+ const message = error instanceof Error ? error.message : "测试失败";
|
|
|
|
|
+ return { ok: false, error: message };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 测试 Anthropic Messages API 连通性
|
|
|
|
|
+ */
|
|
|
|
|
+export async function testProviderAnthropicMessages(
|
|
|
|
|
+ data: ProviderApiTestArgs
|
|
|
|
|
+): Promise<ProviderApiTestResult> {
|
|
|
|
|
+ return executeProviderApiTest(data, {
|
|
|
|
|
+ path: "/v1/messages",
|
|
|
|
|
+ defaultModel: "claude-3-5-sonnet-20241022",
|
|
|
|
|
+ headers: (apiKey) => ({
|
|
|
|
|
+ "Content-Type": "application/json",
|
|
|
|
|
+ "anthropic-version": "2023-06-01",
|
|
|
|
|
+ "x-api-key": apiKey,
|
|
|
|
|
+ }),
|
|
|
|
|
+ body: (model) => ({
|
|
|
|
|
+ model,
|
|
|
|
|
+ max_tokens: 100,
|
|
|
|
|
+ messages: [{ role: "user", content: "Hello" }],
|
|
|
|
|
+ }),
|
|
|
|
|
+ successMessage: "Anthropic Messages API 测试成功",
|
|
|
|
|
+ extract: (result) => ({
|
|
|
|
|
+ model: result?.model,
|
|
|
|
|
+ usage: result?.usage,
|
|
|
|
|
+ content: extractFirstTextSnippet(result?.content),
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 测试 OpenAI Chat Completions API 连通性
|
|
|
|
|
+ */
|
|
|
|
|
+export async function testProviderOpenAIChatCompletions(
|
|
|
|
|
+ data: ProviderApiTestArgs
|
|
|
|
|
+): Promise<ProviderApiTestResult> {
|
|
|
|
|
+ return executeProviderApiTest(data, {
|
|
|
|
|
+ path: "/v1/chat/completions",
|
|
|
|
|
+ defaultModel: "gpt-4.1",
|
|
|
|
|
+ headers: (apiKey) => ({
|
|
|
|
|
+ "Content-Type": "application/json",
|
|
|
|
|
+ Authorization: `Bearer ${apiKey}`,
|
|
|
|
|
+ }),
|
|
|
|
|
+ body: (model) => ({
|
|
|
|
|
+ model,
|
|
|
|
|
+ messages: [
|
|
|
|
|
+ { role: "developer", content: "你是一个有帮助的助手。" },
|
|
|
|
|
+ { role: "user", content: "你好" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ }),
|
|
|
|
|
+ successMessage: "OpenAI Chat Completions API 测试成功",
|
|
|
|
|
+ extract: (result) => ({
|
|
|
|
|
+ model: result?.model,
|
|
|
|
|
+ usage: result?.usage,
|
|
|
|
|
+ content: clipText(result?.choices?.[0]?.message?.content),
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 测试 OpenAI Responses API 连通性
|
|
|
|
|
+ */
|
|
|
|
|
+export async function testProviderOpenAIResponses(
|
|
|
|
|
+ data: ProviderApiTestArgs
|
|
|
|
|
+): Promise<ProviderApiTestResult> {
|
|
|
|
|
+ return executeProviderApiTest(data, {
|
|
|
|
|
+ path: "/v1/responses",
|
|
|
|
|
+ defaultModel: "gpt-4.1",
|
|
|
|
|
+ headers: (apiKey) => ({
|
|
|
|
|
+ "Content-Type": "application/json",
|
|
|
|
|
+ Authorization: `Bearer ${apiKey}`,
|
|
|
|
|
+ }),
|
|
|
|
|
+ body: (model) => ({
|
|
|
|
|
+ model,
|
|
|
|
|
+ input: "讲一个简短的故事",
|
|
|
|
|
+ }),
|
|
|
|
|
+ successMessage: "OpenAI Responses API 测试成功",
|
|
|
|
|
+ extract: (result) => {
|
|
|
|
|
+ let content: string | undefined;
|
|
|
|
|
+ if (result?.output && Array.isArray(result.output)) {
|
|
|
|
|
+ const firstOutput = result.output[0];
|
|
|
|
|
+ if (firstOutput?.type === "message" && Array.isArray(firstOutput.content)) {
|
|
|
|
|
+ const textContent = firstOutput.content.find(
|
|
|
|
|
+ (c: { type?: string }) => c?.type === "output_text"
|
|
|
|
|
+ ) as { text?: unknown } | undefined;
|
|
|
|
|
+ content = clipText(textContent?.text);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ model: result?.model,
|
|
|
|
|
+ usage: result?.usage,
|
|
|
|
|
+ content,
|
|
|
|
|
+ };
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+}
|