chore: prune legacy node backend and mix workflow models
This commit is contained in:
@@ -13,6 +13,7 @@ tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-
|
|||||||
# Async runtime
|
# Async runtime
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.8", features = [
|
sqlx = { version = "0.8", features = [
|
||||||
|
|||||||
+3
-3
@@ -180,7 +180,7 @@ Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
|
|||||||
| Frontend | React 18 + Vite |
|
| Frontend | React 18 + Vite |
|
||||||
| Language | TypeScript |
|
| Language | TypeScript |
|
||||||
| Styling | Tailwind CSS v4 |
|
| Styling | Tailwind CSS v4 |
|
||||||
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM |
|
| Database | SQLite, with PostgreSQL migration preparation |
|
||||||
| Visualization | VChart |
|
| Visualization | VChart |
|
||||||
| Container | Docker + Docker Compose |
|
| Container | Docker + Docker Compose |
|
||||||
| Test | Vitest |
|
| Test | Vitest |
|
||||||
@@ -191,12 +191,12 @@ Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run db:migrate
|
cargo run
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # Build web + server
|
npm run build # Build web assets
|
||||||
npm test # Run tests
|
npm test # Run tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -219,7 +219,7 @@ build_frontend() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "构建前端 (Vite via ${frontend_pm})..."
|
log "构建前端 (Vite via ${frontend_pm})..."
|
||||||
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
|
# 直接调用 vite build;桌面端构建链路已移除。
|
||||||
if [ "$frontend_pm" = "bun" ]; then
|
if [ "$frontend_pm" = "bun" ]; then
|
||||||
bunx --bun vite build
|
bunx --bun vite build
|
||||||
else
|
else
|
||||||
|
|||||||
+9
-9
@@ -28,19 +28,19 @@ proxy_log_retention_days = 30
|
|||||||
|
|
||||||
[comfyui]
|
[comfyui]
|
||||||
enabled = false
|
enabled = false
|
||||||
# boosapi = use embedded Workflow API bridge; external/cloud = proxy Workflow
|
|
||||||
# API and WebSocket to a full ComfyUI-compatible backend.
|
|
||||||
backend = "cloud"
|
|
||||||
docker_image = "boosapi-comfyui:multi-tenant"
|
|
||||||
container_name = "boosapi-comfyui-workflow"
|
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
port = 8188
|
port = 8188
|
||||||
extra_args = []
|
extra_args = []
|
||||||
max_restarts = 5
|
max_restarts = 5
|
||||||
|
# backend modes:
|
||||||
|
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
|
||||||
|
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
|
||||||
|
# - "external": proxy to your own full ComfyUI server.
|
||||||
|
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
|
||||||
|
backend = "cloud"
|
||||||
|
# docker_image = "boosapi-comfyui:multi-tenant"
|
||||||
|
# container_name = "boosapi-comfyui-workflow"
|
||||||
|
# base_url = "https://www.runninghub.cn/proxy/94d1330ea62d4ae980ffb018ecdf82a2"
|
||||||
base_url = "https://cloud.comfy.org"
|
base_url = "https://cloud.comfy.org"
|
||||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||||
# For Comfy Cloud:
|
|
||||||
# backend = "cloud"
|
|
||||||
# base_url = "https://cloud.comfy.org"
|
|
||||||
# api_key = "your-comfy-cloud-api-key"
|
|
||||||
agent_model = "gpt-5.5"
|
agent_model = "gpt-5.5"
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
appId: me.cita777.boosapi.desktop
|
|
||||||
productName: Metapi
|
|
||||||
artifactName: ${name}-${version}-${os}-${arch}.${ext}
|
|
||||||
directories:
|
|
||||||
output: release
|
|
||||||
electronDist: node_modules/electron/dist
|
|
||||||
icon: build/desktop-icon.png
|
|
||||||
asar: false
|
|
||||||
files:
|
|
||||||
- dist/**/*
|
|
||||||
- drizzle/**/*
|
|
||||||
- package.json
|
|
||||||
mac:
|
|
||||||
target:
|
|
||||||
- dmg
|
|
||||||
- zip
|
|
||||||
category: public.app-category.developer-tools
|
|
||||||
hardenedRuntime: false
|
|
||||||
gatekeeperAssess: false
|
|
||||||
entitlementsInherit: null
|
|
||||||
win:
|
|
||||||
target:
|
|
||||||
- nsis
|
|
||||||
- zip
|
|
||||||
linux:
|
|
||||||
target:
|
|
||||||
- AppImage
|
|
||||||
- deb
|
|
||||||
- rpm
|
|
||||||
maintainer: cita-777 <cita-777@users.noreply.github.com>
|
|
||||||
publish:
|
|
||||||
provider: github
|
|
||||||
owner: cita-777
|
|
||||||
repo: boosapi
|
|
||||||
releaseType: release
|
|
||||||
publishAutoUpdate: true
|
|
||||||
nsis:
|
|
||||||
oneClick: false
|
|
||||||
perMachine: false
|
|
||||||
allowToChangeInstallationDirectory: true
|
|
||||||
afterPack: scripts/desktop/afterPack.mjs
|
|
||||||
afterSign: scripts/desktop/afterSign.mjs
|
|
||||||
+7
-69
@@ -2,12 +2,12 @@
|
|||||||
"name": "boosapi",
|
"name": "boosapi",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||||
"main": "dist/desktop/main.js",
|
"main": "dist/web/index.html",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
"api-gateway",
|
"api-gateway",
|
||||||
"proxy",
|
"proxy",
|
||||||
"fastify",
|
"rust",
|
||||||
"openai",
|
"openai",
|
||||||
"claude",
|
"claude",
|
||||||
"aggregator"
|
"aggregator"
|
||||||
@@ -22,51 +22,20 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=25.0.0"
|
"node": ">=22.0.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "tsx scripts/dev/run-server.ts --watch",
|
"dev": "vite",
|
||||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
|
||||||
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
|
|
||||||
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
|
|
||||||
"build:web": "vite build",
|
"build:web": "vite build",
|
||||||
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts",
|
"build": "npm run build:web",
|
||||||
"build:desktop": "tsc -p tsconfig.desktop.json",
|
|
||||||
"build": "npm run build:web && npm run build:server && npm run build:desktop",
|
|
||||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||||
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
|
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||||
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
|
|
||||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
|
|
||||||
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
|
|
||||||
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
|
|
||||||
"package:desktop": "npm run dist:desktop",
|
|
||||||
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
|
|
||||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||||
"docs:build": "vitepress build docs",
|
"docs:build": "vitepress build docs",
|
||||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||||
"start": "node dist/server/index.js",
|
|
||||||
"start:deploy-helper": "node dist/server/update-helper/index.js",
|
|
||||||
"start:desktop": "electron dist/desktop/main.js",
|
|
||||||
"comfyui:install": "node scripts/comfyui/install.mjs",
|
|
||||||
"comfyui:start": "node scripts/comfyui/start.mjs",
|
|
||||||
"comfyui:stop": "node scripts/comfyui/stop.mjs",
|
|
||||||
"workflow-ui:install": "node scripts/workflow-ui/install.mjs",
|
"workflow-ui:install": "node scripts/workflow-ui/install.mjs",
|
||||||
"db:generate": "drizzle-kit generate",
|
|
||||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
|
||||||
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
|
|
||||||
"schema:generate": "npm run db:generate && npm run schema:contract",
|
|
||||||
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
|
|
||||||
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
|
|
||||||
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
|
|
||||||
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
|
|
||||||
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
|
|
||||||
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
|
|
||||||
"smoke:db": "tsx scripts/dev/db-smoke.ts",
|
|
||||||
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
|
|
||||||
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
|
|
||||||
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
|
|
||||||
"test": "vitest run --root .",
|
"test": "vitest run --root .",
|
||||||
"test:watch": "vitest --root ."
|
"test:watch": "vitest --root ."
|
||||||
},
|
},
|
||||||
@@ -74,46 +43,17 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
|
||||||
"@fastify/static": "^9.0.0",
|
|
||||||
"@visactor/react-vchart": "^2.0.21",
|
"@visactor/react-vchart": "^2.0.21",
|
||||||
"better-sqlite3": "^12.8.0",
|
|
||||||
"dotenv": "^17.4.1",
|
|
||||||
"drizzle-orm": "^0.45.2",
|
|
||||||
"electron-log": "^5.2.4",
|
|
||||||
"electron-updater": "^6.6.2",
|
|
||||||
"fastify": "^5.8.4",
|
|
||||||
"get-port": "^7.2.0",
|
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
"minimatch": "^10.2.5",
|
|
||||||
"minimist": "^1.2.8",
|
|
||||||
"mysql2": "^3.20.0",
|
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"nodemailer": "^8.0.4",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"rate-limiter-flexible": "^11.0.0",
|
|
||||||
"socks": "^2.8.7",
|
|
||||||
"undici": "^6.24.1",
|
|
||||||
"ws": "^8.20.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/notarize": "^3.1.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"@types/nodemailer": "^8.0.0",
|
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"concurrently": "^9.1.0",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"drizzle-kit": "^0.31.10",
|
|
||||||
"electron": "^41.1.1",
|
|
||||||
"electron-builder": "^26.0.12",
|
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"mermaid": "^11.14.0",
|
"mermaid": "^11.14.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -122,13 +62,11 @@
|
|||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitepress": "^1.6.4",
|
"vitepress": "^1.6.4",
|
||||||
"vitepress-plugin-mermaid": "^2.0.17",
|
"vitepress-plugin-mermaid": "^2.0.17",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8"
|
||||||
"wait-on": "^9.0.4"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"minimist": "^1.2.8"
|
"minimist": "^1.2.8"
|
||||||
|
|||||||
Generated
+74
-3768
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -109,7 +109,10 @@ impl Default for ComfyUIConfig {
|
|||||||
|
|
||||||
impl ComfyUIConfig {
|
impl ComfyUIConfig {
|
||||||
pub fn uses_native_backend(&self) -> bool {
|
pub fn uses_native_backend(&self) -> bool {
|
||||||
matches!(self.backend, ComfyUIBackend::External | ComfyUIBackend::Cloud)
|
matches!(
|
||||||
|
self.backend,
|
||||||
|
ComfyUIBackend::External | ComfyUIBackend::Cloud
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn uses_cloud_backend(&self) -> bool {
|
pub fn uses_cloud_backend(&self) -> bool {
|
||||||
|
|||||||
+1
-3
@@ -47,9 +47,7 @@ impl AppState {
|
|||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
comfyui,
|
comfyui,
|
||||||
builtin_keys: Arc::new(builtin_keys),
|
builtin_keys: Arc::new(builtin_keys),
|
||||||
register_captcha: Arc::new(
|
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
|
||||||
services::register_captcha::RegisterCaptchaStore::new(),
|
|
||||||
),
|
|
||||||
registration_limiter: Arc::new(
|
registration_limiter: Arc::new(
|
||||||
services::registration_limiter::RegistrationLimiter::new(),
|
services::registration_limiter::RegistrationLimiter::new(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target
|
|||||||
Ok(request) => request,
|
Ok(request) => request,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = client_socket
|
let _ = client_socket
|
||||||
.send(Message::Text(format!("invalid ComfyUI websocket URL: {err}").into()))
|
.send(Message::Text(
|
||||||
|
format!("invalid ComfyUI websocket URL: {err}").into(),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -701,7 +701,8 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("file");
|
.unwrap_or("file");
|
||||||
let header = format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
let header =
|
||||||
|
format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
||||||
|
|
||||||
if let Some(text) = attachment
|
if let Some(text) = attachment
|
||||||
.text
|
.text
|
||||||
|
|||||||
@@ -118,7 +118,13 @@ async fn generate(
|
|||||||
Json(mut body): Json<Value>,
|
Json(mut body): Json<Value>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let auth = resolve_auth(&headers, &state).await?;
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
if body.get("model").and_then(Value::as_str).unwrap_or("").trim().is_empty() {
|
if body
|
||||||
|
.get("model")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
return Err(AppError::BadRequest("model is required".into()));
|
return Err(AppError::BadRequest("model is required".into()));
|
||||||
}
|
}
|
||||||
if body
|
if body
|
||||||
|
|||||||
+164
-49
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::workflow::executor::{WorkflowProgressCallback, WorkflowProgressEvent};
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::jwt::validate_jwt,
|
auth::jwt::validate_jwt,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
@@ -9,7 +10,6 @@ use crate::{
|
|||||||
},
|
},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use crate::workflow::executor::{WorkflowProgressCallback, WorkflowProgressEvent};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::{
|
extract::{
|
||||||
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::{HashMap, HashSet},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{mpsc, Arc},
|
sync::{mpsc, Arc},
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
@@ -138,7 +138,10 @@ pub fn routes() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route("/workflow-ui/api/{*path}", any(native_workflow_api_proxy))
|
.route("/workflow-ui/api/{*path}", any(native_workflow_api_proxy))
|
||||||
.route("/workflow-ui/api/queue", get(queue).post(queue_mutate))
|
.route("/workflow-ui/api/queue", get(queue).post(queue_mutate))
|
||||||
.route("/workflow-ui/api/history", get(history).post(history_mutate))
|
.route(
|
||||||
|
"/workflow-ui/api/history",
|
||||||
|
get(history).post(history_mutate),
|
||||||
|
)
|
||||||
.route("/workflow-ui/api/history/{id}", get(history_by_id))
|
.route("/workflow-ui/api/history/{id}", get(history_by_id))
|
||||||
.route("/workflow-ui/api/interrupt", post(interrupt))
|
.route("/workflow-ui/api/interrupt", post(interrupt))
|
||||||
.route("/workflow-ui/api/view", get(view))
|
.route("/workflow-ui/api/view", get(view))
|
||||||
@@ -1039,6 +1042,84 @@ fn inject_model_choices(defs: &mut Map<String, Value>, models: &[String]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn boosapi_node_defs_with_models(models: &[String]) -> Map<String, Value> {
|
||||||
|
let mut defs = core_node_definitions()
|
||||||
|
.into_iter()
|
||||||
|
.chain(registry::node_types())
|
||||||
|
.map(|def| (def.node_type.to_string(), node_type_def_to_comfy(&def)))
|
||||||
|
.collect::<Map<_, _>>();
|
||||||
|
inject_model_choices(&mut defs, models);
|
||||||
|
defs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_boosapi_object_info(value: &mut Value, models: &[String]) {
|
||||||
|
let Some(object) = value.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (key, def) in boosapi_node_defs_with_models(models) {
|
||||||
|
object.insert(key, def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn builtin_model_folders() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"boosapi_models",
|
||||||
|
"checkpoints",
|
||||||
|
"chat_models",
|
||||||
|
"video_models",
|
||||||
|
"audio_tts_models",
|
||||||
|
"embeddings",
|
||||||
|
"vision_models",
|
||||||
|
"3d_models",
|
||||||
|
"loras",
|
||||||
|
"vae",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_boosapi_model_folders(value: &mut Value) {
|
||||||
|
let Some(items) = value.as_array_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut seen = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
item.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|name| name.to_ascii_lowercase())
|
||||||
|
})
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
for name in builtin_model_folders() {
|
||||||
|
if seen.insert(name.to_ascii_lowercase()) {
|
||||||
|
items.push(json!({ "name": name }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_contains_boosapi_nodes(prompt: &Value) -> bool {
|
||||||
|
prompt
|
||||||
|
.as_object()
|
||||||
|
.map(|nodes| {
|
||||||
|
nodes.values().any(|node| {
|
||||||
|
node.get("class_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|node_type| {
|
||||||
|
node_type.starts_with("boosapi.")
|
||||||
|
|| matches!(
|
||||||
|
node_type,
|
||||||
|
"input.text"
|
||||||
|
| "output.preview_image"
|
||||||
|
| "output.preview_video"
|
||||||
|
| "output.save_file"
|
||||||
|
| "util.text_join"
|
||||||
|
| "util.json_extract"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
async fn workflow_ui_index() -> Html<String> {
|
async fn workflow_ui_index() -> Html<String> {
|
||||||
let dir = workflow_ui_root();
|
let dir = workflow_ui_root();
|
||||||
let index = dir.join("index.html");
|
let index = dir.join("index.html");
|
||||||
@@ -1364,18 +1445,17 @@ async fn embeddings(State(state): State<AppState>, headers: HeaderMap) -> AppRes
|
|||||||
|
|
||||||
async fn object_info(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
async fn object_info(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||||
let _ = auth_caller_optional(&state, &headers);
|
let _ = auth_caller_optional(&state, &headers);
|
||||||
|
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||||
if state.config.comfyui.uses_native_backend() {
|
if state.config.comfyui.uses_native_backend() {
|
||||||
return native_workflow_json(&state, Method::GET, "/object_info", None).await;
|
let mut value = native_workflow_json(&state, Method::GET, "/object_info", None)
|
||||||
|
.await?
|
||||||
|
.0;
|
||||||
|
inject_boosapi_object_info(&mut value, &route_models);
|
||||||
|
return Ok(Json(value));
|
||||||
}
|
}
|
||||||
let mut defs = core_node_definitions()
|
Ok(Json(Value::Object(boosapi_node_defs_with_models(
|
||||||
.into_iter()
|
&route_models,
|
||||||
.chain(registry::node_types())
|
))))
|
||||||
.into_iter()
|
|
||||||
.map(|def| (def.node_type.to_string(), node_type_def_to_comfy(&def)))
|
|
||||||
.collect::<serde_json::Map<_, _>>();
|
|
||||||
let models = workflow_route_models(&state).await.unwrap_or_default();
|
|
||||||
inject_model_choices(&mut defs, &models);
|
|
||||||
Ok(Json(Value::Object(defs)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn system_stats(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
async fn system_stats(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||||
@@ -1420,7 +1500,12 @@ async fn queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<J
|
|||||||
}
|
}
|
||||||
let caller =
|
let caller =
|
||||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||||
let rows = storage::list_runs(&state.db, caller_user_id(&caller), caller_is_admin(&caller), 25)
|
let rows = storage::list_runs(
|
||||||
|
&state.db,
|
||||||
|
caller_user_id(&caller),
|
||||||
|
caller_is_admin(&caller),
|
||||||
|
25,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let running = rows
|
let running = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1487,7 +1572,8 @@ async fn history_mutate(
|
|||||||
return native_workflow_json(&state, Method::POST, "/history", Some(value)).await;
|
return native_workflow_json(&state, Method::POST, "/history", Some(value)).await;
|
||||||
}
|
}
|
||||||
let affected = if payload.clear {
|
let affected = if payload.clear {
|
||||||
storage::clear_runs(&state, caller_user_id(&caller), caller_is_admin(&caller)).await? as usize
|
storage::clear_runs(&state, caller_user_id(&caller), caller_is_admin(&caller)).await?
|
||||||
|
as usize
|
||||||
} else {
|
} else {
|
||||||
storage::delete_runs(
|
storage::delete_runs(
|
||||||
&state,
|
&state,
|
||||||
@@ -1598,6 +1684,9 @@ async fn queue_prompt(
|
|||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let caller = auth_caller(&state, &headers).await?;
|
let caller = auth_caller(&state, &headers).await?;
|
||||||
if state.config.comfyui.uses_native_backend() {
|
if state.config.comfyui.uses_native_backend() {
|
||||||
|
if prompt_contains_boosapi_nodes(&payload.prompt) {
|
||||||
|
return queue_boosapi_prompt(state, caller, payload).await;
|
||||||
|
}
|
||||||
return native_workflow_response(
|
return native_workflow_response(
|
||||||
&state,
|
&state,
|
||||||
Method::POST,
|
Method::POST,
|
||||||
@@ -1606,6 +1695,14 @@ async fn queue_prompt(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
queue_boosapi_prompt(state, caller, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn queue_boosapi_prompt(
|
||||||
|
state: AppState,
|
||||||
|
caller: WorkflowCaller,
|
||||||
|
payload: PromptPayload,
|
||||||
|
) -> AppResult<Response> {
|
||||||
let prompt = payload.prompt.clone();
|
let prompt = payload.prompt.clone();
|
||||||
let graph = match normalize_prompt_payload(&prompt) {
|
let graph = match normalize_prompt_payload(&prompt) {
|
||||||
Ok(graph) => graph,
|
Ok(graph) => graph,
|
||||||
@@ -1667,7 +1764,9 @@ async fn queue_prompt(
|
|||||||
.await;
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Ok(run_id) => {
|
Ok(run_id) => {
|
||||||
if let Ok(run) = storage::get_run(&state_for_task.db, &run_id, user_id, allow_admin).await {
|
if let Ok(run) =
|
||||||
|
storage::get_run(&state_for_task.db, &run_id, user_id, allow_admin).await
|
||||||
|
{
|
||||||
let outputs = run_outputs_for_comfy(&run);
|
let outputs = run_outputs_for_comfy(&run);
|
||||||
publish_run_events(Some(session_for_task.as_str()), &run, &outputs);
|
publish_run_events(Some(session_for_task.as_str()), &run, &outputs);
|
||||||
}
|
}
|
||||||
@@ -1781,7 +1880,10 @@ async fn view(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let remote_url: Option<String> = row.try_get("remote_url").ok();
|
let remote_url: Option<String> = row.try_get("remote_url").ok();
|
||||||
if let Some(url) = remote_url.as_deref().filter(|value| !value.trim().is_empty()) {
|
if let Some(url) = remote_url
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::TEMPORARY_REDIRECT,
|
StatusCode::TEMPORARY_REDIRECT,
|
||||||
[(axum::http::header::LOCATION, url.to_string())],
|
[(axum::http::header::LOCATION, url.to_string())],
|
||||||
@@ -1906,7 +2008,12 @@ fn native_workflow_base(state: &AppState) -> String {
|
|||||||
|
|
||||||
fn native_workflow_http_path(state: &AppState, path: &str) -> String {
|
fn native_workflow_http_path(state: &AppState, path: &str) -> String {
|
||||||
if state.config.comfyui.uses_cloud_backend() {
|
if state.config.comfyui.uses_cloud_backend() {
|
||||||
format!("/api{}", path.strip_prefix('/').map(|value| format!("/{value}")).unwrap_or_else(|| path.to_string()))
|
format!(
|
||||||
|
"/api{}",
|
||||||
|
path.strip_prefix('/')
|
||||||
|
.map(|value| format!("/{value}"))
|
||||||
|
.unwrap_or_else(|| path.to_string())
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
path.to_string()
|
path.to_string()
|
||||||
}
|
}
|
||||||
@@ -1919,9 +2026,13 @@ fn native_workflow_request(
|
|||||||
body: Option<Value>,
|
body: Option<Value>,
|
||||||
) -> reqwest::RequestBuilder {
|
) -> reqwest::RequestBuilder {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let url = format!("{}{}", native_workflow_base(state), native_workflow_http_path(state, path));
|
let url = format!(
|
||||||
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())
|
"{}{}",
|
||||||
.unwrap_or(reqwest::Method::GET);
|
native_workflow_base(state),
|
||||||
|
native_workflow_http_path(state, path)
|
||||||
|
);
|
||||||
|
let reqwest_method =
|
||||||
|
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
|
||||||
let mut request = client.request(reqwest_method, url);
|
let mut request = client.request(reqwest_method, url);
|
||||||
if let Some(api_key) = state
|
if let Some(api_key) = state
|
||||||
.config
|
.config
|
||||||
@@ -2014,14 +2125,7 @@ async fn native_workflow_view_response(
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
archive_native_workflow_asset(
|
archive_native_workflow_asset(state, caller, params, &content_type, bytes.as_ref()).await;
|
||||||
state,
|
|
||||||
caller,
|
|
||||||
params,
|
|
||||||
&content_type,
|
|
||||||
bytes.as_ref(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||||
@@ -2114,9 +2218,10 @@ async fn native_workflow_api_proxy(
|
|||||||
let body = if body.is_empty() {
|
let body = if body.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(serde_json::from_slice::<Value>(&body).unwrap_or_else(|_| {
|
Some(
|
||||||
json!({ "body": String::from_utf8_lossy(&body).to_string() })
|
serde_json::from_slice::<Value>(&body)
|
||||||
}))
|
.unwrap_or_else(|_| json!({ "body": String::from_utf8_lossy(&body).to_string() })),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
native_workflow_response(&state, method, &target, body).await
|
native_workflow_response(&state, method, &target, body).await
|
||||||
}
|
}
|
||||||
@@ -2673,24 +2778,16 @@ async fn experiment_models(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
if state.config.comfyui.uses_native_backend() {
|
if state.config.comfyui.uses_native_backend() {
|
||||||
let _ = auth_caller_optional(&state, &headers);
|
let _ = auth_caller_optional(&state, &headers);
|
||||||
return native_workflow_json(&state, Method::GET, "/experiment/models", None).await;
|
let mut value = native_workflow_json(&state, Method::GET, "/experiment/models", None)
|
||||||
|
.await?
|
||||||
|
.0;
|
||||||
|
inject_boosapi_model_folders(&mut value);
|
||||||
|
return Ok(Json(value));
|
||||||
}
|
}
|
||||||
let caller =
|
let caller =
|
||||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||||
let _ = caller;
|
let _ = caller;
|
||||||
let folders = vec![
|
let items = builtin_model_folders()
|
||||||
"boosapi_models",
|
|
||||||
"checkpoints",
|
|
||||||
"chat_models",
|
|
||||||
"video_models",
|
|
||||||
"audio_tts_models",
|
|
||||||
"embeddings",
|
|
||||||
"vision_models",
|
|
||||||
"3d_models",
|
|
||||||
"loras",
|
|
||||||
"vae",
|
|
||||||
];
|
|
||||||
let items = folders
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|name| json!({ "name": name }))
|
.map(|name| json!({ "name": name }))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -2704,6 +2801,16 @@ async fn experiment_models_in_folder(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
if state.config.comfyui.uses_native_backend() {
|
if state.config.comfyui.uses_native_backend() {
|
||||||
let _ = auth_caller_optional(&state, &headers);
|
let _ = auth_caller_optional(&state, &headers);
|
||||||
|
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||||
|
let api_models = workflow_folder_models(&folder, &route_models);
|
||||||
|
if !api_models.is_empty() {
|
||||||
|
return Ok(Json(Value::Array(
|
||||||
|
api_models
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| json!({ "name": name, "pathIndex": 0 }))
|
||||||
|
.collect(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
let path = format!("/experiment/models/{}", simple_urlencode(&folder));
|
let path = format!("/experiment/models/{}", simple_urlencode(&folder));
|
||||||
return native_workflow_json(&state, Method::GET, &path, None).await;
|
return native_workflow_json(&state, Method::GET, &path, None).await;
|
||||||
}
|
}
|
||||||
@@ -2743,7 +2850,11 @@ async fn view_metadata(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
if state.config.comfyui.uses_native_backend() {
|
if state.config.comfyui.uses_native_backend() {
|
||||||
let _ = auth_caller_optional(&state, &headers);
|
let _ = auth_caller_optional(&state, &headers);
|
||||||
let path = format!("/view_metadata/{}{}", simple_urlencode(&folder), build_query_string(&query));
|
let path = format!(
|
||||||
|
"/view_metadata/{}{}",
|
||||||
|
simple_urlencode(&folder),
|
||||||
|
build_query_string(&query)
|
||||||
|
);
|
||||||
return native_workflow_json(&state, Method::GET, &path, None).await;
|
return native_workflow_json(&state, Method::GET, &path, None).await;
|
||||||
}
|
}
|
||||||
let caller = auth_caller(&state, &headers).await?;
|
let caller = auth_caller(&state, &headers).await?;
|
||||||
@@ -2815,7 +2926,9 @@ async fn workflow_ws(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| "boosapi-workflow".to_string());
|
.unwrap_or_else(|| "boosapi-workflow".to_string());
|
||||||
if native_workflow_enabled(&state) {
|
if native_workflow_enabled(&state) {
|
||||||
return Ok(ws.on_upgrade(move |socket| native_workflow_ws_bridge(state, socket, query, sid)));
|
return Ok(
|
||||||
|
ws.on_upgrade(move |socket| native_workflow_ws_bridge(state, socket, query, sid))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(ws.on_upgrade(move |socket| async move {
|
Ok(ws.on_upgrade(move |socket| async move {
|
||||||
use tokio::time::{interval, Duration};
|
use tokio::time::{interval, Duration};
|
||||||
@@ -2883,7 +2996,9 @@ async fn native_workflow_ws_bridge(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
query.entry("token".to_string()).or_insert_with(|| api_key.to_string());
|
query
|
||||||
|
.entry("token".to_string())
|
||||||
|
.or_insert_with(|| api_key.to_string());
|
||||||
}
|
}
|
||||||
ws_url.push_str("/ws");
|
ws_url.push_str("/ws");
|
||||||
ws_url.push_str(&build_query_string(&query));
|
ws_url.push_str(&build_query_string(&query));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum WorkspaceCaller {
|
enum WorkspaceCaller {
|
||||||
@@ -318,7 +319,11 @@ async fn get_asset_file(
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
|
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
|
||||||
let path = PathBuf::from(file_path);
|
let path = PathBuf::from(file_path);
|
||||||
let bytes = tokio::fs::read(&path)
|
let file = tokio::fs::File::open(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||||
|
let metadata = file
|
||||||
|
.metadata()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||||
let mime_type = row
|
let mime_type = row
|
||||||
@@ -334,11 +339,13 @@ async fn get_asset_file(
|
|||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, mime_type)
|
.header(header::CONTENT_TYPE, mime_type)
|
||||||
|
.header(header::CONTENT_LENGTH, metadata.len().to_string())
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
|
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
|
||||||
)
|
)
|
||||||
.body(Body::from(bytes))
|
.body(Body::from_stream(ReaderStream::new(file)))
|
||||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export function parseBatchApiKeys(input) {
|
|
||||||
if (Array.isArray(input)) {
|
|
||||||
return Array.from(new Set(input
|
|
||||||
.map((item) => String(item || '').trim())
|
|
||||||
.filter((item) => item.length > 0)));
|
|
||||||
}
|
|
||||||
const raw = String(input || '').trim();
|
|
||||||
if (!raw)
|
|
||||||
return [];
|
|
||||||
return Array.from(new Set(raw
|
|
||||||
.split(/[\r\n,,;;\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => item.length > 0)));
|
|
||||||
}
|
|
||||||
export function buildBatchApiKeyConnectionName(baseName, index, total) {
|
|
||||||
const normalized = String(baseName || '').trim();
|
|
||||||
if (!normalized)
|
|
||||||
return '';
|
|
||||||
if (total <= 1)
|
|
||||||
return normalized;
|
|
||||||
return `${normalized} #${index + 1}`;
|
|
||||||
}
|
|
||||||
@@ -1,779 +0,0 @@
|
|||||||
const BRAND_DEFINITIONS = [
|
|
||||||
{
|
|
||||||
name: 'OpenAI',
|
|
||||||
icon: 'openai',
|
|
||||||
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'gpt', mode: 'startsWith' },
|
|
||||||
{ keyword: 'chatgpt', mode: 'startsWith' },
|
|
||||||
{ keyword: 'dall-e', mode: 'startsWith' },
|
|
||||||
{ keyword: 'whisper', mode: 'startsWith' },
|
|
||||||
{ keyword: 'text-embedding', mode: 'startsWith' },
|
|
||||||
{ keyword: 'text-moderation', mode: 'startsWith' },
|
|
||||||
{ keyword: 'davinci', mode: 'startsWith' },
|
|
||||||
{ keyword: 'babbage', mode: 'startsWith' },
|
|
||||||
{ keyword: 'codex-mini', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o1', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o3', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o4', mode: 'startsWith' },
|
|
||||||
{ keyword: 'tts', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Anthropic',
|
|
||||||
icon: 'claude-color',
|
|
||||||
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'claude', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Google',
|
|
||||||
icon: 'gemini-color',
|
|
||||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'gemini', mode: 'includes' },
|
|
||||||
{ keyword: 'gemma', mode: 'includes' },
|
|
||||||
{ keyword: 'google/', mode: 'includes' },
|
|
||||||
{ keyword: 'palm', mode: 'includes' },
|
|
||||||
{ keyword: 'paligemma', mode: 'includes' },
|
|
||||||
{ keyword: 'shieldgemma', mode: 'includes' },
|
|
||||||
{ keyword: 'recurrentgemma', mode: 'includes' },
|
|
||||||
{ keyword: 'deplot', mode: 'includes' },
|
|
||||||
{ keyword: 'codegemma', mode: 'includes' },
|
|
||||||
{ keyword: 'imagen', mode: 'includes' },
|
|
||||||
{ keyword: 'learnlm', mode: 'includes' },
|
|
||||||
{ keyword: 'aqa', mode: 'includes' },
|
|
||||||
{ keyword: 'veo', mode: 'startsWith' },
|
|
||||||
{ keyword: 'google/', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepSeek',
|
|
||||||
icon: 'deepseek-color',
|
|
||||||
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepseek', mode: 'includes' },
|
|
||||||
{ keyword: 'ds-chat', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '通义千问',
|
|
||||||
icon: 'qwen-color',
|
|
||||||
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'qwen', mode: 'includes' },
|
|
||||||
{ keyword: 'qwq', mode: 'includes' },
|
|
||||||
{ keyword: 'tongyi', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '智谱 AI',
|
|
||||||
icon: 'zhipu-color',
|
|
||||||
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'glm', mode: 'includes' },
|
|
||||||
{ keyword: 'chatglm', mode: 'includes' },
|
|
||||||
{ keyword: 'codegeex', mode: 'includes' },
|
|
||||||
{ keyword: 'cogview', mode: 'includes' },
|
|
||||||
{ keyword: 'cogvideo', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Meta',
|
|
||||||
icon: 'meta-color',
|
|
||||||
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'llama', mode: 'includes' },
|
|
||||||
{ keyword: 'code-llama', mode: 'includes' },
|
|
||||||
{ keyword: 'codellama', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mistral',
|
|
||||||
icon: 'mistral-color',
|
|
||||||
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'mistral', mode: 'includes' },
|
|
||||||
{ keyword: 'mixtral', mode: 'includes' },
|
|
||||||
{ keyword: 'codestral', mode: 'includes' },
|
|
||||||
{ keyword: 'pixtral', mode: 'includes' },
|
|
||||||
{ keyword: 'ministral', mode: 'includes' },
|
|
||||||
{ keyword: 'voxtral', mode: 'includes' },
|
|
||||||
{ keyword: 'magistral', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Moonshot',
|
|
||||||
icon: 'moonshot',
|
|
||||||
color: 'linear-gradient(135deg, #000000, #333333)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'moonshot', mode: 'includes' },
|
|
||||||
{ keyword: 'kimi', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '零一万物',
|
|
||||||
icon: 'yi-color',
|
|
||||||
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'yi-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'yi', mode: 'boundary' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '文心一言',
|
|
||||||
icon: 'wenxin-color',
|
|
||||||
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ernie', mode: 'includes' },
|
|
||||||
{ keyword: 'eb-', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '讯飞星火',
|
|
||||||
icon: 'spark-color',
|
|
||||||
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'spark', mode: 'includes' },
|
|
||||||
{ keyword: 'generalv', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '腾讯混元',
|
|
||||||
icon: 'hunyuan-color',
|
|
||||||
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'hunyuan', mode: 'includes' },
|
|
||||||
{ keyword: 'tencent-hunyuan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '豆包',
|
|
||||||
icon: 'doubao-color',
|
|
||||||
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'doubao', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'MiniMax',
|
|
||||||
icon: 'minimax-color',
|
|
||||||
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'minimax', mode: 'includes' },
|
|
||||||
{ keyword: 'abab', mode: 'includes' },
|
|
||||||
{ keyword: 'mini2.1', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cohere',
|
|
||||||
icon: 'cohere-color',
|
|
||||||
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'command', mode: 'includes' },
|
|
||||||
{ keyword: 'c4ai-', mode: 'includes' },
|
|
||||||
{ keyword: 'aya', mode: 'includes' },
|
|
||||||
{ keyword: 'embed-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Microsoft',
|
|
||||||
icon: 'microsoft-color',
|
|
||||||
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'microsoft/', mode: 'includes' },
|
|
||||||
{ keyword: 'phi-', mode: 'includes' },
|
|
||||||
{ keyword: 'kosmos', mode: 'includes' },
|
|
||||||
{ keyword: 'phi4', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'xAI',
|
|
||||||
icon: 'xai',
|
|
||||||
color: 'linear-gradient(135deg, #111, #444)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'grok', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '阶跃星辰',
|
|
||||||
icon: 'stepfun-color',
|
|
||||||
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'stepfun', mode: 'includes' },
|
|
||||||
{ keyword: 'step-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'step3', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百川智能',
|
|
||||||
icon: 'baichuan-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baichuan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI21 Labs',
|
|
||||||
icon: 'ai21-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #a855f7)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ai21', mode: 'includes' },
|
|
||||||
{ keyword: 'jamba', mode: 'startsWith' },
|
|
||||||
{ keyword: 'jamba', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI2',
|
|
||||||
icon: 'ai2-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'allenai', mode: 'includes' },
|
|
||||||
{ keyword: 'olmo', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Amazon Nova',
|
|
||||||
icon: 'nova',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'amazon/nova', mode: 'startsWith' },
|
|
||||||
{ keyword: 'amazon.nova', mode: 'includes' },
|
|
||||||
{ keyword: 'us.amazon.nova', mode: 'includes' },
|
|
||||||
{ keyword: 'nova-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-lite', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-pro', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-micro', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-canvas', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-reel', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Stability',
|
|
||||||
icon: 'stability-color',
|
|
||||||
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'flux', mode: 'includes' },
|
|
||||||
{ keyword: 'stablediffusion', mode: 'includes' },
|
|
||||||
{ keyword: 'stable-diffusion', mode: 'includes' },
|
|
||||||
{ keyword: 'sdxl', mode: 'includes' },
|
|
||||||
{ keyword: 'sd3', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NVIDIA',
|
|
||||||
icon: 'nvidia-color',
|
|
||||||
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'nvidia/', mode: 'includes' },
|
|
||||||
{ keyword: 'nvclip', mode: 'includes' },
|
|
||||||
{ keyword: 'nemotron', mode: 'includes' },
|
|
||||||
{ keyword: 'nemoretriever', mode: 'includes' },
|
|
||||||
{ keyword: 'neva', mode: 'includes' },
|
|
||||||
{ keyword: 'riva-translate', mode: 'includes' },
|
|
||||||
{ keyword: 'cosmos', mode: 'includes' },
|
|
||||||
{ keyword: 'nv-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'IBM',
|
|
||||||
icon: 'ibm',
|
|
||||||
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ibm/', mode: 'includes' },
|
|
||||||
{ keyword: 'granite', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'BAAI',
|
|
||||||
icon: 'baai',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baai/', mode: 'includes' },
|
|
||||||
{ keyword: 'bge-', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ByteDance',
|
|
||||||
icon: 'bytedance-color',
|
|
||||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bytedance', mode: 'includes' },
|
|
||||||
{ keyword: 'seed-oss', mode: 'includes' },
|
|
||||||
{ keyword: 'kolors', mode: 'includes' },
|
|
||||||
{ keyword: 'kwai', mode: 'includes' },
|
|
||||||
{ keyword: 'kwaipilot', mode: 'includes' },
|
|
||||||
{ keyword: 'wan-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'kat-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'InternLM',
|
|
||||||
icon: 'internlm-color',
|
|
||||||
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'internlm', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Midjourney',
|
|
||||||
icon: 'midjourney',
|
|
||||||
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'midjourney', mode: 'includes' },
|
|
||||||
{ keyword: 'mj_', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepL',
|
|
||||||
icon: 'deepl-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepl-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'deepl/', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Jina AI',
|
|
||||||
icon: 'jina',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'jina', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Relace',
|
|
||||||
icon: 'relace',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'relace', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Arcee',
|
|
||||||
icon: 'arcee-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'arcee-ai', mode: 'includes' },
|
|
||||||
{ keyword: 'arcee', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AionLabs',
|
|
||||||
icon: 'aionlabs-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'aion-labs', mode: 'includes' },
|
|
||||||
{ keyword: 'aionlabs', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepCogito',
|
|
||||||
icon: 'deepcogito-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #7c3aed)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepcogito', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Essential AI',
|
|
||||||
icon: 'essentialai-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #334155)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'essentialai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inception',
|
|
||||||
icon: 'inception',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'inception', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inflection',
|
|
||||||
icon: 'inflection',
|
|
||||||
color: 'linear-gradient(135deg, #1d4ed8, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'inflection', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Liquid AI',
|
|
||||||
icon: 'liquid',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #475569)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'liquid', mode: 'includes' },
|
|
||||||
{ keyword: 'lfm-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'LongCat',
|
|
||||||
icon: 'longcat-color',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb7185)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'longcat', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Morph',
|
|
||||||
icon: 'morph-color',
|
|
||||||
color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'morph/', mode: 'includes' },
|
|
||||||
{ keyword: 'morph-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Nous Research',
|
|
||||||
icon: 'nousresearch',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'nousresearch', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Upstage',
|
|
||||||
icon: 'upstage-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'upstage', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Xiaomi MiMo',
|
|
||||||
icon: 'xiaomimimo',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'xiaomi/mimo', mode: 'includes' },
|
|
||||||
{ keyword: 'xiaomimimo', mode: 'includes' },
|
|
||||||
{ keyword: 'mimo-v', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Z.ai',
|
|
||||||
icon: 'zai',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: '2zai', mode: 'startsWith' },
|
|
||||||
{ keyword: 'z-ai', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SenseNova',
|
|
||||||
icon: 'sensenova-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'sensenova', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Perplexity',
|
|
||||||
icon: 'perplexity-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'perplexity', mode: 'includes' },
|
|
||||||
{ keyword: 'pplx-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OpenRouter',
|
|
||||||
icon: 'openrouter',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'openrouter', mode: 'includes' },
|
|
||||||
{ keyword: 'openrouter-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Groq',
|
|
||||||
icon: 'groq',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'groq', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Fireworks',
|
|
||||||
icon: 'fireworks-color',
|
|
||||||
color: 'linear-gradient(135deg, #fb7185, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'fireworks-ai', mode: 'includes' },
|
|
||||||
{ keyword: 'fireworks', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepInfra',
|
|
||||||
icon: 'deepinfra-color',
|
|
||||||
color: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepinfra', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Together AI',
|
|
||||||
icon: 'together-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'together.ai', mode: 'includes' },
|
|
||||||
{ keyword: 'together', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Replicate',
|
|
||||||
icon: 'replicate-brand',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'replicate', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SambaNova',
|
|
||||||
icon: 'sambanova-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #06b6d4)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'sambanova', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cerebras',
|
|
||||||
icon: 'cerebras-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #65a30d)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'cerebras', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Ollama',
|
|
||||||
icon: 'ollama',
|
|
||||||
color: 'linear-gradient(135deg, #1f2937, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ollama', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ModelScope',
|
|
||||||
icon: 'modelscope-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'modelscope', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SiliconCloud',
|
|
||||||
icon: 'siliconcloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'siliconcloud', mode: 'includes' },
|
|
||||||
{ keyword: 'siliconflow', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Azure AI',
|
|
||||||
icon: 'azureai-color',
|
|
||||||
color: 'linear-gradient(135deg, #0284c7, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'azureai', mode: 'includes' },
|
|
||||||
{ keyword: 'azure-openai', mode: 'includes' },
|
|
||||||
{ keyword: 'azure/openai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AWS Bedrock',
|
|
||||||
icon: 'bedrock-color',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bedrock', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Vertex AI',
|
|
||||||
icon: 'vertexai-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'vertexai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Google Cloud',
|
|
||||||
icon: 'googlecloud-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'googlecloud', mode: 'includes' },
|
|
||||||
{ keyword: 'google-cloud', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百度智能云',
|
|
||||||
icon: 'baiducloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #38bdf8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baiducloud', mode: 'includes' },
|
|
||||||
{ keyword: 'qianfan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百炼',
|
|
||||||
icon: 'bailian-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bailian', mode: 'includes' },
|
|
||||||
{ keyword: 'dashscope', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '阿里云',
|
|
||||||
icon: 'alibabacloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'alibabacloud', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '火山引擎',
|
|
||||||
icon: 'volcengine-color',
|
|
||||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'volcengine', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '七牛云',
|
|
||||||
icon: 'qiniu-color',
|
|
||||||
color: 'linear-gradient(135deg, #06b6d4, #0891b2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'qiniu', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
function normalizeInput(value) {
|
|
||||||
return String(value || '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
export function stripCommonWrappers(value) {
|
|
||||||
return value
|
|
||||||
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
|
||||||
.replace(/^re:\s*/g, '')
|
|
||||||
.replace(/^\^+/, '')
|
|
||||||
.replace(/\$+$/, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
export function collectBrandCandidates(modelName) {
|
|
||||||
const queue = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const push = (value) => {
|
|
||||||
const normalized = normalizeInput(value);
|
|
||||||
if (!normalized || seen.has(normalized))
|
|
||||||
return;
|
|
||||||
seen.add(normalized);
|
|
||||||
queue.push(normalized);
|
|
||||||
};
|
|
||||||
push(modelName);
|
|
||||||
for (let index = 0; index < queue.length; index += 1) {
|
|
||||||
const candidate = queue[index];
|
|
||||||
const cleaned = stripCommonWrappers(candidate);
|
|
||||||
push(cleaned);
|
|
||||||
if (cleaned.includes('/')) {
|
|
||||||
for (const part of cleaned.split('/'))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
if (cleaned.includes(':')) {
|
|
||||||
for (const part of cleaned.split(':'))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
if (cleaned.includes(',')) {
|
|
||||||
for (const part of cleaned.split(','))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return queue;
|
|
||||||
}
|
|
||||||
function buildMatchContext(modelName) {
|
|
||||||
const candidates = collectBrandCandidates(modelName);
|
|
||||||
const raw = candidates[0] || normalizeInput(modelName);
|
|
||||||
const cleaned = stripCommonWrappers(raw);
|
|
||||||
const segments = Array.from(new Set(candidates
|
|
||||||
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
|
|
||||||
.map((segment) => segment.trim())
|
|
||||||
.filter(Boolean)));
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
cleaned,
|
|
||||||
segments,
|
|
||||||
candidates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function matchesRule(context, rule) {
|
|
||||||
switch (rule.mode) {
|
|
||||||
case 'includes':
|
|
||||||
return context.raw.includes(rule.keyword)
|
|
||||||
|| context.cleaned.includes(rule.keyword)
|
|
||||||
|| context.candidates.some((candidate) => candidate.includes(rule.keyword));
|
|
||||||
case 'startsWith':
|
|
||||||
return context.raw.startsWith(rule.keyword)
|
|
||||||
|| context.cleaned.startsWith(rule.keyword)
|
|
||||||
|| context.segments.some((segment) => segment.startsWith(rule.keyword))
|
|
||||||
|| context.candidates.some((candidate) => candidate.startsWith(rule.keyword));
|
|
||||||
case 'segment':
|
|
||||||
return context.segments.includes(rule.keyword);
|
|
||||||
case 'boundary': {
|
|
||||||
const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`);
|
|
||||||
return pattern.test(context.raw)
|
|
||||||
|| pattern.test(context.cleaned)
|
|
||||||
|| context.candidates.some((candidate) => pattern.test(candidate));
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function escapeRegExp(value) {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
|
|
||||||
brand,
|
|
||||||
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
|
|
||||||
}));
|
|
||||||
export function getAllBrands() {
|
|
||||||
return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color }));
|
|
||||||
}
|
|
||||||
export function getAllBrandNames() {
|
|
||||||
return BRAND_DEFINITIONS.map((brand) => brand.name);
|
|
||||||
}
|
|
||||||
function toBrandInfo(brand) {
|
|
||||||
return {
|
|
||||||
name: brand.name,
|
|
||||||
icon: brand.icon,
|
|
||||||
color: brand.color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function getMatchingBrands(modelName) {
|
|
||||||
const context = buildMatchContext(modelName);
|
|
||||||
const matches = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const add = (brand) => {
|
|
||||||
if (seen.has(brand.name))
|
|
||||||
return;
|
|
||||||
seen.add(brand.name);
|
|
||||||
matches.push(toBrandInfo(brand));
|
|
||||||
};
|
|
||||||
for (const definition of BRAND_DEFINITIONS) {
|
|
||||||
if (definition.rules.some((rule) => matchesRule(context, rule))) {
|
|
||||||
add(definition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const candidate of context.candidates) {
|
|
||||||
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
|
||||||
if (rule.boundaryRegex.test(candidate)) {
|
|
||||||
add(rule.brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
export function getMatchingBrandNames(modelName) {
|
|
||||||
return getMatchingBrands(modelName).map((brand) => brand.name);
|
|
||||||
}
|
|
||||||
export function getBrand(modelName) {
|
|
||||||
return getMatchingBrands(modelName)[0] || null;
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
|
||||||
function normalizeString(value) {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}
|
|
||||||
function normalizeDigest(value) {
|
|
||||||
const digest = normalizeString(value);
|
|
||||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
|
||||||
}
|
|
||||||
function normalizeStableTag(value) {
|
|
||||||
return normalizeStableVersion(value);
|
|
||||||
}
|
|
||||||
function hasSameImageTag(left, right) {
|
|
||||||
const rawLeft = normalizeString(left);
|
|
||||||
const rawRight = normalizeString(right);
|
|
||||||
if (!rawLeft || !rawRight)
|
|
||||||
return false;
|
|
||||||
const stableLeft = normalizeStableTag(left);
|
|
||||||
const stableRight = normalizeStableTag(right);
|
|
||||||
if (stableLeft && stableRight) {
|
|
||||||
return stableLeft === stableRight;
|
|
||||||
}
|
|
||||||
return rawLeft === rawRight;
|
|
||||||
}
|
|
||||||
export function normalizeStableVersion(value) {
|
|
||||||
const raw = normalizeString(value);
|
|
||||||
if (!raw)
|
|
||||||
return '';
|
|
||||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
|
||||||
if (!match)
|
|
||||||
return '';
|
|
||||||
return [
|
|
||||||
Number.parseInt(match[1], 10),
|
|
||||||
Number.parseInt(match[2], 10),
|
|
||||||
Number.parseInt(match[3], 10),
|
|
||||||
].join('.');
|
|
||||||
}
|
|
||||||
export function compareStableVersions(left, right) {
|
|
||||||
const normalizedLeft = normalizeStableVersion(left);
|
|
||||||
const normalizedRight = normalizeStableVersion(right);
|
|
||||||
if (!normalizedLeft || !normalizedRight)
|
|
||||||
return null;
|
|
||||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
if (leftParts[index] === rightParts[index])
|
|
||||||
continue;
|
|
||||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
export function isSameImageTarget(current, target) {
|
|
||||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
|
||||||
const targetDigest = normalizeDigest(target.digest);
|
|
||||||
if (currentDigest && targetDigest) {
|
|
||||||
return currentDigest === targetDigest;
|
|
||||||
}
|
|
||||||
return hasSameImageTag(current?.imageTag, target.tag);
|
|
||||||
}
|
|
||||||
export function buildUpdateReminderCandidateKey(source, candidate) {
|
|
||||||
const tagName = normalizeString(candidate.tagName);
|
|
||||||
const digest = normalizeDigest(candidate.digest);
|
|
||||||
if (!tagName)
|
|
||||||
return '';
|
|
||||||
if (source === 'docker-hub-tag' && digest) {
|
|
||||||
return `${source}:${tagName}@${digest}`;
|
|
||||||
}
|
|
||||||
return `${source}:${tagName}`;
|
|
||||||
}
|
|
||||||
export function resolveUpdateReminderCandidate(input) {
|
|
||||||
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
|
|
||||||
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
|
|
||||||
if (githubTag) {
|
|
||||||
const githubTargetVersion = githubCandidateVersion || githubTag;
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
|
|
||||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
|
|
||||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
|
||||||
return {
|
|
||||||
source: 'github-release',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
|
|
||||||
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
|
|
||||||
tagName: githubTag,
|
|
||||||
digest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
|
|
||||||
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
|
|
||||||
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
|
|
||||||
if (!dockerTag)
|
|
||||||
return null;
|
|
||||||
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
|
|
||||||
if (dockerVersionCompare === -1) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
|
||||||
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-digest',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -179,7 +179,13 @@ mod tests {
|
|||||||
fn captcha_is_one_time() {
|
fn captcha_is_one_time() {
|
||||||
let store = RegisterCaptchaStore::new();
|
let store = RegisterCaptchaStore::new();
|
||||||
let challenge = store.issue(None);
|
let challenge = store.issue(None);
|
||||||
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
let answer = store
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.get(&challenge.id)
|
||||||
|
.unwrap()
|
||||||
|
.answer
|
||||||
|
.clone();
|
||||||
|
|
||||||
store.verify(&challenge.id, &answer, None).unwrap();
|
store.verify(&challenge.id, &answer, None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -192,7 +198,13 @@ mod tests {
|
|||||||
fn captcha_rejects_other_ip() {
|
fn captcha_rejects_other_ip() {
|
||||||
let store = RegisterCaptchaStore::new();
|
let store = RegisterCaptchaStore::new();
|
||||||
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||||
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
let answer = store
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.get(&challenge.id)
|
||||||
|
.unwrap()
|
||||||
|
.answer
|
||||||
|
.clone();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
|
|||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
export declare function parseBatchApiKeys(input: unknown): string[];
|
|
||||||
export declare function buildBatchApiKeyConnectionName(
|
|
||||||
baseName: string | null | undefined,
|
|
||||||
index: number,
|
|
||||||
total: number,
|
|
||||||
): string;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
buildBatchApiKeyConnectionName,
|
|
||||||
parseBatchApiKeys,
|
|
||||||
} from '../server/services/shared/apiKeyBatchCore.js';
|
|
||||||
Vendored
-40
@@ -1,40 +0,0 @@
|
|||||||
export type UpdateVersionCandidateLike = {
|
|
||||||
normalizedVersion?: string | null;
|
|
||||||
displayVersion?: string | null;
|
|
||||||
tagName?: string | null;
|
|
||||||
digest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateHelperRuntimeLike = {
|
|
||||||
imageTag?: string | null;
|
|
||||||
imageDigest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateReminderCandidate = {
|
|
||||||
source: 'github-release' | 'docker-hub-tag';
|
|
||||||
kind: 'new-version' | 'new-digest';
|
|
||||||
candidateKey: string;
|
|
||||||
displayVersion: string;
|
|
||||||
tagName: string;
|
|
||||||
digest: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function normalizeStableVersion(value?: string | null): string;
|
|
||||||
export declare function compareStableVersions(
|
|
||||||
left?: string | null,
|
|
||||||
right?: string | null,
|
|
||||||
): number | null;
|
|
||||||
export declare function isSameImageTarget(
|
|
||||||
current: UpdateHelperRuntimeLike | null | undefined,
|
|
||||||
target: { tag?: string | null; digest?: string | null },
|
|
||||||
): boolean;
|
|
||||||
export declare function buildUpdateReminderCandidateKey(
|
|
||||||
source: 'github-release' | 'docker-hub-tag',
|
|
||||||
candidate: { tagName?: string | null; digest?: string | null },
|
|
||||||
): string;
|
|
||||||
export declare function resolveUpdateReminderCandidate(input: {
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
|
||||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateReminderCandidate | null;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export {
|
|
||||||
buildUpdateReminderCandidateKey,
|
|
||||||
compareStableVersions,
|
|
||||||
isSameImageTarget,
|
|
||||||
normalizeStableVersion,
|
|
||||||
resolveUpdateReminderCandidate,
|
|
||||||
} from '../server/shared/updateCenterReminder.js';
|
|
||||||
@@ -6,7 +6,7 @@ export {
|
|||||||
stripCommonWrappers,
|
stripCommonWrappers,
|
||||||
type BrandInfo,
|
type BrandInfo,
|
||||||
type BrandMatchContext,
|
type BrandMatchContext,
|
||||||
} from '../../server/shared/modelBrand.js';
|
} from '../../shared/modelBrand.js';
|
||||||
|
|
||||||
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
||||||
anthropic: 'claude-color',
|
anthropic: 'claude-color',
|
||||||
|
|||||||
+187
-39
@@ -1,9 +1,13 @@
|
|||||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
|
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
|
||||||
import type { SessionAudience } from '../authSession.js';
|
import type { SessionAudience } from '../authSession.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
const AUTO_PREVIEW_LIMIT_BYTES = 8 * 1024 * 1024;
|
||||||
|
const AUTO_PREVIEW_COUNT = 12;
|
||||||
|
|
||||||
const SOURCE_OPTIONS = [
|
const SOURCE_OPTIONS = [
|
||||||
{ value: 'all', label: '全部来源' },
|
{ value: 'all', label: '全部来源' },
|
||||||
{ value: 'picture-agent', label: '生图助手' },
|
{ value: 'picture-agent', label: '生图助手' },
|
||||||
@@ -51,6 +55,23 @@ function assetCreatedAtLabel(asset: WorkspaceAsset) {
|
|||||||
return formatDateTimeLocal(asset.createdAt);
|
return formatDateTimeLocal(asset.createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileEndpoint(audience: SessionAudience, publicId: string) {
|
||||||
|
return `${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(publicId)}/file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAutoPreview(asset: WorkspaceAsset) {
|
||||||
|
return Boolean(
|
||||||
|
asset.hasLocalFile
|
||||||
|
&& asset.kind === 'image'
|
||||||
|
&& Number(asset.sizeBytes || 0) > 0
|
||||||
|
&& Number(asset.sizeBytes || 0) <= AUTO_PREVIEW_LIMIT_BYTES,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRenderPreview(asset: WorkspaceAsset) {
|
||||||
|
return asset.kind === 'image' || asset.kind === 'video' || asset.kind === 'audio';
|
||||||
|
}
|
||||||
|
|
||||||
export default function Workspace() {
|
export default function Workspace() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
@@ -62,7 +83,11 @@ export default function Workspace() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
|
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
|
||||||
|
const [previewLoading, setPreviewLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [loadedLocalPreviews, setLoadedLocalPreviews] = useState<Set<string>>(() => new Set());
|
||||||
const [selectedId, setSelectedId] = useState('');
|
const [selectedId, setSelectedId] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const objectUrlsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const selected = items.find((item) => item.publicId === selectedId) || items[0] || null;
|
const selected = items.find((item) => item.publicId === selectedId) || items[0] || null;
|
||||||
const sourceStats = useMemo(() => {
|
const sourceStats = useMemo(() => {
|
||||||
@@ -78,9 +103,13 @@ export default function Workspace() {
|
|||||||
source: source === 'all' ? undefined : source,
|
source: source === 'all' ? undefined : source,
|
||||||
kind: kind === 'all' ? undefined : kind,
|
kind: kind === 'all' ? undefined : kind,
|
||||||
q: query.trim() || undefined,
|
q: query.trim() || undefined,
|
||||||
page: 1,
|
page,
|
||||||
pageSize: 60,
|
pageSize: PAGE_SIZE,
|
||||||
}), [kind, query, source]);
|
}), [kind, page, query, source]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [kind, query, source]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -89,11 +118,13 @@ export default function Workspace() {
|
|||||||
const response = audience === 'user'
|
const response = audience === 'user'
|
||||||
? await userApi.getWorkspaceAssets(params)
|
? await userApi.getWorkspaceAssets(params)
|
||||||
: await api.getWorkspaceAssets(params);
|
: await api.getWorkspaceAssets(params);
|
||||||
setItems(Array.isArray(response.items) ? response.items : []);
|
const nextItems = Array.isArray(response.items) ? response.items : [];
|
||||||
|
setItems((current) => (params.page && params.page > 1 ? [...current, ...nextItems] : nextItems));
|
||||||
setTotal(Number(response.total || 0));
|
setTotal(Number(response.total || 0));
|
||||||
setSelectedId((current) => {
|
setSelectedId((current) => {
|
||||||
if (current && response.items?.some((item) => item.publicId === current)) return current;
|
if (params.page && params.page > 1 && current) return current;
|
||||||
return response.items?.[0]?.publicId || '';
|
if (current && nextItems.some((item) => item.publicId === current)) return current;
|
||||||
|
return nextItems[0]?.publicId || '';
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '工作空间加载失败');
|
setError(err?.message || '工作空间加载失败');
|
||||||
@@ -106,38 +137,76 @@ export default function Workspace() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
objectUrlsRef.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const objectUrls: string[] = [];
|
|
||||||
async function hydratePreviews() {
|
async function hydratePreviews() {
|
||||||
const candidates = items
|
const candidates = items
|
||||||
.filter((item) => item.hasLocalFile && (item.kind === 'image' || item.kind === 'video' || item.kind === 'audio'))
|
.slice(0, AUTO_PREVIEW_COUNT)
|
||||||
.slice(0, 24);
|
.filter((item) => canAutoPreview(item) && !previewUrls[item.publicId])
|
||||||
|
.slice(0, AUTO_PREVIEW_COUNT);
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
await Promise.all(candidates.map(async (asset) => {
|
for (const asset of candidates) {
|
||||||
|
if (cancelled) break;
|
||||||
try {
|
try {
|
||||||
const response = await fetchSessionResponse(
|
const response = await fetchSessionResponse(
|
||||||
audience,
|
audience,
|
||||||
`${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`,
|
fileEndpoint(audience, asset.publicId),
|
||||||
{ timeoutMs: 30_000 },
|
{ timeoutMs: 15_000 },
|
||||||
);
|
);
|
||||||
if (!response.ok) return;
|
if (!response.ok) continue;
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (cancelled) break;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
objectUrls.push(url);
|
objectUrlsRef.current.add(url);
|
||||||
next[asset.publicId] = url;
|
next[asset.publicId] = url;
|
||||||
} catch {
|
} catch {
|
||||||
// Preview hydration is best-effort; the asset row still remains usable.
|
// Preview hydration is best-effort; the asset row still remains usable.
|
||||||
}
|
}
|
||||||
}));
|
}
|
||||||
if (!cancelled) setPreviewUrls(next);
|
if (!cancelled && Object.keys(next).length > 0) {
|
||||||
|
setPreviewUrls((current) => ({ ...current, ...next }));
|
||||||
|
setLoadedLocalPreviews((current) => new Set([...current, ...Object.keys(next)]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
void hydratePreviews();
|
void hydratePreviews();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
||||||
};
|
};
|
||||||
}, [audience, items]);
|
}, [audience, items, previewUrls]);
|
||||||
|
|
||||||
|
const loadPreview = useCallback(async (asset: WorkspaceAsset) => {
|
||||||
|
if (!asset.hasLocalFile || !canRenderPreview(asset)) return;
|
||||||
|
setPreviewLoading((current) => ({ ...current, [asset.publicId]: true }));
|
||||||
|
try {
|
||||||
|
const response = await fetchSessionResponse(
|
||||||
|
audience,
|
||||||
|
fileEndpoint(audience, asset.publicId),
|
||||||
|
{ timeoutMs: 120_000 },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error('预览加载失败');
|
||||||
|
const blob = await response.blob();
|
||||||
|
const oldUrl = previewUrls[asset.publicId];
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
objectUrlsRef.current.add(url);
|
||||||
|
setPreviewUrls((current) => ({ ...current, [asset.publicId]: url }));
|
||||||
|
setLoadedLocalPreviews((current) => new Set(current).add(asset.publicId));
|
||||||
|
if (oldUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(oldUrl);
|
||||||
|
objectUrlsRef.current.delete(oldUrl);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '预览加载失败');
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading((current) => ({ ...current, [asset.publicId]: false }));
|
||||||
|
}
|
||||||
|
}, [audience, previewUrls]);
|
||||||
|
|
||||||
const handleDelete = async (asset: WorkspaceAsset) => {
|
const handleDelete = async (asset: WorkspaceAsset) => {
|
||||||
if (!window.confirm(`删除「${assetTitle(asset)}」?`)) return;
|
if (!window.confirm(`删除「${assetTitle(asset)}」?`)) return;
|
||||||
@@ -154,8 +223,18 @@ export default function Workspace() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (asset: WorkspaceAsset) => {
|
const handleDownload = async (asset: WorkspaceAsset) => {
|
||||||
const previewUrl = previewUrls[asset.publicId];
|
try {
|
||||||
const href = previewUrl || asset.remoteUrl || '';
|
let href = asset.remoteUrl || '';
|
||||||
|
if (asset.hasLocalFile) {
|
||||||
|
const response = await fetchSessionResponse(
|
||||||
|
audience,
|
||||||
|
fileEndpoint(audience, asset.publicId),
|
||||||
|
{ timeoutMs: 180_000 },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error('下载失败');
|
||||||
|
const blob = await response.blob();
|
||||||
|
href = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
if (!href) return;
|
if (!href) return;
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = href;
|
link.href = href;
|
||||||
@@ -164,8 +243,40 @@ export default function Workspace() {
|
|||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
|
if (href.startsWith('blob:')) {
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(href), 30_000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '下载失败');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderPreview = (asset: WorkspaceAsset, compact = false) => {
|
||||||
|
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
||||||
|
const isLargeLocal = Boolean(asset.hasLocalFile && Number(asset.sizeBytes || 0) > AUTO_PREVIEW_LIMIT_BYTES);
|
||||||
|
if (asset.kind === 'image' && preview) {
|
||||||
|
return <img src={preview} alt="" style={mediaStyle} loading="lazy" />;
|
||||||
|
}
|
||||||
|
if (asset.kind === 'video' && preview) {
|
||||||
|
return <video src={preview} style={mediaStyle} muted controls={!compact} preload="metadata" />;
|
||||||
|
}
|
||||||
|
if (asset.kind === 'audio' && preview) {
|
||||||
|
return compact ? <div style={placeholderStyle}>音频</div> : <audio src={preview} controls style={{ width: '100%' }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={placeholderStyle}>
|
||||||
|
<span>{kindLabel(asset.kind)}</span>
|
||||||
|
{asset.hasLocalFile && canRenderPreview(asset) ? (
|
||||||
|
<span style={placeholderHintStyle}>
|
||||||
|
{isLargeLocal ? '文件较大,按需预览' : '等待预览'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMore = items.length < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -257,16 +368,11 @@ export default function Workspace() {
|
|||||||
onClick={() => setSelectedId(asset.publicId)}
|
onClick={() => setSelectedId(asset.publicId)}
|
||||||
>
|
>
|
||||||
<div style={thumbStyle}>
|
<div style={thumbStyle}>
|
||||||
{asset.kind === 'image' && preview ? (
|
{renderPreview(asset, true)}
|
||||||
<img src={preview} alt="" style={mediaStyle} />
|
|
||||||
) : asset.kind === 'video' && preview ? (
|
|
||||||
<video src={preview} style={mediaStyle} muted />
|
|
||||||
) : asset.kind === 'audio' ? (
|
|
||||||
<div style={placeholderStyle}>音频</div>
|
|
||||||
) : (
|
|
||||||
<div style={placeholderStyle}>{kindLabel(asset.kind)}</div>
|
|
||||||
)}
|
|
||||||
<span style={typeBadgeStyle}>{kindLabel(asset.kind)}</span>
|
<span style={typeBadgeStyle}>{kindLabel(asset.kind)}</span>
|
||||||
|
{asset.hasLocalFile && canRenderPreview(asset) && !preview && (
|
||||||
|
<span style={previewBadgeStyle}>{formatBytes(asset.sizeBytes)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={cardBodyStyle}>
|
<div style={cardBodyStyle}>
|
||||||
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
|
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
|
||||||
@@ -280,6 +386,18 @@ export default function Workspace() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{hasMore && (
|
||||||
|
<div style={loadMoreStyle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setPage((current) => current + 1)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? '加载中...' : `加载更多(${items.length}/${total})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside style={detailStyle}>
|
<aside style={detailStyle}>
|
||||||
@@ -293,16 +411,19 @@ export default function Workspace() {
|
|||||||
<span style={statusPillStyle}>{statusLabel(selected.status)}</span>
|
<span style={statusPillStyle}>{statusLabel(selected.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={detailPreviewStyle}>
|
<div style={detailPreviewStyle}>
|
||||||
{selected.kind === 'image' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
|
{renderPreview(selected)}
|
||||||
<img src={previewUrls[selected.publicId] || selected.remoteUrl || ''} alt="" style={mediaStyle} />
|
|
||||||
) : selected.kind === 'video' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
|
|
||||||
<video src={previewUrls[selected.publicId] || selected.remoteUrl || ''} controls style={mediaStyle} />
|
|
||||||
) : selected.kind === 'audio' && previewUrls[selected.publicId] ? (
|
|
||||||
<audio src={previewUrls[selected.publicId]} controls style={{ width: '100%' }} />
|
|
||||||
) : (
|
|
||||||
<div style={placeholderStyle}>{kindLabel(selected.kind)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{selected.hasLocalFile && canRenderPreview(selected) && !loadedLocalPreviews.has(selected.publicId) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ width: '100%', marginTop: 10 }}
|
||||||
|
onClick={() => void loadPreview(selected)}
|
||||||
|
disabled={Boolean(previewLoading[selected.publicId])}
|
||||||
|
>
|
||||||
|
{previewLoading[selected.publicId] ? '预览加载中...' : `加载预览(${formatBytes(selected.sizeBytes)})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div style={detailMetaStyle}>
|
<div style={detailMetaStyle}>
|
||||||
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
|
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
|
||||||
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
|
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
|
||||||
@@ -402,13 +523,22 @@ const placeholderStyle: CSSProperties = {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
color: 'var(--color-text-muted)',
|
color: 'var(--color-text-muted)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
background: 'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), var(--color-bg-card))',
|
background: 'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), var(--color-bg-card))',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const placeholderHintStyle: CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
};
|
||||||
|
|
||||||
const cardBodyStyle: CSSProperties = {
|
const cardBodyStyle: CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -535,6 +665,24 @@ const typeBadgeStyle: CSSProperties = {
|
|||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const previewBadgeStyle: CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 10,
|
||||||
|
bottom: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 18,
|
||||||
|
};
|
||||||
|
|
||||||
const cardFooterStyle: CSSProperties = {
|
const cardFooterStyle: CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -159,8 +159,7 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | '
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
|
// Keep this in sync with the Rust site endpoint normalization in admin account routes.
|
||||||
// src/server/services/siteApiEndpointService.ts.
|
|
||||||
function normalizeSiteApiEndpointUrl(raw: string): string {
|
function normalizeSiteApiEndpointUrl(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return '';
|
if (!trimmed) return '';
|
||||||
|
|||||||
+27
-12
@@ -317,9 +317,7 @@ async fn execute_node(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| inputs.get("image").cloned())
|
.or_else(|| inputs.get("image").cloned())
|
||||||
.or_else(|| inputs.get("IMAGE").cloned())
|
.or_else(|| inputs.get("IMAGE").cloned())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| AppError::BadRequest(format!("{} missing images input", node.id)))?;
|
||||||
AppError::BadRequest(format!("{} missing images input", node.id))
|
|
||||||
})?;
|
|
||||||
Ok(NodeExecution {
|
Ok(NodeExecution {
|
||||||
inputs,
|
inputs,
|
||||||
outputs: json!({ "IMAGE": image, "image": image }),
|
outputs: json!({ "IMAGE": image, "image": image }),
|
||||||
@@ -354,7 +352,12 @@ async fn execute_node(
|
|||||||
.get("file")
|
.get("file")
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| inputs.get("image").cloned())
|
.or_else(|| inputs.get("image").cloned())
|
||||||
.or_else(|| inputs.get("images").and_then(|images| images.get(0)).cloned())
|
.or_else(|| {
|
||||||
|
inputs
|
||||||
|
.get("images")
|
||||||
|
.and_then(|images| images.get(0))
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing file input", node.id)))?;
|
.ok_or_else(|| AppError::BadRequest(format!("{} missing file input", node.id)))?;
|
||||||
Ok(NodeExecution {
|
Ok(NodeExecution {
|
||||||
inputs,
|
inputs,
|
||||||
@@ -643,7 +646,10 @@ async fn video_status(
|
|||||||
|
|
||||||
fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<String>) -> Value {
|
fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<String>) -> Value {
|
||||||
let mut body = Map::new();
|
let mut body = Map::new();
|
||||||
body.insert("model".to_string(), json!(param_str(node, "model", "seedance-1-0-lite-t2v")));
|
body.insert(
|
||||||
|
"model".to_string(),
|
||||||
|
json!(param_str(node, "model", "seedance-1-0-lite-t2v")),
|
||||||
|
);
|
||||||
body.insert("prompt".to_string(), json!(prompt));
|
body.insert("prompt".to_string(), json!(prompt));
|
||||||
for key in ["ratio", "resolution"] {
|
for key in ["ratio", "resolution"] {
|
||||||
let value = param_str(node, key, "");
|
let value = param_str(node, key, "");
|
||||||
@@ -776,10 +782,7 @@ async fn vae_decode_to_image(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.or_else(|| vae.get("model").and_then(Value::as_str))
|
.or_else(|| vae.get("model").and_then(Value::as_str))
|
||||||
.unwrap_or("gpt-image-2");
|
.unwrap_or("gpt-image-2");
|
||||||
let width = samples
|
let width = samples.get("width").and_then(Value::as_i64).unwrap_or(1024);
|
||||||
.get("width")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.unwrap_or(1024);
|
|
||||||
let height = samples
|
let height = samples
|
||||||
.get("height")
|
.get("height")
|
||||||
.and_then(Value::as_i64)
|
.and_then(Value::as_i64)
|
||||||
@@ -895,7 +898,14 @@ async fn get_json(ctx: &RunContext, path: &str) -> AppResult<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_task_id(value: &Value) -> Value {
|
fn extract_task_id(value: &Value) -> Value {
|
||||||
for pointer in ["/id", "/task_id", "/data/id", "/data/task_id", "/result/id", "/result/task_id"] {
|
for pointer in [
|
||||||
|
"/id",
|
||||||
|
"/task_id",
|
||||||
|
"/data/id",
|
||||||
|
"/data/task_id",
|
||||||
|
"/result/id",
|
||||||
|
"/result/task_id",
|
||||||
|
] {
|
||||||
if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
|
if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
|
||||||
if !id.trim().is_empty() {
|
if !id.trim().is_empty() {
|
||||||
return json!(id);
|
return json!(id);
|
||||||
@@ -927,7 +937,10 @@ fn video_task_is_terminal_failure(value: &Value) -> bool {
|
|||||||
|
|
||||||
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
||||||
let value = value?;
|
let value = value?;
|
||||||
find_url_key(value, &["url", "remote_url", "remoteUrl", "image_url", "imageUrl"])
|
find_url_key(
|
||||||
|
value,
|
||||||
|
&["url", "remote_url", "remoteUrl", "image_url", "imageUrl"],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_string_key(value: &Value, keys: &[&str]) -> Option<String> {
|
fn find_string_key(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
@@ -972,7 +985,9 @@ fn find_url_by_extension(value: &Value, extensions: &[&str]) -> Option<String> {
|
|||||||
match value {
|
match value {
|
||||||
Value::String(text)
|
Value::String(text)
|
||||||
if (text.starts_with("http://") || text.starts_with("https://"))
|
if (text.starts_with("http://") || text.starts_with("https://"))
|
||||||
&& extensions.iter().any(|ext| text.to_ascii_lowercase().contains(ext)) =>
|
&& extensions
|
||||||
|
.iter()
|
||||||
|
.any(|ext| text.to_ascii_lowercase().contains(ext)) =>
|
||||||
{
|
{
|
||||||
Some(text.clone())
|
Some(text.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-7
@@ -13,13 +13,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true
|
||||||
"paths": {
|
|
||||||
"@db/*": ["./src/server/db/*"],
|
|
||||||
"@services/*": ["./src/server/services/*"],
|
|
||||||
"@routes/*": ["./src/server/routes/*"],
|
|
||||||
"@middleware/*": ["./src/server/middleware/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": false,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"declaration": false,
|
|
||||||
"sourceMap": false,
|
|
||||||
"outDir": "/tmp/tsc_out",
|
|
||||||
"rootDir": "/root/metapi",
|
|
||||||
"allowJs": false,
|
|
||||||
"removeComments": false
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/server/services/shared/apiKeyBatchCore.ts",
|
|
||||||
"src/server/shared/modelBrand.ts",
|
|
||||||
"src/server/shared/updateCenterReminder.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user