diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..dc4c9dc0f4dcb97dc869b78bda656ec2285fbe28 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.env* +npm-debug.log* +pnpm-debug.log* +.pnpm-store +README.md +.gitignore +.dockerignore +Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/.env b/.env deleted file mode 100644 index 13e833f99853bf5666d2bf6ea91727790d490114..0000000000000000000000000000000000000000 --- a/.env +++ /dev/null @@ -1,9 +0,0 @@ -psql -h db.ttzfpnlvvfthtatadsrr.supabase.co -p 5432 -d postgres -U postgres - -# POSTGRES_HOST= -# POSTGRES_PORT= -# POSTGRES_USER= -# POSTGRES_PASSWORD= udh4Lo8wXKzriqzC -# POSTGRES_DATABASE= - -NZRn4OW6azoR1YOewock6BZfXY7rRgq6F4SngcM9RicjPJ3xaX diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..a560d8db09eb89ba2f47f9b99358d4ca92757ed8 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# OpenWebUI Configuration +OPENWEBUI_DOMAIN=your_openwebui_domain # OpenWebUI domain, e.g. https://chat.example.com +OPENWEBUI_API_KEY=your_api_key # OpenWebUI API key for fetching model list + +# Access Control +ACCESS_TOKEN=your-access-token-here # Used for Monitor page login +API_KEY=your-api-key-here # Used for authentication when sending requests to Monitor + +# Price Configuration (Optional, $/million tokens) +# DEFAULT_MODEL_INPUT_PRICE=60 # Default input price for models +# DEFAULT_MODEL_OUTPUT_PRICE=60 # Default output price for models +# DEFAULT_MODEL_PER_MSG_PRICE=-1 # Default price per message for models, -1 means charging by tokens +# INIT_BALANCE=0 # Initial balance for users, optional +# COST_ON_INLET=0 # Pre-deduction amount on inlet, can be a fixed number (e.g. 0.1) or model-specific (e.g. gpt-4:0.32,gpt-3.5:0.01) + +# PostgreSQL Database Configuration (Optional, configure these if using external database) +# POSTGRES_HOST= +# POSTGRES_PORT= +# POSTGRES_USER= +# POSTGRES_PASSWORD= +# POSTGRES_DATABASE= \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..37224185490e6db2d26a574d66d4d476336bf644 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a6344aac8c09253b3b630fb776ae94478aa0275b..0000000000000000000000000000000000000000 --- a/.gitattributes +++ /dev/null @@ -1,35 +0,0 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..9cd9dece455edd14d69b6cbe957ea6560c0acafe --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env*.local diff --git a/Dockerfile b/Dockerfile index a54578af25dc56370a79b9305a32673ac7c24847..b7ca4473096c345134d0a8e31d965d4ac91e3a0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1 +1,39 @@ -FROM ghcr.io/variantconst/openwebui-monitor:latest \ No newline at end of file +# 使用 Node.js 官方镜像作为基础镜像 +FROM node:18-alpine + +# 设置工作目录 +WORKDIR /app + +# 安装必要的系统依赖 +RUN apk add --no-cache \ + python3 \ + make \ + g++ \ + gcc \ + libc-dev \ + netcat-openbsd \ + postgresql-client + +# 全局安装 pnpm +RUN npm install -g pnpm + +# 复制 package.json 和 pnpm-lock.yaml +COPY package.json pnpm-lock.yaml ./ + +# 安装依赖 +RUN pnpm install --no-frozen-lockfile + +# 复制项目文件 +COPY . . + +# 添加执行权限到启动脚本 +RUN chmod +x start.sh + +# 构建应用 +RUN pnpm build + +# 暴露端口 +EXPOSE 3000 + +# 使用启动脚本 +CMD ["./start.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..2c776a957bd858d6834709473b75acb8023f0ce3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 VariantConst + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/api/config/key/route.ts b/app/api/config/key/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba9684c6423bf1dba735b86bff0fc5a318c822ba --- /dev/null +++ b/app/api/config/key/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +export async function GET() { + const apiKey = process.env.API_KEY; + + if (!apiKey) { + return NextResponse.json( + { error: "API Key Not Configured" }, + { status: 500 } + ); + } + + return NextResponse.json({ apiKey }); +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..abd1f80c3476b4d2b758130b8d65a45e570e2285 --- /dev/null +++ b/app/api/config/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; + +export async function GET() { + const headersList = headers(); + const token = headersList.get("authorization")?.split(" ")[1]; + const expectedToken = process.env.ACCESS_TOKEN; + + if (!token || token !== expectedToken) { + return NextResponse.json( + { + apiKey: "Unauthorized", + status: 401, + }, + { status: 401 } + ); + } + + return NextResponse.json({ + apiKey: process.env.API_KEY || "Unconfigured", + status: 200, + }); +} diff --git a/app/api/users/[id]/balance/route.ts b/app/api/users/[id]/balance/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..da2fe1b4e78e4a8a5ed2c5e2ad4ab02edea83de8 --- /dev/null +++ b/app/api/users/[id]/balance/route.ts @@ -0,0 +1,42 @@ +import { query } from "@/lib/db/client"; +import { NextResponse } from "next/server"; + +export async function PUT( + req: Request, + { params }: { params: { id: string } } +) { + try { + const { balance } = await req.json(); + const userId = params.id; + + if (typeof balance !== "number") { + return NextResponse.json( + { error: "Balance must be positive" }, + { status: 400 } + ); + } + + const result = await query( + `UPDATE users + SET balance = $1 + WHERE id = $2 + RETURNING id, email, balance`, + [balance, userId] + ); + + if (result.rows.length === 0) { + return NextResponse.json( + { error: "User does not exist" }, + { status: 404 } + ); + } + + return NextResponse.json(result.rows[0]); + } catch (error) { + console.error("Fail to update user balance:", error); + return NextResponse.json( + { error: "Fail to update user balance" }, + { status: 500 } + ); + } +} diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4697dc5db1986b516b2d52892943a693b4218f4 --- /dev/null +++ b/app/api/users/[id]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { deleteUser } from "@/lib/db/users"; +import { query } from "@/lib/db/client"; + +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + await deleteUser(params.id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Fail to delete user:", error); + return NextResponse.json({ error: "Fail to delete user" }, { status: 500 }); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { deleted } = await req.json(); + + const result = await query( + `UPDATE users + SET deleted = $1 + WHERE id = $2 + RETURNING *`, + [deleted, params.id] + ); + + if (result.rowCount === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ + success: true, + user: result.rows[0], + }); + } catch (error) { + console.error("Failed to update user:", error); + return NextResponse.json( + { error: "Failed to update user" }, + { status: 500 } + ); + } +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6a30cf69e265534bae26a8c67477dea0e3d3d54 --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { query } from "@/lib/db/client"; +import { ensureUserTableExists } from "@/lib/db/users"; + +export async function GET(req: NextRequest) { + try { + // 确保表结构正确 + await ensureUserTableExists(); + + const { searchParams } = new URL(req.url); + const page = parseInt(searchParams.get("page") || "1"); + const pageSize = parseInt(searchParams.get("pageSize") || "20"); + const sortField = searchParams.get("sortField"); + const sortOrder = searchParams.get("sortOrder"); + const search = searchParams.get("search"); + const deleted = searchParams.get("deleted") === "true"; + + // 构建查询条件 + const conditions = [`deleted = ${deleted}`]; + const params = []; + let paramIndex = 1; + + if (search) { + conditions.push( + `(LOWER(name) LIKE $${paramIndex} OR LOWER(email) LIKE $${paramIndex})` + ); + params.push(`%${search.toLowerCase()}%`); + paramIndex++; + } + + const whereClause = `WHERE ${conditions.join(" AND ")}`; + + // 获取总记录数 + const countResult = await query( + `SELECT COUNT(*) FROM users ${whereClause}`, + params + ); + const total = parseInt(countResult.rows[0].count); + + // 获取分页数据 + const result = await query( + `SELECT id, email, name, role, balance, deleted, created_at + FROM users + ${whereClause} + ${ + sortField + ? `ORDER BY ${sortField} ${sortOrder === "descend" ? "DESC" : "ASC"}` + : "ORDER BY created_at DESC" + } + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...params, pageSize, (page - 1) * pageSize] + ); + + return NextResponse.json({ + users: result.rows, + total, + page, + pageSize, + }); + } catch (error) { + console.error("Failed to fetch users:", error); + return NextResponse.json( + { error: "Failed to fetch users" }, + { status: 500 } + ); + } +} diff --git a/app/api/v1/inlet/route.ts b/app/api/v1/inlet/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..9352ea28f726e0451c2a1cef680a4bd004527c8b --- /dev/null +++ b/app/api/v1/inlet/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { getOrCreateUser } from "@/lib/db/users"; +import { query } from "@/lib/db/client"; +import { getModelInletCost } from "@/lib/utils/inlet-cost"; + +export async function POST(req: Request) { + try { + const data = await req.json(); + const user = await getOrCreateUser(data.user); + const modelId = data.body?.model; + + // 如果用户被拉黑,返回余额为 -1 + if (user.deleted) { + return NextResponse.json({ + success: true, + balance: -1, + message: "Request successful", + }); + } + + // 获取预扣费金额 + const inletCost = getModelInletCost(modelId); + + // 预扣费 + if (inletCost > 0) { + const userResult = await query( + `UPDATE users + SET balance = LEAST( + balance - CAST($1 AS DECIMAL(16,4)), + 999999.9999 + ) + WHERE id = $2 AND NOT deleted + RETURNING balance`, + [inletCost, user.id] + ); + + if (userResult.rows.length === 0) { + throw new Error("Failed to update user balance"); + } + + return NextResponse.json({ + success: true, + balance: Number(userResult.rows[0].balance), + inlet_cost: inletCost, + message: "Request successful", + }); + } + + return NextResponse.json({ + success: true, + balance: Number(user.balance), + message: "Request successful", + }); + } catch (error) { + console.error("Inlet error:", error); + return NextResponse.json( + { + success: false, + error: + error instanceof Error ? error.message : "Error dealing with request", + error_type: error instanceof Error ? error.name : "UNKNOWN_ERROR", + }, + { status: 500 } + ); + } +} diff --git a/app/api/v1/models/price/route.ts b/app/api/v1/models/price/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7056b258fb36a58410c485a5cad64877c4f39a5 --- /dev/null +++ b/app/api/v1/models/price/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { updateModelPrice } from "@/lib/db"; + +interface PriceUpdate { + id: string; + input_price: number; + output_price: number; + per_msg_price: number; +} + +export async function POST(request: NextRequest) { + try { + const data = await request.json(); + console.log("Raw data received:", data); + + // 从对象中提取模型数组 + const updates = data.updates || data; + if (!Array.isArray(updates)) { + console.error("Invalid data format - expected array:", updates); + return NextResponse.json( + { error: "Invalid data format" }, + { status: 400 } + ); + } + + // 验证并转换数据格式 + const validUpdates = updates + .map((update: any) => ({ + id: update.id, + input_price: Number(update.input_price), + output_price: Number(update.output_price), + per_msg_price: Number(update.per_msg_price ?? -1), + })) + .filter((update: PriceUpdate) => { + const isValidPrice = (price: number) => + !isNaN(price) && isFinite(price); + + if ( + !update.id || + !isValidPrice(update.input_price) || + !isValidPrice(update.output_price) || + !isValidPrice(update.per_msg_price) + ) { + console.log("Skipping invalid data:", update); + return false; + } + return true; + }); + + console.log("Update data after processing:", validUpdates); + console.log( + `Successfully verified price updating requests of ${validUpdates.length} models` + ); + + // 执行批量更新并收集结果 + const results = await Promise.all( + validUpdates.map(async (update: PriceUpdate) => { + try { + console.log("Updating model prices:", { + id: update.id, + input_price: update.input_price, + output_price: update.output_price, + per_msg_price: update.per_msg_price, + }); + + const result = await updateModelPrice( + update.id, + update.input_price, + update.output_price, + update.per_msg_price + ); + + console.log("Update results:", { + id: update.id, + success: !!result, + result, + }); + + return { + id: update.id, + success: !!result, + data: result, + }; + } catch (error) { + console.error("Fail to update:", { + id: update.id, + error: error instanceof Error ? error.message : "Unknown error", + }); + return { + id: update.id, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }) + ); + + const successCount = results.filter((r) => r.success).length; + console.log(`Successfully updated prices of ${successCount} models`); + + return NextResponse.json({ + success: true, + message: `Successfully updated prices of ${successCount} models`, + results, + }); + } catch (error) { + console.error("Batch update failed:", error); + return NextResponse.json({ error: "Batch update failed" }, { status: 500 }); + } +} + +export async function OPTIONS() { + return NextResponse.json({}, { status: 200 }); +} diff --git a/app/api/v1/models/route.ts b/app/api/v1/models/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfd87bf10cb5f1cd6230f4838ea5a01a636604c7 --- /dev/null +++ b/app/api/v1/models/route.ts @@ -0,0 +1,146 @@ +import { NextResponse } from "next/server"; +import { ensureTablesExist, getOrCreateModelPrices } from "@/lib/db"; + +interface ModelInfo { + id: string; + base_model_id: string; + name: string; + params: { + system: string; + }; + meta: { + profile_image_url: string; + }; +} + +interface ModelResponse { + data: { + id: string; + name: string; + info: ModelInfo; + }[]; +} + +export async function GET() { + try { + // Ensure database is initialized + await ensureTablesExist(); + + const domain = process.env.OPENWEBUI_DOMAIN; + if (!domain) { + throw new Error("OPENWEBUI_DOMAIN environment variable is not set."); + } + + // Normalize API URL + const apiUrl = domain.replace(/\/+$/, "") + "/api/models"; + + const response = await fetch(apiUrl, { + headers: { + Authorization: `Bearer ${process.env.OPENWEBUI_API_KEY}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + console.error("API response status:", response.status); + console.error("API response text:", await response.text()); + throw new Error(`Failed to fetch models: ${response.status}`); + } + + // Get response text for debugging + const responseText = await response.text(); + // console.log("API response:", responseText); + + let data: ModelResponse; + try { + data = JSON.parse(responseText); + } catch (error) { + console.error("Failed to parse JSON:", error); + throw new Error("Invalid JSON response from API"); + } + + console.log("data:", data); + + if (!data || !Array.isArray(data.data)) { + console.error("Unexpected API response structure:", data); + throw new Error("Unexpected API response structure"); + } + + // Get price information for all models + const modelsWithPrices = await getOrCreateModelPrices( + data.data.map((item) => { + // 处理形如 gemini_search.gemini-2.0-flash 的派生模型ID + let baseModelId = item.info?.base_model_id; + + // 如果没有明确的base_model_id,尝试从ID中提取 + if (!baseModelId && item.id) { + const idParts = String(item.id).split("."); + if (idParts.length > 1) { + baseModelId = idParts[idParts.length - 1]; + } + } + + return { + id: String(item.id), + name: String(item.name), + base_model_id: baseModelId, + }; + }) + ); + + const validModels = data.data.map((item, index) => { + // 处理形如 gemini_search.gemini-2.0-flash 的派生模型ID + let baseModelId = item.info?.base_model_id || ""; + + // 如果没有明确的base_model_id,尝试从ID中提取 + if (!baseModelId && item.id) { + const idParts = String(item.id).split("."); + if (idParts.length > 1) { + baseModelId = idParts[idParts.length - 1]; + } + } + + return { + id: modelsWithPrices[index].id, + base_model_id: baseModelId, + name: modelsWithPrices[index].name, + imageUrl: item.info?.meta?.profile_image_url || "/static/favicon.png", + system_prompt: item.info?.params?.system || "", + input_price: modelsWithPrices[index].input_price, + output_price: modelsWithPrices[index].output_price, + per_msg_price: modelsWithPrices[index].per_msg_price, + updated_at: modelsWithPrices[index].updated_at, + }; + }); + + return NextResponse.json(validModels); + } catch (error) { + console.error("Error fetching models:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to fetch models", + }, + { status: 500 } + ); + } +} + +// Add inlet endpoint +export async function POST(req: Request) { + const data = await req.json(); + + return new Response("Inlet placeholder response", { + headers: { "Content-Type": "application/json" }, + }); +} + +// Add outlet endpoint +export async function PUT(req: Request) { + const data = await req.json(); + // console.log("Outlet received:", JSON.stringify(data, null, 2)); + + return new Response("Outlet placeholder response", { + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/app/api/v1/models/sync-all-prices/route.ts b/app/api/v1/models/sync-all-prices/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bf9d4abdd70d427de516477f7e090cfc3548194 --- /dev/null +++ b/app/api/v1/models/sync-all-prices/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; + +export async function POST(request: NextRequest) { + try { + const client = await pool.connect(); + try { + // 1. 获取所有有效的派生模型(base_model_id 存在且在数据库中有对应记录) + const derivedModelsResult = await client.query(` + SELECT d.id, d.name, d.base_model_id + FROM model_prices d + JOIN model_prices b ON d.base_model_id = b.id + WHERE d.base_model_id IS NOT NULL + `); + + if (derivedModelsResult.rows.length === 0) { + return NextResponse.json({ + success: true, + message: "No derived models found", + syncedModels: [], + }); + } + + const derivedModels = derivedModelsResult.rows; + const syncResults = []; + + // 2. 为每个派生模型同步价格 + for (const derivedModel of derivedModels) { + try { + // 获取上游模型价格 + const baseModelResult = await client.query( + `SELECT input_price, output_price, per_msg_price FROM model_prices WHERE id = $1`, + [derivedModel.base_model_id] + ); + + if (baseModelResult.rows.length === 0) { + syncResults.push({ + id: derivedModel.id, + name: derivedModel.name, + success: false, + error: "Base model not found", + }); + continue; + } + + const baseModel = baseModelResult.rows[0]; + + // 更新派生模型价格 + const updateResult = await client.query( + `UPDATE model_prices + SET + input_price = $2, + output_price = $3, + per_msg_price = $4, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING *`, + [ + derivedModel.id, + baseModel.input_price, + baseModel.output_price, + baseModel.per_msg_price, + ] + ); + + const updatedModel = updateResult.rows[0]; + + syncResults.push({ + id: updatedModel.id, + name: updatedModel.name, + base_model_id: derivedModel.base_model_id, + success: true, + input_price: Number(updatedModel.input_price), + output_price: Number(updatedModel.output_price), + per_msg_price: Number(updatedModel.per_msg_price), + }); + } catch (error) { + console.error(`Error syncing model ${derivedModel.id}:`, error); + syncResults.push({ + id: derivedModel.id, + name: derivedModel.name, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + const successCount = syncResults.filter((r) => r.success).length; + + return NextResponse.json({ + success: true, + message: `Successfully synced ${successCount} of ${derivedModels.length} derived models`, + syncedModels: syncResults, + }); + } finally { + client.release(); + } + } catch (error) { + console.error("Sync all prices failed:", error); + return NextResponse.json( + { + error: "Sync all prices failed", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} + +export async function OPTIONS() { + return NextResponse.json({}, { status: 200 }); +} diff --git a/app/api/v1/models/sync-price/route.ts b/app/api/v1/models/sync-price/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..031456a76b4a7746a59df04679ed3a474e7757da --- /dev/null +++ b/app/api/v1/models/sync-price/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; + +export async function POST(request: NextRequest) { + try { + const data = await request.json(); + const { modelId } = data; + + if (!modelId) { + return NextResponse.json( + { error: "Model ID is required" }, + { status: 400 } + ); + } + + const client = await pool.connect(); + try { + // 1. 获取派生模型信息 + const derivedModelResult = await client.query( + `SELECT id, name, base_model_id FROM model_prices WHERE id = $1`, + [modelId] + ); + + if (derivedModelResult.rows.length === 0) { + return NextResponse.json({ error: "Model not found" }, { status: 404 }); + } + + const derivedModel = derivedModelResult.rows[0]; + let baseModelId = derivedModel.base_model_id; + + // 如果数据库中没有base_model_id,尝试从ID中提取 + if (!baseModelId) { + const idParts = modelId.split("."); + if (idParts.length > 1) { + baseModelId = idParts[idParts.length - 1]; + + // 更新数据库中的base_model_id + await client.query( + `UPDATE model_prices SET base_model_id = $2 WHERE id = $1`, + [modelId, baseModelId] + ); + } + } + + if (!baseModelId) { + return NextResponse.json( + { error: "Model does not have a base model" }, + { status: 400 } + ); + } + + // 2. 获取上游模型价格 + const baseModelResult = await client.query( + `SELECT input_price, output_price, per_msg_price FROM model_prices WHERE id = $1`, + [baseModelId] + ); + + if (baseModelResult.rows.length === 0) { + return NextResponse.json( + { error: "Base model not found" }, + { status: 404 } + ); + } + + const baseModel = baseModelResult.rows[0]; + + // 3. 更新派生模型价格 + const updateResult = await client.query( + `UPDATE model_prices + SET + input_price = $2, + output_price = $3, + per_msg_price = $4, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING *`, + [ + modelId, + baseModel.input_price, + baseModel.output_price, + baseModel.per_msg_price, + ] + ); + + const updatedModel = updateResult.rows[0]; + + return NextResponse.json({ + success: true, + message: `Successfully synced prices from ${baseModelId} to ${modelId}`, + data: { + id: updatedModel.id, + name: updatedModel.name, + base_model_id: baseModelId, + input_price: Number(updatedModel.input_price), + output_price: Number(updatedModel.output_price), + per_msg_price: Number(updatedModel.per_msg_price), + updated_at: updatedModel.updated_at, + }, + }); + } finally { + client.release(); + } + } catch (error) { + console.error("Sync price failed:", error); + return NextResponse.json( + { + error: "Sync price failed", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} + +export async function OPTIONS() { + return NextResponse.json({}, { status: 200 }); +} diff --git a/app/api/v1/models/test/route.ts b/app/api/v1/models/test/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a6e89a5f3f72917fd3587236b50894908f4207c --- /dev/null +++ b/app/api/v1/models/test/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + try { + const { modelId } = await req.json(); + + if (!modelId) { + return NextResponse.json({ + success: false, + message: "Model ID cannot be empty", + }); + } + + const domain = process.env.OPENWEBUI_DOMAIN; + const apiKey = process.env.OPENWEBUI_API_KEY; + + if (!domain || !apiKey) { + return NextResponse.json({ + success: false, + message: "Environment variables not configured correctly", + }); + } + + const apiUrl = domain.replace(/\/+$/, "") + "/api/chat/completions"; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: modelId, + messages: [ + { + role: "user", + content: "test, just say hi", + }, + ], + }), + }); + + const responseText = await response.text(); + let data; + + try { + data = JSON.parse(responseText); + } catch (e) { + return NextResponse.json({ + success: false, + message: `Fail to resolve response: ${responseText}`, + }); + } + + if (!response.ok) { + return NextResponse.json({ + success: false, + message: + data.error || + `API request failed: ${response.status} ${response.statusText}`, + }); + } + + if (!data.choices?.[0]?.message?.content) { + return NextResponse.json({ + success: false, + message: "Invalid response format", + }); + } + + return NextResponse.json({ + success: true, + message: "Test successful", + response: data.choices[0].message.content, + }); + } catch (error) { + return NextResponse.json({ + success: false, + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/app/api/v1/outlet/route.ts b/app/api/v1/outlet/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..626f25395f6418b61d9a8fcb0dfebe0462713042 --- /dev/null +++ b/app/api/v1/outlet/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from "next/server"; +import { encode } from "gpt-tokenizer/model/gpt-4"; +import { Pool, PoolClient } from "pg"; +import { createClient } from "@vercel/postgres"; +import { query, getClient } from "@/lib/db/client"; +import { getModelInletCost } from "@/lib/utils/inlet-cost"; + +const isVercel = process.env.VERCEL === "1"; + +interface Message { + role: string; + content: string; +} + +interface ModelPrice { + id: string; + name: string; + input_price: number; + output_price: number; + per_msg_price: number; +} + +type DbClient = ReturnType | Pool | PoolClient; + +async function getModelPrice(modelId: string): Promise { + const result = await query( + `SELECT id, name, input_price, output_price, per_msg_price + FROM model_prices + WHERE id = $1`, + [modelId] + ); + + if (result.rows[0]) { + return result.rows[0]; + } + + // If no price is found in the database, use the default price + const defaultInputPrice = parseFloat( + process.env.DEFAULT_MODEL_INPUT_PRICE || "60" + ); + const defaultOutputPrice = parseFloat( + process.env.DEFAULT_MODEL_OUTPUT_PRICE || "60" + ); + + // Verify that the default price is a valid non-negative number + if ( + isNaN(defaultInputPrice) || + defaultInputPrice < 0 || + isNaN(defaultOutputPrice) || + defaultOutputPrice < 0 + ) { + return null; + } + + return { + id: modelId, + name: modelId, + input_price: defaultInputPrice, + output_price: defaultOutputPrice, + per_msg_price: -1, // Default to token-based pricing + }; +} + +export async function POST(req: Request) { + const client = (await getClient()) as DbClient; + let pgClient: DbClient | null = null; + + try { + // Get a dedicated transaction client + if (isVercel) { + pgClient = client; + } else { + pgClient = await (client as Pool).connect(); + } + + const data = await req.json(); + console.log("请求数据:", JSON.stringify(data, null, 2)); + const modelId = data.body.model; + const userId = data.user.id; + const userName = data.user.name || "Unknown User"; + + // Start a transaction + await query("BEGIN"); + + // Get model price + const modelPrice = await getModelPrice(modelId); + if (!modelPrice) { + throw new Error(`Fail to fetch price info of model ${modelId}`); + } + + // Calculate tokens + const lastMessage = data.body.messages[data.body.messages.length - 1]; + + let inputTokens: number; + let outputTokens: number; + if ( + lastMessage.usage && + lastMessage.usage.prompt_tokens && + lastMessage.usage.completion_tokens + ) { + inputTokens = lastMessage.usage.prompt_tokens; + outputTokens = lastMessage.usage.completion_tokens; + } else { + outputTokens = encode(lastMessage.content).length; + const totalTokens = data.body.messages.reduce( + (sum: number, msg: Message) => sum + encode(msg.content).length, + 0 + ); + inputTokens = totalTokens - outputTokens; + } + + // Calculate total cost + let totalCost: number; + if (outputTokens === 0) { + // If output tokens are 0, no charge + totalCost = 0; + console.log("No charge for zero output tokens"); + } else if (modelPrice.per_msg_price >= 0) { + // If fixed pricing is set, use it directly + totalCost = Number(modelPrice.per_msg_price); + console.log( + `Using fixed pricing: ${totalCost} (${modelPrice.per_msg_price} per message)` + ); + } else { + // Otherwise, calculate price by token quantity + const inputCost = (inputTokens / 1_000_000) * modelPrice.input_price; + const outputCost = (outputTokens / 1_000_000) * modelPrice.output_price; + totalCost = inputCost + outputCost; + } + + // Get the pre-deducted cost when getting inlet + const inletCost = getModelInletCost(modelId); + + // The actual cost to be deducted = total cost - pre-deducted cost + const actualCost = totalCost - inletCost; + + // Get and update user balance + const userResult = await query( + `UPDATE users + SET balance = LEAST( + balance - CAST($1 AS DECIMAL(16,4)), + 999999.9999 + ) + WHERE id = $2 + RETURNING balance`, + [actualCost, userId] + ); + + if (userResult.rows.length === 0) { + throw new Error("User does not exist"); + } + + const newBalance = Number(userResult.rows[0].balance); + + if (newBalance > 999999.9999) { + throw new Error("Balance exceeds maximum allowed value"); + } + + // Record usage + await query( + `INSERT INTO user_usage_records ( + user_id, nickname, model_name, + input_tokens, output_tokens, + cost, balance_after + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + userId, + userName, + modelId, + inputTokens, + outputTokens, + totalCost, + newBalance, + ] + ); + + await query("COMMIT"); + + console.log( + JSON.stringify({ + success: true, + inputTokens, + outputTokens, + totalCost, + newBalance, + message: "Request successful", + }) + ); + + return NextResponse.json({ + success: true, + inputTokens, + outputTokens, + totalCost, + newBalance, + message: "Request successful", + }); + } catch (error) { + await query("ROLLBACK"); + console.error("Outlet error:", error); + return NextResponse.json( + { + success: false, + error: + error instanceof Error ? error.message : "Error processing request", + error_type: error instanceof Error ? error.name : "UNKNOWN_ERROR", + }, + { status: 500 } + ); + } finally { + // Only release connection in non-Vercel environment + if (!isVercel && pgClient && "release" in pgClient) { + pgClient.release(); + } + } +} diff --git a/app/api/v1/panel/database/export/route.ts b/app/api/v1/panel/database/export/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8d758fd6bd4e08e05980139f48f2021e348422a --- /dev/null +++ b/app/api/v1/panel/database/export/route.ts @@ -0,0 +1,56 @@ +import { pool } from "@/lib/db"; +import { NextResponse } from "next/server"; +import { PoolClient } from "pg"; + +export async function GET() { + let client: PoolClient | null = null; + + try { + // 获取数据库连接 + client = await pool.connect(); + + // 获取所有表的数据 + const users = await client.query("SELECT * FROM users ORDER BY id"); + const modelPrices = await client.query( + "SELECT * FROM model_prices ORDER BY id" + ); + const records = await client.query( + "SELECT * FROM user_usage_records ORDER BY id" + ); + + // 构建导出数据结构 + const exportData = { + version: "1.0", + timestamp: new Date().toISOString(), + data: { + users: users.rows, + model_prices: modelPrices.rows, + user_usage_records: records.rows, + }, + }; + + // 设置响应头 + const headers = new Headers(); + headers.set("Content-Type", "application/json"); + headers.set( + "Content-Disposition", + `attachment; filename=openwebui_monitor_backup_${ + new Date().toISOString().split("T")[0] + }.json` + ); + + return new Response(JSON.stringify(exportData, null, 2), { + headers, + }); + } catch (error) { + console.error("Fail to export database:", error); + return NextResponse.json( + { error: "Fail to export database" }, + { status: 500 } + ); + } finally { + if (client) { + client.release(); + } + } +} diff --git a/app/api/v1/panel/database/import/route.ts b/app/api/v1/panel/database/import/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f08f6a8eb0b1479b2e8e9b8bbc3040f071e40d4 --- /dev/null +++ b/app/api/v1/panel/database/import/route.ts @@ -0,0 +1,97 @@ +import { pool } from "@/lib/db"; +import { NextResponse } from "next/server"; +import { PoolClient } from "pg"; + +export async function POST(req: Request) { + let client: PoolClient | null = null; + + try { + const data = await req.json(); + + // 验证数据格式 + if (!data.version || !data.data) { + throw new Error("Invalid import data format"); + } + + // 获取数据库连接 + client = await pool.connect(); + + // 开启事务 + await client.query("BEGIN"); + + try { + // 清空现有数据 + await client.query("TRUNCATE TABLE user_usage_records CASCADE"); + await client.query("TRUNCATE TABLE model_prices CASCADE"); + await client.query("TRUNCATE TABLE users CASCADE"); + + // 导入用户数据 + if (data.data.users?.length) { + for (const user of data.data.users) { + await client.query( + `INSERT INTO users (id, email, name, role, balance) + VALUES ($1, $2, $3, $4, $5)`, + [user.id, user.email, user.name, user.role, user.balance] + ); + } + } + + // 导入模型价格 + if (data.data.model_prices?.length) { + for (const price of data.data.model_prices) { + await client.query( + `INSERT INTO model_prices (id, name, input_price, output_price) + VALUES ($1, $2, $3, $4)`, + [price.id, price.name, price.input_price, price.output_price] + ); + } + } + + // 导入使用记录 + if (data.data.user_usage_records?.length) { + for (const record of data.data.user_usage_records) { + await client.query( + `INSERT INTO user_usage_records ( + user_id, nickname, use_time, model_name, + input_tokens, output_tokens, cost, balance_after + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + record.user_id, + record.nickname, + record.use_time, + record.model_name, + record.input_tokens, + record.output_tokens, + record.cost, + record.balance_after, + ] + ); + } + } + + await client.query("COMMIT"); + + return NextResponse.json({ + success: true, + message: "Data import successful", + }); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + } catch (error) { + console.error("Fail to import database:", error); + return NextResponse.json( + { + success: false, + error: + error instanceof Error ? error.message : "Fail to import database", + }, + { status: 500 } + ); + } finally { + if (client) { + client.release(); + } + } +} diff --git a/app/api/v1/panel/records/export/route.ts b/app/api/v1/panel/records/export/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..622608b129ff3a316d178870d5fa58b3ac7a8cff --- /dev/null +++ b/app/api/v1/panel/records/export/route.ts @@ -0,0 +1,70 @@ +import { pool } from "@/lib/db"; +import { NextResponse } from "next/server"; +import { PoolClient } from "pg"; + +export async function GET() { + let client: PoolClient | null = null; + try { + client = await pool.connect(); + + const records = await client.query(` + SELECT + nickname, + use_time, + model_name, + input_tokens, + output_tokens, + cost, + balance_after + FROM user_usage_records + ORDER BY use_time DESC + `); + + // 生成 CSV 内容 + const csvHeaders = [ + "User", + "Time", + "Model", + "Input tokens", + "Output tokens", + "Cost", + "Balance", + ]; + const rows = records.rows.map((record) => [ + record.nickname, + new Date(record.use_time).toLocaleString(), + record.model_name, + record.input_tokens, + record.output_tokens, + Number(record.cost).toFixed(4), + Number(record.balance_after).toFixed(4), + ]); + + const csvContent = [ + csvHeaders.join(","), + ...rows.map((row) => row.join(",")), + ].join("\n"); + + // 设置响应头 + const responseHeaders = new Headers(); + responseHeaders.set("Content-Type", "text/csv; charset=utf-8"); + responseHeaders.set( + "Content-Disposition", + "attachment; filename=usage_records.csv" + ); + + return new Response(csvContent, { + headers: responseHeaders, + }); + } catch (error) { + console.error("Fail to export records:", error); + return NextResponse.json( + { error: "Fail to export records" }, + { status: 500 } + ); + } finally { + if (client) { + client.release(); + } + } +} diff --git a/app/api/v1/panel/records/route.ts b/app/api/v1/panel/records/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e931d49412be7aaf83694c3c11f55c9a05c5ea54 --- /dev/null +++ b/app/api/v1/panel/records/route.ts @@ -0,0 +1,89 @@ +import { pool } from "@/lib/db"; +import { NextResponse } from "next/server"; +import { PoolClient } from "pg"; + +export async function GET(req: Request) { + let client: PoolClient | null = null; + try { + const { searchParams } = new URL(req.url); + const page = parseInt(searchParams.get("page") || "1"); + const pageSize = parseInt(searchParams.get("pageSize") || "10"); + const sortField = searchParams.get("sortField"); + const sortOrder = searchParams.get("sortOrder"); + const users = searchParams.get("users")?.split(",") || []; + const models = searchParams.get("models")?.split(",") || []; + + client = await pool.connect(); + + // 构建查询条件 + const conditions = []; + const params = []; + let paramIndex = 1; + + if (users.length > 0) { + conditions.push(`nickname = ANY($${paramIndex})`); + params.push(users); + paramIndex++; + } + + if (models.length > 0) { + conditions.push(`model_name = ANY($${paramIndex})`); + params.push(models); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // 构建排序 + const orderClause = sortField + ? `ORDER BY ${sortField} ${sortOrder === "descend" ? "DESC" : "ASC"}` + : "ORDER BY use_time DESC"; + + // 获取总记录数 + const countQuery = ` + SELECT COUNT(*) + FROM user_usage_records + ${whereClause} + `; + const countResult = await client.query(countQuery, params); + + // 获取分页数据 + const offset = (page - 1) * pageSize; + const dataQuery = ` + SELECT + user_id, + nickname, + use_time, + model_name, + input_tokens, + output_tokens, + cost, + balance_after + FROM user_usage_records + ${whereClause} + ${orderClause} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const dataParams = [...params, pageSize, offset]; + const records = await client.query(dataQuery, dataParams); + + const total = parseInt(countResult.rows[0].count); + + return NextResponse.json({ + records: records.rows, + total, + }); + } catch (error) { + console.error("Fail to fetch usage records:", error); + return NextResponse.json( + { error: "Fail to fetch usage records" }, + { status: 500 } + ); + } finally { + if (client) { + client.release(); + } + } +} diff --git a/app/api/v1/panel/usage/route.ts b/app/api/v1/panel/usage/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a3b47533343c204c7cd3cbf5ac65067c44a51b8 --- /dev/null +++ b/app/api/v1/panel/usage/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { pool } from "@/lib/db"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const startTime = searchParams.get("startTime"); + const endTime = searchParams.get("endTime"); + + const timeFilter = + startTime && endTime ? `WHERE use_time >= $1 AND use_time <= $2` : ""; + + const params = startTime && endTime ? [startTime, endTime] : []; + + const [modelResult, userResult, timeRangeResult, statsResult] = + await Promise.all([ + pool.query( + ` + SELECT + model_name, + COUNT(*) as total_count, + COALESCE(SUM(cost), 0) as total_cost + FROM user_usage_records + ${timeFilter} + GROUP BY model_name + ORDER BY total_cost DESC + `, + params + ), + pool.query( + ` + SELECT + nickname, + COUNT(*) as total_count, + COALESCE(SUM(cost), 0) as total_cost + FROM user_usage_records + ${timeFilter} + GROUP BY nickname + ORDER BY total_cost DESC + `, + params + ), + pool.query(` + SELECT + MIN(use_time) as min_time, + MAX(use_time) as max_time + FROM user_usage_records + `), + pool.query( + ` + SELECT + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COUNT(*) as total_calls + FROM user_usage_records + ${timeFilter} + `, + params + ), + ]); + + const formattedData = { + models: modelResult.rows.map((row) => ({ + model_name: row.model_name, + total_count: parseInt(row.total_count), + total_cost: parseFloat(row.total_cost), + })), + users: userResult.rows.map((row) => ({ + nickname: row.nickname, + total_count: parseInt(row.total_count), + total_cost: parseFloat(row.total_cost), + })), + timeRange: { + minTime: timeRangeResult.rows[0].min_time, + maxTime: timeRangeResult.rows[0].max_time, + }, + stats: { + totalTokens: parseInt(statsResult.rows[0].total_tokens), + totalCalls: parseInt(statsResult.rows[0].total_calls), + }, + }; + + return NextResponse.json(formattedData); + } catch (error) { + console.error("Fail to fetch usage records:", error); + return NextResponse.json( + { error: "Fail to fetch usage records" }, + { status: 500 } + ); + } +} diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ed203ef5e88beab19b3dd07dfad56d8b9917fe66 Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/fonts/GeistMonoVF.woff b/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000000000000000000000000000000000000..f2ae185cbfd16946a534d819e9eb03924abbcc49 Binary files /dev/null and b/app/fonts/GeistMonoVF.woff differ diff --git a/app/fonts/GeistVF.woff b/app/fonts/GeistVF.woff new file mode 100644 index 0000000000000000000000000000000000000000..1b62daacff96dad6584e71cd962051b82957c313 Binary files /dev/null and b/app/fonts/GeistVF.woff differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..3e8008e4350414d4a7d61cd5d7c3633b62e4be47 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,278 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; +} + +@layer utilities { + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .animate-fade-in { + animation: fadeIn 0.6s ease-out forwards; + } + + .animate-fade-in-delay { + animation: fadeIn 0.6s ease-out 0.2s forwards; + opacity: 0; + } + + @keyframes slide-up { + from { + opacity: 0; + transform: translateY(1rem); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .animate-slide-up { + animation: slide-up 0.3s ease-out; + } +} + +.custom-modal .ant-modal-content { + padding: 24px; + border-radius: 16px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08); +} + +.custom-modal .ant-modal-header { + margin-bottom: 0; + padding: 0; +} + +.custom-modal .ant-modal-body { + padding: 0; +} + +.custom-modal .ant-modal-close { + top: 20px; + right: 20px; +} + +.custom-modal .ant-btn { + height: 40px; + border-radius: 8px; +} + +.custom-tooltip-simple .ant-tooltip-inner { + padding: 0; + border-radius: 8px; + min-width: 120px; + border: 1px solid rgba(0, 0, 0, 0.04); +} + +.custom-tooltip-simple .ant-tooltip-arrow { + display: none; +} + +/* 更新模态框样式 */ +.update-modal .ant-modal-content { + padding: 24px; + border-radius: 16px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08); +} + +.update-modal .ant-modal-footer { + margin-top: 24px; + border-top: none; + padding: 0; +} + +.update-modal .ant-modal-footer .ant-btn { + height: 38px; + padding: 0 24px; + border-radius: 8px; + font-weight: 500; +} + +.update-modal .ant-modal-footer .ant-btn-primary { + background: rgb(59 130 246); + border-color: rgb(59 130 246); + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1); +} + +.update-modal .ant-modal-footer .ant-btn-primary:hover { + background: rgb(37 99 235); + border-color: rgb(37 99 235); +} + +.update-modal .ant-modal-footer .ant-btn-default { + border-color: #e5e7eb; + color: #6b7280; +} + +.update-modal .ant-modal-footer .ant-btn-default:hover { + border-color: #d1d5db; + color: #4b5563; + background: #f9fafb; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + border-color: hsl(var(--border)); + } + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + } +} + +.bg-dot-pattern { + background-image: radial-gradient( + circle at center, + hsl(var(--foreground) / 0.1) 1px, + transparent 1px + ); + background-size: 24px 24px; +} + +.bg-dot-pattern-dark { + background-image: radial-gradient( + circle at center, + hsl(var(--foreground) / 0.2) 1px, + transparent 1px + ); + background-size: 24px 24px; +} + +/* 添加以下样式 */ +@media (max-width: 640px) { + .toaster-group { + --viewport-padding: 16px; + right: var(--viewport-padding); + left: var(--viewport-padding); + width: calc(100% - var(--viewport-padding) * 2); + } + + .toast { + --viewport-padding: 16px; + width: 100%; + border-radius: 12px; + margin-left: 0; + margin-right: 0; + } +} + +/* 自定义日期选择器样式 */ +.custom-date-picker { + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); +} + +.custom-date-picker .ant-picker-panel { + background-color: hsl(var(--background)); +} + +.custom-date-picker + .ant-picker-cell-in-view.ant-picker-cell-selected + .ant-picker-cell-inner { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.custom-date-picker + .ant-picker-cell-in-view.ant-picker-cell-today + .ant-picker-cell-inner::before { + border-color: hsl(var(--primary)); +} + +.custom-date-picker + .ant-picker-time-panel-column + > li.ant-picker-time-panel-cell-selected + .ant-picker-time-panel-cell-inner { + background-color: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); +} + +.custom-date-picker .ant-picker-ranges { + border-top: 1px solid hsl(var(--border)); +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ed203ef5e88beab19b3dd07dfad56d8b9917fe66 Binary files /dev/null and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0bb7001cc9f74fcac9a67c1e1743444362fea701 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import localFont from "next/font/local"; +import "./globals.css"; +import Header from "@/components/Header"; +import AuthCheck from "@/components/AuthCheck"; +import { Toaster } from "@/components/ui/toaster"; +import I18nProvider from "@/components/I18nProvider"; + +const geistSans = localFont({ + src: "./fonts/GeistVF.woff", + variable: "--font-geist-sans", + weight: "100 900", +}); +const geistMono = localFont({ + src: "./fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", + weight: "100 900", +}); + +export const metadata: Metadata = { + title: "OpenWebUI Monitor", + description: "Monitor and analyze your OpenWebUI usage data", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +