mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
1 Commits
ci/biome-e
...
chore/bump
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f900255c9 |
5
.github/renovate.json
vendored
5
.github/renovate.json
vendored
@@ -33,11 +33,6 @@
|
|||||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||||
"groupName": "Core framework packages",
|
"groupName": "Core framework packages",
|
||||||
"automerge": false
|
"automerge": false
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchPackageNames": ["@biomejs/biome"],
|
|
||||||
"groupName": "Biome",
|
|
||||||
"automerge": false
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
|
|||||||
4
.github/workflows/auto-format.yml
vendored
4
.github/workflows/auto-format.yml
vendored
@@ -23,9 +23,7 @@ jobs:
|
|||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
- name: Run Biome format
|
- name: Run Biome format
|
||||||
# Pin to the version in package.json so CI matches local/pre-commit
|
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
|
||||||
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
|
||||||
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
|
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
id: changes
|
id: changes
|
||||||
|
|||||||
3
.github/workflows/docker-build.yml
vendored
3
.github/workflows/docker-build.yml
vendored
@@ -58,8 +58,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
provenance: mode=max
|
|
||||||
sbom: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
@@ -91,3 +89,4 @@ jobs:
|
|||||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||||
|
|
||||||
|
|||||||
67
.github/workflows/publish-mcp.yml
vendored
67
.github/workflows/publish-mcp.yml
vendored
@@ -1,67 +0,0 @@
|
|||||||
name: Publish MCP Server
|
|
||||||
|
|
||||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
|
||||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
|
||||||
# skips silently if the package.json version is already on npm — so a
|
|
||||||
# release is just "bump the version in a PR and merge".
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "packages/mcp-server/**"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write # OIDC token for npm trusted publishing
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: publish-mcp
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: packages/mcp-server
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
|
|
||||||
# Trusted publishing requires npm >= 11.5.1
|
|
||||||
- name: Update npm
|
|
||||||
run: npm install -g npm@latest
|
|
||||||
|
|
||||||
- name: Check if version is already published
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
LOCAL=$(node -p "require('./package.json').version")
|
|
||||||
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
|
|
||||||
echo "Version ${LOCAL} already on npm - nothing to publish"
|
|
||||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "Version ${LOCAL} not on npm - publishing"
|
|
||||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
if: steps.version.outputs.publish == 'true'
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
if: steps.version.outputs.publish == 'true'
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Publish to npm
|
|
||||||
if: steps.version.outputs.publish == 'true'
|
|
||||||
run: npm publish
|
|
||||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -28,16 +28,6 @@ jobs:
|
|||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm run test -- --run
|
run: npm run test -- --run
|
||||||
|
|
||||||
# The MCP server package ships its own vitest because its DOM polyfill
|
|
||||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
|
||||||
# for the Next.js app. Install + run its tests separately so CI catches
|
|
||||||
# multi-page mxfile regressions.
|
|
||||||
- name: Install MCP server dependencies
|
|
||||||
run: npm --prefix packages/mcp-server ci
|
|
||||||
|
|
||||||
- name: Run MCP server unit tests
|
|
||||||
run: npm --prefix packages/mcp-server test
|
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -76,6 +76,3 @@ ai-models.json
|
|||||||
# local backups
|
# local backups
|
||||||
*.bak
|
*.bak
|
||||||
.gstack/
|
.gstack/
|
||||||
|
|
||||||
# admin panel settings (contains secrets)
|
|
||||||
data/
|
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ COPY --from=builder /app/public ./public
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
# Writable dir for admin panel settings (data/settings.json)
|
|
||||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -43,8 +43,6 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Deploy on Vercel](#deploy-on-vercel)
|
- [Deploy on Vercel](#deploy-on-vercel)
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||||
- [Multi-Provider Support](#multi-provider-support)
|
- [Multi-Provider Support](#multi-provider-support)
|
||||||
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
|
|
||||||
- [Admin Panel](#admin-panel)
|
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
- [Support \& Contact](#support--contact)
|
- [Support \& Contact](#support--contact)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
@@ -211,7 +209,6 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
@@ -225,13 +222,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
|||||||
|
|
||||||
### Server-Side Multi-Model Configuration
|
### Server-Side Multi-Model Configuration
|
||||||
|
|
||||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
|
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
|
||||||
|
|
||||||
### Admin Panel
|
|
||||||
|
|
||||||
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
|
|
||||||
|
|
||||||
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
|
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { getApiEndpoint } from "@/lib/base-path"
|
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
|
||||||
|
|
||||||
// ── Shared types ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type SecretValue = { isSet: true; hint: string }
|
|
||||||
|
|
||||||
export function isSecretValue(v: unknown): v is SecretValue {
|
|
||||||
return typeof v === "object" && v !== null && "isSet" in v
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingState {
|
|
||||||
key: string
|
|
||||||
source: "file" | "env" | "default"
|
|
||||||
value: string | SecretValue | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SettingsMap = Record<string, SettingState>
|
|
||||||
|
|
||||||
// Editable text of a saved setting; secrets have none (write-only)
|
|
||||||
export function savedTextOf(state: SettingState | undefined): string {
|
|
||||||
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin provider in client state. Secret fields hold either a masked
|
|
||||||
// marker (unchanged) or a plaintext string (new value).
|
|
||||||
export interface AdminProvider {
|
|
||||||
id: string
|
|
||||||
provider: ProviderName
|
|
||||||
name?: string
|
|
||||||
apiKey?: string | SecretValue
|
|
||||||
baseUrl?: string
|
|
||||||
awsAccessKeyId?: string | SecretValue
|
|
||||||
awsSecretAccessKey?: string | SecretValue
|
|
||||||
awsRegion?: string
|
|
||||||
vertexApiKey?: string | SecretValue
|
|
||||||
models: string[]
|
|
||||||
isDefault?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
|
||||||
export interface EnvProvider {
|
|
||||||
name: string
|
|
||||||
provider: ProviderName
|
|
||||||
models: string[]
|
|
||||||
isDefault: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
|
|
||||||
const res = await fetch(getApiEndpoint(path), {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
...init?.headers,
|
|
||||||
"x-admin-password": pw,
|
|
||||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || `Request failed (${res.status})`)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -1,609 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Check,
|
|
||||||
Loader2,
|
|
||||||
Plus,
|
|
||||||
Star,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
|
|
||||||
import { ProviderLogo } from "@/components/provider-logo"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
|
||||||
import {
|
|
||||||
FIXED_CRED_PROVIDERS,
|
|
||||||
PROVIDER_INFO,
|
|
||||||
type ProviderName,
|
|
||||||
SUGGESTED_MODELS,
|
|
||||||
} from "@/lib/types/model-config"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
type AdminProvider,
|
|
||||||
adminFetch,
|
|
||||||
type EnvProvider,
|
|
||||||
} from "./admin-shared"
|
|
||||||
import { SecretInput } from "./setting-field"
|
|
||||||
|
|
||||||
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
|
|
||||||
|
|
||||||
function ProviderDetail({
|
|
||||||
provider,
|
|
||||||
disabled,
|
|
||||||
password,
|
|
||||||
onUpdate,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
provider: AdminProvider
|
|
||||||
disabled: boolean
|
|
||||||
password: string
|
|
||||||
onUpdate: (patch: Partial<AdminProvider>) => void
|
|
||||||
onDelete: () => void
|
|
||||||
}) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [modelInput, setModelInput] = useState("")
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
||||||
const [testing, setTesting] = useState<string | null>(null)
|
|
||||||
const [testResults, setTestResults] = useState<
|
|
||||||
Record<string, { ok: boolean; message: string }>
|
|
||||||
>({})
|
|
||||||
|
|
||||||
const info = PROVIDER_INFO[provider.provider]
|
|
||||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
|
||||||
(m) => !provider.models.includes(m),
|
|
||||||
)
|
|
||||||
|
|
||||||
const addModel = (modelId: string) => {
|
|
||||||
const trimmed = modelId.trim()
|
|
||||||
if (!trimmed || provider.models.includes(trimmed)) return
|
|
||||||
onUpdate({ models: [...provider.models, trimmed] })
|
|
||||||
setModelInput("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const testModel = async (modelId: string) => {
|
|
||||||
setTesting(modelId)
|
|
||||||
try {
|
|
||||||
const data = await adminFetch("/api/admin/test-model", password, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ provider, modelId }),
|
|
||||||
})
|
|
||||||
setTestResults((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[modelId]: data.valid
|
|
||||||
? {
|
|
||||||
ok: true,
|
|
||||||
message: formatMessage(dict.admin.testOk, {
|
|
||||||
ms: data.responseTime,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
ok: false,
|
|
||||||
message: data.error || dict.admin.testFailed,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
} catch (err) {
|
|
||||||
setTestResults((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[modelId]: {
|
|
||||||
ok: false,
|
|
||||||
message:
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: dict.admin.testFailed,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
} finally {
|
|
||||||
setTesting(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
|
||||||
<ProviderLogo
|
|
||||||
provider={provider.provider}
|
|
||||||
className="size-5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-semibold">{info.label}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{provider.models.length === 0
|
|
||||||
? dict.admin.noModelsConfigured
|
|
||||||
: formatMessage(
|
|
||||||
provider.models.length === 1
|
|
||||||
? dict.admin.modelCount
|
|
||||||
: dict.admin.modelCountPlural,
|
|
||||||
{ count: provider.models.length },
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<Star
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
provider.isDefault &&
|
|
||||||
"fill-amber-400 text-amber-400",
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.admin.default}
|
|
||||||
<Switch
|
|
||||||
checked={!!provider.isDefault}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={dict.admin.setAsDefault}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onUpdate({ isDefault: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
|
||||||
{dict.admin.delete}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Credentials (shared with the user ModelConfigDialog) */}
|
|
||||||
<ProviderCredentialsFields
|
|
||||||
provider={provider.provider}
|
|
||||||
name={provider.name}
|
|
||||||
baseUrl={provider.baseUrl}
|
|
||||||
awsRegion={provider.awsRegion}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(field, value) => onUpdate({ [field]: value })}
|
|
||||||
renderSecret={({ field, id }) => (
|
|
||||||
// Bare id keeps the shared component's <Label htmlFor={id}>
|
|
||||||
// associated; only one ProviderDetail is mounted at a time.
|
|
||||||
<SecretInput
|
|
||||||
id={id}
|
|
||||||
keepOnEmpty
|
|
||||||
value={provider[field]}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(v) => onUpdate({ [field]: v })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Models */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
||||||
{dict.admin.models}
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Input
|
|
||||||
value={modelInput}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={dict.admin.modelIdPlaceholder}
|
|
||||||
spellCheck={false}
|
|
||||||
className="h-8 w-48 font-mono text-xs"
|
|
||||||
onChange={(e) => setModelInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") addModel(modelInput)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
disabled={disabled || !modelInput.trim()}
|
|
||||||
aria-label={dict.admin.addModel}
|
|
||||||
onClick={() => addModel(modelInput)}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
{suggestions.length > 0 && (
|
|
||||||
<Select
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={(v) => addModel(v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-28 text-xs">
|
|
||||||
{dict.admin.suggested}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-72">
|
|
||||||
{suggestions.map((m) => (
|
|
||||||
<SelectItem
|
|
||||||
key={m}
|
|
||||||
value={m}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-lg border">
|
|
||||||
{provider.models.length === 0 ? (
|
|
||||||
<p className="p-5 text-center text-sm text-muted-foreground">
|
|
||||||
{dict.admin.addProviderToOfferModels}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y">
|
|
||||||
{provider.models.map((modelId, index) => {
|
|
||||||
const result = testResults[modelId]
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={modelId}
|
|
||||||
className="flex items-center gap-2 px-3 py-2"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
|
||||||
{modelId}
|
|
||||||
{provider.isDefault &&
|
|
||||||
index === 0 && (
|
|
||||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
|
||||||
{
|
|
||||||
dict.admin
|
|
||||||
.defaultModel
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{result && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1 text-xs",
|
|
||||||
result.ok
|
|
||||||
? "text-green-600 dark:text-green-400"
|
|
||||||
: "text-destructive",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{result.ok ? (
|
|
||||||
<Check
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AlertCircle
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="max-w-48 truncate">
|
|
||||||
{result.message}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
disabled={
|
|
||||||
disabled || testing !== null
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void testModel(modelId)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{testing === modelId ? (
|
|
||||||
<Loader2
|
|
||||||
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Zap
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="ml-1">
|
|
||||||
{dict.admin.test}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={formatMessage(
|
|
||||||
dict.admin.removeModel,
|
|
||||||
{ model: modelId },
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
onUpdate({
|
|
||||||
models: provider.models.filter(
|
|
||||||
(m) => m !== modelId,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{formatMessage(dict.admin.deleteProviderTitle, {
|
|
||||||
name: provider.name || info.label,
|
|
||||||
})}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{dict.admin.deleteProviderDesc}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{dict.admin.cancel}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteOpen(false)
|
|
||||||
onDelete()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.admin.delete}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelsSection({
|
|
||||||
providers,
|
|
||||||
envProviders,
|
|
||||||
disabled,
|
|
||||||
password,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
providers: AdminProvider[]
|
|
||||||
envProviders: EnvProvider[]
|
|
||||||
disabled: boolean
|
|
||||||
password: string
|
|
||||||
onChange: (providers: AdminProvider[]) => void
|
|
||||||
}) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(
|
|
||||||
providers[0]?.id ?? null,
|
|
||||||
)
|
|
||||||
const selected = providers.find((p) => p.id === selectedId)
|
|
||||||
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
|
|
||||||
|
|
||||||
const addProvider = (provider: ProviderName) => {
|
|
||||||
const newProvider: AdminProvider = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
provider,
|
|
||||||
models: [],
|
|
||||||
isDefault: providers.length === 0,
|
|
||||||
}
|
|
||||||
onChange([...providers, newProvider])
|
|
||||||
setSelectedId(newProvider.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
|
|
||||||
onChange(
|
|
||||||
providers.map((p) => {
|
|
||||||
if (p.id !== id) {
|
|
||||||
// Only one default at a time
|
|
||||||
return patch.isDefault ? { ...p, isDefault: false } : p
|
|
||||||
}
|
|
||||||
return { ...p, ...patch }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteProvider = (id: string) => {
|
|
||||||
const next = providers.filter((p) => p.id !== id)
|
|
||||||
onChange(next)
|
|
||||||
setSelectedId(next[0]?.id ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-72 flex-col sm:flex-row">
|
|
||||||
{/* Provider list */}
|
|
||||||
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
|
|
||||||
<div className="flex-1 space-y-1 p-2">
|
|
||||||
{providers.length === 0 && envProviders.length === 0 && (
|
|
||||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
|
||||||
{dict.admin.addProviderHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{envProviders.map((p) => (
|
|
||||||
<button
|
|
||||||
key={`env:${p.name}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedId(`env:${p.name}`)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
selectedId === `env:${p.name}` &&
|
|
||||||
"bg-muted font-medium",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ProviderLogo provider={p.provider} />
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
|
||||||
{p.name}
|
|
||||||
</span>
|
|
||||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
|
||||||
{dict.admin.sourceEnv}
|
|
||||||
</span>
|
|
||||||
{p.isDefault && (
|
|
||||||
<Star
|
|
||||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
|
||||||
aria-label={dict.admin.defaultProvider}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{providers.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedId(p.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
selectedId === p.id && "bg-muted font-medium",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ProviderLogo provider={p.provider} />
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
|
||||||
{p.name || PROVIDER_INFO[p.provider].label}
|
|
||||||
</span>
|
|
||||||
{p.isDefault && (
|
|
||||||
<Star
|
|
||||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
|
||||||
aria-label={dict.admin.defaultProvider}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="border-t p-2">
|
|
||||||
<Select
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<Plus
|
|
||||||
className="mr-1 h-4 w-4 text-muted-foreground"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.modelConfig.addProvider}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-72">
|
|
||||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
|
||||||
(p) => {
|
|
||||||
// Global-credential providers already in
|
|
||||||
// the env config can't be added here —
|
|
||||||
// panel credentials would override theirs
|
|
||||||
const envBlocked =
|
|
||||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
|
||||||
envProviders.some(
|
|
||||||
(e) => e.provider === p,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<SelectItem
|
|
||||||
key={p}
|
|
||||||
value={p}
|
|
||||||
disabled={envBlocked}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderLogo provider={p} />
|
|
||||||
{PROVIDER_INFO[p].label}
|
|
||||||
{envBlocked && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{
|
|
||||||
dict.admin
|
|
||||||
.managedViaEnv
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detail */}
|
|
||||||
<div className="min-w-0 flex-1 p-4">
|
|
||||||
{selected ? (
|
|
||||||
<ProviderDetail
|
|
||||||
key={selected.id}
|
|
||||||
provider={selected}
|
|
||||||
disabled={disabled}
|
|
||||||
password={password}
|
|
||||||
onUpdate={(patch) => updateProvider(selected.id, patch)}
|
|
||||||
onDelete={() => deleteProvider(selected.id)}
|
|
||||||
/>
|
|
||||||
) : selectedEnv ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
|
||||||
<ProviderLogo
|
|
||||||
provider={selectedEnv.provider}
|
|
||||||
className="size-5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-semibold">
|
|
||||||
{selectedEnv.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.admin.envReadOnly}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-lg border">
|
|
||||||
<ul className="divide-y">
|
|
||||||
{selectedEnv.models.map((modelId, index) => (
|
|
||||||
<li
|
|
||||||
key={modelId}
|
|
||||||
className="flex items-center gap-2 px-3 py-2"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
|
||||||
{modelId}
|
|
||||||
{selectedEnv.isDefault &&
|
|
||||||
index === 0 && (
|
|
||||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
|
||||||
{
|
|
||||||
dict.admin
|
|
||||||
.defaultModel
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
|
||||||
{dict.admin.selectProviderHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,610 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
Check,
|
|
||||||
Loader2,
|
|
||||||
LockKeyhole,
|
|
||||||
ShieldCheck,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import {
|
|
||||||
SETTING_GROUPS,
|
|
||||||
SETTINGS_BY_GROUP,
|
|
||||||
} from "@/lib/admin/settings-registry"
|
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
type AdminProvider,
|
|
||||||
adminFetch,
|
|
||||||
type EnvProvider,
|
|
||||||
isSecretValue,
|
|
||||||
SESSION_PASSWORD_KEY,
|
|
||||||
type SettingState,
|
|
||||||
type SettingsMap,
|
|
||||||
savedTextOf,
|
|
||||||
} from "./admin-shared"
|
|
||||||
import { ModelsSection } from "./models-section"
|
|
||||||
import { SettingField } from "./setting-field"
|
|
||||||
|
|
||||||
// ── Page ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
|
||||||
const dict = useDictionary()
|
|
||||||
// Localized group title/description, keyed by group id
|
|
||||||
const groupText = (id: string) =>
|
|
||||||
(
|
|
||||||
dict.admin.groups as Record<
|
|
||||||
string,
|
|
||||||
{ title: string; description: string } | undefined
|
|
||||||
>
|
|
||||||
)[id]
|
|
||||||
const navItems = NAV_GROUP_IDS.map((id) => ({
|
|
||||||
id,
|
|
||||||
title:
|
|
||||||
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
|
|
||||||
}))
|
|
||||||
const [password, setPassword] = useState("")
|
|
||||||
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
|
||||||
const [authError, setAuthError] = useState("")
|
|
||||||
const [authLoading, setAuthLoading] = useState(false)
|
|
||||||
|
|
||||||
const [writable, setWritable] = useState(true)
|
|
||||||
|
|
||||||
// Models section state
|
|
||||||
const [providers, setProviders] = useState<AdminProvider[]>([])
|
|
||||||
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
|
||||||
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
|
||||||
const providersDirty = JSON.stringify(providers) !== savedProviders
|
|
||||||
|
|
||||||
// General settings state
|
|
||||||
const [settings, setSettings] = useState<SettingsMap>({})
|
|
||||||
const [pending, setPending] = useState<Record<string, string | null>>({})
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [saveMessage, setSaveMessage] = useState<{
|
|
||||||
ok: boolean
|
|
||||||
text: string
|
|
||||||
} | null>(null)
|
|
||||||
const [activeGroup, setActiveGroup] = useState("models")
|
|
||||||
|
|
||||||
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
|
|
||||||
|
|
||||||
const applySettingsResponse = useCallback(
|
|
||||||
(data: { writable: boolean; settings: SettingState[] }) => {
|
|
||||||
setWritable(data.writable)
|
|
||||||
const map: SettingsMap = {}
|
|
||||||
for (const s of data.settings) map[s.key] = s
|
|
||||||
setSettings(map)
|
|
||||||
// Seed each toggle once from whether the group has configured
|
|
||||||
// values; don't stomp a user's explicit toggle on later saves
|
|
||||||
setEnabledGroups((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
for (const group of SETTING_GROUPS) {
|
|
||||||
if (!group.toggleable || group.id in next) continue
|
|
||||||
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
|
||||||
(d) => map[d.key]?.source !== "default",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const applyProvidersResponse = useCallback(
|
|
||||||
(data: {
|
|
||||||
providers: AdminProvider[]
|
|
||||||
envProviders?: EnvProvider[]
|
|
||||||
}) => {
|
|
||||||
setProviders(data.providers)
|
|
||||||
setSavedProviders(JSON.stringify(data.providers))
|
|
||||||
setEnvProviders(data.envProviders ?? [])
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const login = useCallback(
|
|
||||||
async (pw: string) => {
|
|
||||||
setAuthLoading(true)
|
|
||||||
setAuthError("")
|
|
||||||
try {
|
|
||||||
const [settingsData, providersData] = await Promise.all([
|
|
||||||
adminFetch("/api/admin/settings", pw),
|
|
||||||
adminFetch("/api/admin/providers", pw),
|
|
||||||
])
|
|
||||||
applySettingsResponse(settingsData)
|
|
||||||
applyProvidersResponse(providersData)
|
|
||||||
setAuthedPassword(pw)
|
|
||||||
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
|
||||||
} catch (err) {
|
|
||||||
setAuthError(
|
|
||||||
err instanceof Error ? err.message : dict.admin.loginFailed,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setAuthLoading(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[applySettingsResponse, applyProvidersResponse, dict],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Restore session on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
|
||||||
if (stored) void login(stored)
|
|
||||||
}, [login])
|
|
||||||
|
|
||||||
// Warn before leaving with unsaved changes
|
|
||||||
const hasDirty = dirtyCount > 0
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasDirty) return
|
|
||||||
const handler = (e: BeforeUnloadEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
// Some browsers only show the prompt when returnValue is set
|
|
||||||
e.returnValue = ""
|
|
||||||
}
|
|
||||||
window.addEventListener("beforeunload", handler)
|
|
||||||
return () => window.removeEventListener("beforeunload", handler)
|
|
||||||
}, [hasDirty])
|
|
||||||
|
|
||||||
// Highlight the section currently in view in the sidebar
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authedPassword) return
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
const visible = entries
|
|
||||||
.filter((e) => e.isIntersecting)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
a.boundingClientRect.top - b.boundingClientRect.top,
|
|
||||||
)
|
|
||||||
if (visible[0]) setActiveGroup(visible[0].target.id)
|
|
||||||
},
|
|
||||||
{ rootMargin: "-10% 0px -50% 0px" },
|
|
||||||
)
|
|
||||||
for (const id of NAV_GROUP_IDS) {
|
|
||||||
const el = document.getElementById(id)
|
|
||||||
if (el) observer.observe(el)
|
|
||||||
}
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [authedPassword])
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(key: string, value: string | null) => {
|
|
||||||
setSaveMessage(null)
|
|
||||||
setErrors((prev) => {
|
|
||||||
if (!(key in prev)) return prev
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[key]
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setPending((prev) => {
|
|
||||||
const state = settings[key]
|
|
||||||
const isRevert =
|
|
||||||
value !== null &&
|
|
||||||
state?.source === "file" &&
|
|
||||||
!isSecretValue(state?.value) &&
|
|
||||||
value === savedTextOf(state)
|
|
||||||
const isNoop =
|
|
||||||
value === "" &&
|
|
||||||
(!state || state.source !== "file") &&
|
|
||||||
!isSecretValue(state?.value)
|
|
||||||
if (isRevert || isNoop) {
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[key]
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return { ...prev, [key]: value === "" ? null : value }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[settings],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Toggling a group off stages deletion of its saved values so the
|
|
||||||
// feature actually turns off on save; toggling on drops those deletions.
|
|
||||||
const handleGroupToggle = useCallback(
|
|
||||||
(groupId: string, enabled: boolean) => {
|
|
||||||
setSaveMessage(null)
|
|
||||||
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
|
|
||||||
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
|
|
||||||
(d) => d.key,
|
|
||||||
)
|
|
||||||
setPending((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
for (const key of keys) {
|
|
||||||
if (!enabled) {
|
|
||||||
// Stage deletion only for values currently set
|
|
||||||
if (settings[key]?.source !== "default")
|
|
||||||
next[key] = null
|
|
||||||
} else if (next[key] === null) {
|
|
||||||
delete next[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[settings],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!authedPassword || dirtyCount === 0) return
|
|
||||||
setSaving(true)
|
|
||||||
setSaveMessage(null)
|
|
||||||
setErrors({})
|
|
||||||
try {
|
|
||||||
if (providersDirty) {
|
|
||||||
const data = await adminFetch(
|
|
||||||
"/api/admin/providers",
|
|
||||||
authedPassword,
|
|
||||||
{ method: "PUT", body: JSON.stringify({ providers }) },
|
|
||||||
)
|
|
||||||
applyProvidersResponse(data)
|
|
||||||
}
|
|
||||||
if (Object.keys(pending).length > 0) {
|
|
||||||
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-admin-password": authedPassword,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ values: pending }),
|
|
||||||
})
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok) {
|
|
||||||
// Per-field validation errors come back as {errors: {...}}
|
|
||||||
if (data.errors) {
|
|
||||||
setErrors(data.errors)
|
|
||||||
const firstKey = Object.keys(data.errors)[0]
|
|
||||||
document.getElementById(`setting-${firstKey}`)?.focus()
|
|
||||||
throw new Error(dict.admin.invalidSettings)
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
data.error || `Request failed (${res.status})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
applySettingsResponse(data)
|
|
||||||
setPending({})
|
|
||||||
}
|
|
||||||
setSaveMessage({
|
|
||||||
ok: true,
|
|
||||||
text: dict.admin.saved,
|
|
||||||
})
|
|
||||||
setTimeout(() => setSaveMessage(null), 4000)
|
|
||||||
} catch (err) {
|
|
||||||
setSaveMessage({
|
|
||||||
ok: false,
|
|
||||||
text:
|
|
||||||
err instanceof Error ? err.message : dict.admin.saveFailed,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
authedPassword,
|
|
||||||
pending,
|
|
||||||
providers,
|
|
||||||
providersDirty,
|
|
||||||
dirtyCount,
|
|
||||||
applySettingsResponse,
|
|
||||||
applyProvidersResponse,
|
|
||||||
dict,
|
|
||||||
])
|
|
||||||
|
|
||||||
// ── Login screen ─────────────────────────────────────────────────
|
|
||||||
if (!authedPassword) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
||||||
<form
|
|
||||||
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
void login(password)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<LockKeyhole
|
|
||||||
className="h-5 w-5 text-muted-foreground"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<h1 className="text-lg font-semibold">
|
|
||||||
{dict.admin.title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{dict.admin.loginPrompt}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="admin-password">
|
|
||||||
{dict.admin.password}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="admin-password"
|
|
||||||
name="admin-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
autoComplete="current-password"
|
|
||||||
spellCheck={false}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-sm text-destructive",
|
|
||||||
!authError && "sr-only",
|
|
||||||
)}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{authError}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={authLoading}
|
|
||||||
>
|
|
||||||
{authLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2
|
|
||||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.admin.signingIn}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
dict.admin.signIn
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings screen ──────────────────────────────────────────────
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ShieldCheck
|
|
||||||
className="h-5 w-5 text-primary"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<h1 className="text-lg font-semibold">
|
|
||||||
{dict.admin.title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.admin.precedence}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{!writable && (
|
|
||||||
<div className="border-b bg-amber-500/10">
|
|
||||||
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
|
||||||
<AlertTriangle
|
|
||||||
className="h-4 w-4 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.admin.notWritable}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
|
|
||||||
<nav
|
|
||||||
aria-label={dict.admin.settingGroups}
|
|
||||||
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
|
||||||
>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
<a
|
|
||||||
href={`#${item.id}`}
|
|
||||||
aria-current={
|
|
||||||
activeGroup === item.id
|
|
||||||
? "true"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
activeGroup === item.id
|
|
||||||
? "bg-muted font-medium text-foreground"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main className="min-w-0 flex-1 pb-24">
|
|
||||||
{/* Models section */}
|
|
||||||
<section aria-labelledby="models" className="mb-10">
|
|
||||||
<h2
|
|
||||||
id="models"
|
|
||||||
className="scroll-mt-20 text-base font-semibold"
|
|
||||||
>
|
|
||||||
{dict.admin.models}
|
|
||||||
</h2>
|
|
||||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
|
||||||
{dict.admin.modelsDescription}
|
|
||||||
</p>
|
|
||||||
<div className="overflow-hidden rounded-lg border bg-card">
|
|
||||||
<ModelsSection
|
|
||||||
providers={providers}
|
|
||||||
envProviders={envProviders}
|
|
||||||
disabled={!writable || saving}
|
|
||||||
password={authedPassword}
|
|
||||||
onChange={(next) => {
|
|
||||||
setSaveMessage(null)
|
|
||||||
setProviders(next)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Registry-driven groups */}
|
|
||||||
{SETTING_GROUPS.map((group) => {
|
|
||||||
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
|
||||||
const groupOff =
|
|
||||||
group.toggleable && !enabledGroups[group.id]
|
|
||||||
const fieldsDisabled = !writable || saving || !!groupOff
|
|
||||||
const gt = groupText(group.id)
|
|
||||||
const title = gt?.title ?? group.title
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={group.id}
|
|
||||||
aria-labelledby={group.id}
|
|
||||||
className="mb-10"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<h2
|
|
||||||
id={group.id}
|
|
||||||
className="scroll-mt-20 text-base font-semibold"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{group.toggleable && (
|
|
||||||
<label
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
|
||||||
enabledGroups[group.id]
|
|
||||||
? "border-primary/30 bg-primary/5 text-primary"
|
|
||||||
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{enabledGroups[group.id]
|
|
||||||
? dict.admin.enabled
|
|
||||||
: dict.admin.disabled}
|
|
||||||
<Switch
|
|
||||||
checked={
|
|
||||||
!!enabledGroups[group.id]
|
|
||||||
}
|
|
||||||
disabled={!writable || saving}
|
|
||||||
aria-label={formatMessage(
|
|
||||||
dict.admin.enableGroup,
|
|
||||||
{ group: title },
|
|
||||||
)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleGroupToggle(
|
|
||||||
group.id,
|
|
||||||
checked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
|
||||||
{gt?.description ?? group.description}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border bg-card px-4",
|
|
||||||
groupOff &&
|
|
||||||
"pointer-events-none opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{defs.map((def) => (
|
|
||||||
<SettingField
|
|
||||||
key={def.key}
|
|
||||||
def={def}
|
|
||||||
state={settings[def.key]}
|
|
||||||
pendingValue={pending[def.key]}
|
|
||||||
error={errors[def.key]}
|
|
||||||
disabled={fieldsDisabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
handleChange(def.key, v)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Always-mounted live region so save results are announced */}
|
|
||||||
<p aria-live="polite" className="sr-only">
|
|
||||||
{saveMessage?.text ?? ""}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{(dirtyCount > 0 || saveMessage) && (
|
|
||||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
|
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 items-center gap-1.5 truncate text-sm",
|
|
||||||
saveMessage?.ok
|
|
||||||
? "text-green-600 dark:text-green-400"
|
|
||||||
: saveMessage
|
|
||||||
? "text-destructive"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{saveMessage?.ok && (
|
|
||||||
<Check
|
|
||||||
className="h-4 w-4 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{saveMessage && !saveMessage.ok
|
|
||||||
? saveMessage.text
|
|
||||||
: dirtyCount > 0
|
|
||||||
? dict.admin.unsavedChanges
|
|
||||||
: saveMessage?.text}
|
|
||||||
</p>
|
|
||||||
{dirtyCount > 0 && (
|
|
||||||
<div className="flex shrink-0 gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => {
|
|
||||||
setPending({})
|
|
||||||
setErrors({})
|
|
||||||
setProviders(JSON.parse(savedProviders))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.admin.discard}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={saving || !writable}
|
|
||||||
onClick={() => void handleSave()}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2
|
|
||||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.admin.saving}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
dict.admin.saveChanges
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
import { Eye, EyeOff, X } from "lucide-react"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import type { SettingDef } from "@/lib/admin/settings-registry"
|
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
isSecretValue,
|
|
||||||
type SecretValue,
|
|
||||||
type SettingState,
|
|
||||||
savedTextOf,
|
|
||||||
} from "./admin-shared"
|
|
||||||
|
|
||||||
// ── Small shared UI bits ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
if (source === "default") return null
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
|
||||||
source === "file"
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: "bg-muted text-muted-foreground",
|
|
||||||
)}
|
|
||||||
title={
|
|
||||||
source === "file"
|
|
||||||
? dict.admin.sourceSavedTitle
|
|
||||||
: dict.admin.sourceEnvTitle
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RestartBadge() {
|
|
||||||
const dict = useDictionary()
|
|
||||||
return (
|
|
||||||
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
|
||||||
{dict.admin.restartRequired}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secret input: shows masked hint as placeholder, typing replaces.
|
|
||||||
// With keepOnEmpty, clearing the field reverts to the stored value
|
|
||||||
// ("keep") instead of deleting it — explicit deletion is via the X button.
|
|
||||||
export function SecretInput({
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
disabled,
|
|
||||||
keepOnEmpty,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
id: string
|
|
||||||
value: string | SecretValue | undefined
|
|
||||||
disabled?: boolean
|
|
||||||
keepOnEmpty?: boolean
|
|
||||||
onChange: (value: string | SecretValue) => void
|
|
||||||
}) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [show, setShow] = useState(false)
|
|
||||||
// The stored marker as it was at mount, to revert to on empty
|
|
||||||
const [original] = useState(value)
|
|
||||||
const hadStored = isSecretValue(original)
|
|
||||||
const text = typeof value === "string" ? value : ""
|
|
||||||
const placeholder = isSecretValue(value)
|
|
||||||
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
|
|
||||||
: dict.admin.notSet
|
|
||||||
const handleText = (t: string) => {
|
|
||||||
if (t === "" && keepOnEmpty && hadStored && original) {
|
|
||||||
onChange(original)
|
|
||||||
} else {
|
|
||||||
onChange(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Input
|
|
||||||
id={id}
|
|
||||||
type={show ? "text" : "password"}
|
|
||||||
value={text}
|
|
||||||
disabled={disabled}
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="h-9 font-mono text-xs"
|
|
||||||
onChange={(e) => handleText(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
|
|
||||||
onClick={() => setShow((s) => !s)}
|
|
||||||
>
|
|
||||||
{show ? (
|
|
||||||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{keepOnEmpty && (hadStored || text) && !disabled && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
aria-label={dict.admin.removeValue}
|
|
||||||
title={dict.admin.removeValueTitle}
|
|
||||||
onClick={() => onChange("")}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── General settings field (registry-driven) ─────────────────────────
|
|
||||||
|
|
||||||
export function SettingField({
|
|
||||||
def,
|
|
||||||
state,
|
|
||||||
pendingValue,
|
|
||||||
error,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
def: SettingDef
|
|
||||||
state: SettingState | undefined
|
|
||||||
pendingValue: string | null | undefined
|
|
||||||
error?: string
|
|
||||||
disabled: boolean
|
|
||||||
onChange: (value: string | null) => void
|
|
||||||
}) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const isDirty = pendingValue !== undefined
|
|
||||||
const source = state?.source ?? "default"
|
|
||||||
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
|
|
||||||
const secretState = state && isSecretValue(state.value) ? state.value : null
|
|
||||||
|
|
||||||
// Localized label/description keyed by env var name, falling back to the
|
|
||||||
// registry's English (the registry stays canonical for the server).
|
|
||||||
const t = (
|
|
||||||
dict.admin.settings as Record<
|
|
||||||
string,
|
|
||||||
{ label?: string; description?: string } | undefined
|
|
||||||
>
|
|
||||||
)[def.key]
|
|
||||||
const label = t?.label ?? def.label
|
|
||||||
const description = t?.description ?? def.description
|
|
||||||
|
|
||||||
const inputId = `setting-${def.key}`
|
|
||||||
const errorId = `${inputId}-error`
|
|
||||||
|
|
||||||
let control: React.ReactNode
|
|
||||||
switch (def.type) {
|
|
||||||
case "boolean": {
|
|
||||||
// When unset, reflect the built-in runtime default so the toggle
|
|
||||||
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
|
|
||||||
const effective =
|
|
||||||
currentValue !== "" ? currentValue : (def.default ?? "false")
|
|
||||||
// A saved boolean can be cleared back to its env/default value.
|
|
||||||
const canClear =
|
|
||||||
(isDirty && pendingValue !== null) || source === "file"
|
|
||||||
control = (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch
|
|
||||||
id={inputId}
|
|
||||||
checked={effective === "true"}
|
|
||||||
disabled={disabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onChange(checked ? "true" : "false")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{canClear && !disabled && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs text-muted-foreground"
|
|
||||||
onClick={() => onChange(null)}
|
|
||||||
>
|
|
||||||
{dict.admin.resetToDefault}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "enum":
|
|
||||||
control = (
|
|
||||||
<Select
|
|
||||||
value={currentValue || undefined}
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={onChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
|
||||||
<SelectValue placeholder={dict.admin.notSet} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{def.options?.map((opt) => (
|
|
||||||
<SelectItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case "secret":
|
|
||||||
control = (
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<SecretInput
|
|
||||||
id={inputId}
|
|
||||||
value={
|
|
||||||
isDirty
|
|
||||||
? (pendingValue ?? "")
|
|
||||||
: (secretState ?? currentValue)
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(v) =>
|
|
||||||
onChange(typeof v === "string" ? v : "")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case "number":
|
|
||||||
control = (
|
|
||||||
<Input
|
|
||||||
id={inputId}
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={def.min}
|
|
||||||
max={def.max}
|
|
||||||
value={currentValue}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={def.placeholder ?? dict.admin.notSet}
|
|
||||||
className="w-full max-w-xs tabular-nums"
|
|
||||||
aria-invalid={!!error}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
control = (
|
|
||||||
<Input
|
|
||||||
id={inputId}
|
|
||||||
type="text"
|
|
||||||
value={currentValue}
|
|
||||||
disabled={disabled}
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder={def.placeholder ?? dict.admin.notSet}
|
|
||||||
className="w-full max-w-md"
|
|
||||||
aria-invalid={!!error}
|
|
||||||
aria-describedby={error ? errorId : undefined}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b border-border/60 py-4 last:border-b-0">
|
|
||||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
|
||||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
<SourceChip source={source} />
|
|
||||||
{def.restartRequired && <RestartBadge />}
|
|
||||||
{isDirty && (
|
|
||||||
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
|
||||||
{dict.admin.modified}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{control}
|
|
||||||
<p
|
|
||||||
id={errorId}
|
|
||||||
className={cn(
|
|
||||||
"text-xs text-destructive",
|
|
||||||
error ? "mt-1.5" : "sr-only",
|
|
||||||
)}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{error ?? ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
|
||||||
import {
|
|
||||||
AdminProvidersSchema,
|
|
||||||
deriveEnvUpdates,
|
|
||||||
loadAdminProviders,
|
|
||||||
maskAdminProviders,
|
|
||||||
mergeSecrets,
|
|
||||||
validateAdminProviders,
|
|
||||||
} from "@/lib/admin/providers"
|
|
||||||
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
|
|
||||||
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
|
|
||||||
async function payload() {
|
|
||||||
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
|
|
||||||
// read-only in the panel; their credentials live in the environment
|
|
||||||
const envConfig = await loadEnvServerModelsConfig()
|
|
||||||
const adminProviders = loadAdminProviders()
|
|
||||||
// A panel default overrides any env default (matches the merge in
|
|
||||||
// loadRawServerModelsConfig), so env stars must reflect that
|
|
||||||
const adminHasDefault = adminProviders.some(
|
|
||||||
(p) => p.isDefault && p.models.length > 0,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
writable: isSettingsWritable(),
|
|
||||||
providers: maskAdminProviders(adminProviders),
|
|
||||||
envProviders:
|
|
||||||
envConfig?.providers.map((p) => ({
|
|
||||||
name: p.name,
|
|
||||||
provider: p.provider,
|
|
||||||
models: p.models,
|
|
||||||
isDefault: !!p.default && !adminHasDefault,
|
|
||||||
})) ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
const authError = checkAdminAuth(req)
|
|
||||||
if (authError) return authError
|
|
||||||
return Response.json(await payload())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(req: Request) {
|
|
||||||
const authError = checkAdminAuth(req)
|
|
||||||
if (authError) return authError
|
|
||||||
|
|
||||||
if (!isSettingsWritable()) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
|
||||||
},
|
|
||||||
{ status: 503 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: unknown
|
|
||||||
try {
|
|
||||||
body = await req.json()
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = AdminProvidersSchema.safeParse(
|
|
||||||
(body as { providers?: unknown })?.providers,
|
|
||||||
)
|
|
||||||
if (!parsed.success) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = loadAdminProviders()
|
|
||||||
const merged = mergeSecrets(parsed.data, stored)
|
|
||||||
|
|
||||||
const envConfig = await loadEnvServerModelsConfig()
|
|
||||||
const validationError = validateAdminProviders(merged, envConfig)
|
|
||||||
if (validationError) {
|
|
||||||
return Response.json({ error: validationError }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSettings(deriveEnvUpdates(merged, stored))
|
|
||||||
|
|
||||||
return Response.json(await payload())
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
|
|
||||||
import {
|
|
||||||
getEnvFallback,
|
|
||||||
getValueSource,
|
|
||||||
isSettingsWritable,
|
|
||||||
loadSettings,
|
|
||||||
saveSettings,
|
|
||||||
} from "@/lib/admin/settings"
|
|
||||||
import {
|
|
||||||
SETTINGS_BY_KEY,
|
|
||||||
SETTINGS_REGISTRY,
|
|
||||||
type SettingDef,
|
|
||||||
} from "@/lib/admin/settings-registry"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
|
|
||||||
function serializeSettings() {
|
|
||||||
const fileValues = loadSettings()
|
|
||||||
return SETTINGS_REGISTRY.map((def) => {
|
|
||||||
const source = getValueSource(def.key)
|
|
||||||
const raw =
|
|
||||||
source === "file"
|
|
||||||
? fileValues[def.key]
|
|
||||||
: (getEnvFallback(def.key) ?? null)
|
|
||||||
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
|
|
||||||
return { key: def.key, source, value }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
const authError = checkAdminAuth(req)
|
|
||||||
if (authError) return authError
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
writable: isSettingsWritable(),
|
|
||||||
settings: serializeSettings(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateValue(def: SettingDef, value: string): string | null {
|
|
||||||
switch (def.type) {
|
|
||||||
case "number": {
|
|
||||||
const num = Number(value)
|
|
||||||
if (!Number.isFinite(num)) return "Must be a number"
|
|
||||||
if (def.min !== undefined && num < def.min)
|
|
||||||
return `Must be at least ${def.min}`
|
|
||||||
if (def.max !== undefined && num > def.max)
|
|
||||||
return `Must be at most ${def.max}`
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
case "boolean":
|
|
||||||
return value === "true" || value === "false"
|
|
||||||
? null
|
|
||||||
: 'Must be "true" or "false"'
|
|
||||||
case "enum":
|
|
||||||
return def.options?.includes(value)
|
|
||||||
? null
|
|
||||||
: `Must be one of: ${def.options?.join(", ")}`
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(req: Request) {
|
|
||||||
const authError = checkAdminAuth(req)
|
|
||||||
if (authError) return authError
|
|
||||||
|
|
||||||
if (!isSettingsWritable()) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
|
||||||
},
|
|
||||||
{ status: 503 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: { values?: Record<string, unknown> }
|
|
||||||
try {
|
|
||||||
body = await req.json()
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (!body.values || typeof body.values !== "object") {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Body must contain a values object" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates: Record<string, string | null> = {}
|
|
||||||
const errors: Record<string, string> = {}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(body.values)) {
|
|
||||||
const def = SETTINGS_BY_KEY.get(key)
|
|
||||||
if (!def) {
|
|
||||||
errors[key] = "Unknown setting"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (value === null || value === "") {
|
|
||||||
updates[key] = null
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
errors[key] = "Value must be a string"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const error = validateValue(def, value)
|
|
||||||
if (error) {
|
|
||||||
errors[key] = error
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
updates[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
|
||||||
return Response.json({ errors }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSettings(updates)
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
writable: true,
|
|
||||||
settings: serializeSettings(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
|
||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
|
||||||
import {
|
|
||||||
AdminProviderSchema,
|
|
||||||
loadAdminProviders,
|
|
||||||
mergeSecrets,
|
|
||||||
} from "@/lib/admin/providers"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
|
|
||||||
// Test a model with the client's CURRENT provider state (which may be
|
|
||||||
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
|
|
||||||
// masked {isSet} markers, which are resolved against settings.json — so
|
|
||||||
// testing works both before and after saving.
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const authError = checkAdminAuth(req)
|
|
||||||
if (authError) return authError
|
|
||||||
|
|
||||||
let body: { provider?: unknown; modelId?: string }
|
|
||||||
try {
|
|
||||||
body = await req.json()
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = AdminProviderSchema.safeParse(body.provider)
|
|
||||||
if (!parsed.success || !body.modelId) {
|
|
||||||
return Response.json(
|
|
||||||
{ valid: false, error: "Invalid provider or model" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY: a stored secret is only resolved from an {isSet} marker if
|
|
||||||
// the endpoint it would be sent to (provider + baseUrl) still matches
|
|
||||||
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
|
|
||||||
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
|
|
||||||
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
|
|
||||||
const sameEndpoint =
|
|
||||||
stored &&
|
|
||||||
stored.provider === parsed.data.provider &&
|
|
||||||
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
|
|
||||||
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
|
|
||||||
const [resolved] = mergeSecrets(
|
|
||||||
[parsed.data],
|
|
||||||
sameEndpoint && stored ? [stored] : [],
|
|
||||||
)
|
|
||||||
|
|
||||||
return validateModel(
|
|
||||||
new Request(new URL("/api/validate-model", req.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
provider: resolved.provider,
|
|
||||||
apiKey: resolved.apiKey,
|
|
||||||
baseUrl: resolved.baseUrl,
|
|
||||||
modelId: body.modelId,
|
|
||||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
|
||||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
|
||||||
awsRegion: resolved.awsRegion,
|
|
||||||
vertexApiKey: resolved.vertexApiKey,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import {
|
|
||||||
AIHUBMIX_MODELS_ENDPOINT,
|
|
||||||
extractAihubmixModelIds,
|
|
||||||
} from "@/lib/aihubmix-models"
|
|
||||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
const SUCCESS_CACHE_CONTROL =
|
|
||||||
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
|
|
||||||
|
|
||||||
function fallbackResponse() {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
models: SUGGESTED_MODELS.aihubmix || [],
|
|
||||||
source: "fallback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
|
|
||||||
next: { revalidate: 3600 },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(
|
|
||||||
`[aihubmix-models] Failed to fetch models: ${response.status}`,
|
|
||||||
)
|
|
||||||
return fallbackResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json()
|
|
||||||
const models = extractAihubmixModelIds(payload)
|
|
||||||
|
|
||||||
if (models.length === 0) {
|
|
||||||
console.warn("[aihubmix-models] Model list response was empty")
|
|
||||||
return fallbackResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
models,
|
|
||||||
source: "aihubmix",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": SUCCESS_CACHE_CONTROL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[aihubmix-models] Failed to load models:", error)
|
|
||||||
return fallbackResponse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ import { z } from "zod"
|
|||||||
import {
|
import {
|
||||||
getAIModel,
|
getAIModel,
|
||||||
SINGLE_SYSTEM_PROVIDERS,
|
SINGLE_SYSTEM_PROVIDERS,
|
||||||
|
supportsImageInput,
|
||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
@@ -265,10 +266,16 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||||
[]
|
[]
|
||||||
|
|
||||||
// Note: we used to pre-emptively reject images for models we guessed were
|
// Check if user is sending images to a model that doesn't support them
|
||||||
// text-only (by name matching). That heuristic misfired on newer models
|
// AI SDK silently drops unsupported parts, so we need to catch this early
|
||||||
// (see issue #874), so we now let the request through and surface the real
|
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
||||||
// provider error if the model genuinely can't accept images.
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// User input only - XML is now in a separate cached system message
|
// User input only - XML is now in a separate cached system message
|
||||||
const formattedUserInput = `User input:
|
const formattedUserInput = `User input:
|
||||||
|
|||||||
@@ -1,37 +1,12 @@
|
|||||||
import { extractFromHtml } from "@extractus/article-extractor"
|
import { extract } from "@extractus/article-extractor"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import TurndownService from "turndown"
|
import TurndownService from "turndown"
|
||||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
const EXTRACT_TIMEOUT_MS = 15000
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||||
|
|
||||||
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
|
||||||
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
|
||||||
// would produce mojibake; the article-extractor library does the same detection
|
|
||||||
// when it fetches the page itself, which we no longer rely on.
|
|
||||||
function detectCharset(
|
|
||||||
contentType: string | null,
|
|
||||||
buffer: ArrayBuffer,
|
|
||||||
): string {
|
|
||||||
// 1. HTTP Content-Type header charset (most authoritative).
|
|
||||||
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
|
||||||
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
|
||||||
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
|
||||||
const metaCharset =
|
|
||||||
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
|
||||||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
|
||||||
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
|
||||||
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
|
||||||
try {
|
|
||||||
new TextDecoder(charset)
|
|
||||||
return charset
|
|
||||||
} catch {
|
|
||||||
return "utf-8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { url } = await req.json()
|
const { url } = await req.json()
|
||||||
@@ -53,34 +28,22 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection: parse-url has no use case for fetching internal
|
// SSRF protection
|
||||||
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
if (!allowPrivateUrls && isPrivateUrl(url)) {
|
||||||
// governs LLM provider baseUrl overrides (validate-model, chat).
|
|
||||||
if (await isPrivateUrl(url)) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Cannot access private/internal URLs" },
|
{ error: "Cannot access private/internal URLs" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Fetch the page ourselves so we control redirect handling. The
|
const headController = new AbortController()
|
||||||
// article-extractor library follows redirects internally and ignores a
|
const headTimeout = setTimeout(() => headController.abort(), 3000)
|
||||||
// `redirect` option, which would let a public URL 302 to an internal
|
|
||||||
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
|
||||||
// redirect outright.
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
controller.abort()
|
|
||||||
}, EXTRACT_TIMEOUT_MS)
|
|
||||||
|
|
||||||
let html: string
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const headResponse = await fetch(url, {
|
||||||
|
method: "HEAD",
|
||||||
headers: { "User-Agent": USER_AGENT },
|
headers: { "User-Agent": USER_AGENT },
|
||||||
redirect: "error",
|
signal: headController.signal,
|
||||||
signal: controller.signal,
|
|
||||||
})
|
})
|
||||||
|
const contentType = headResponse.headers.get("content-type")
|
||||||
const contentType = response.headers.get("content-type")
|
|
||||||
if (contentType?.includes("application/pdf")) {
|
if (contentType?.includes("application/pdf")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -89,17 +52,27 @@ export async function POST(req: Request) {
|
|||||||
{ status: 422 },
|
{ status: 422 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
if (!response.ok) {
|
console.warn(
|
||||||
return NextResponse.json(
|
"HEAD pre-check failed, proceeding with extraction:",
|
||||||
{ error: "Could not fetch URL content" },
|
err,
|
||||||
{ status: 400 },
|
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(headTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer()
|
// Extract article content with timeout to avoid tying up server resources
|
||||||
const charset = detectCharset(contentType, buffer)
|
const controller = new AbortController()
|
||||||
html = new TextDecoder(charset).decode(buffer)
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort()
|
||||||
|
}, EXTRACT_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let article
|
||||||
|
try {
|
||||||
|
article = await extract(url, undefined, {
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") {
|
if (err?.name === "AbortError") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -107,25 +80,11 @@ export async function POST(req: Request) {
|
|||||||
{ status: 504 },
|
{ status: 504 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Redirects are rejected with a TypeError ("failed to fetch" /
|
throw err
|
||||||
// "unexpected redirect") when redirect: "error" is set.
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Could not fetch URL content" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
|
||||||
// so map any parse error to the same 400 as the no-content case.
|
|
||||||
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
|
||||||
try {
|
|
||||||
article = await extractFromHtml(html, url)
|
|
||||||
} catch {
|
|
||||||
article = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!article || !article.content) {
|
if (!article || !article.content) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Could not extract content from URL" },
|
{ error: "Could not extract content from URL" },
|
||||||
|
|||||||
@@ -5,16 +5,11 @@ import { createGateway } from "@ai-sdk/gateway"
|
|||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
import { createVertex } from "@ai-sdk/google-vertex"
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
|
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { generateText } from "ai"
|
import { generateText } from "ai"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
import { createOllama } from "ollama-ai-provider-v2"
|
||||||
import {
|
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
|
||||||
AIHUBMIX_APP_CODE,
|
|
||||||
isAihubmixStandardBaseURL,
|
|
||||||
normalizeMiniMaxBaseURL,
|
|
||||||
} from "@/lib/ai-providers"
|
|
||||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
@@ -56,7 +51,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid base URL" },
|
{ valid: false, error: "Invalid base URL" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -158,28 +153,6 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "aihubmix": {
|
|
||||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
|
||||||
|
|
||||||
if (
|
|
||||||
isAihubmixStandardBaseURL(baseUrl) ||
|
|
||||||
baseUrl === defaultBaseURL
|
|
||||||
) {
|
|
||||||
const aihubmix = createAihubmix({
|
|
||||||
apiKey,
|
|
||||||
appCode: AIHUBMIX_APP_CODE,
|
|
||||||
})
|
|
||||||
model = aihubmix(modelId)
|
|
||||||
} else {
|
|
||||||
const aihubmixCompatible = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: baseUrl,
|
|
||||||
})
|
|
||||||
model = aihubmixCompatible.chat(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "deepseek": {
|
case "deepseek": {
|
||||||
if (baseUrl || apiKey) {
|
if (baseUrl || apiKey) {
|
||||||
const ds = createDeepSeek({
|
const ds = createDeepSeek({
|
||||||
@@ -372,14 +345,12 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
|
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
||||||
case "glm":
|
case "glm":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "qiniu":
|
case "qiniu":
|
||||||
case "novita":
|
case "novita": {
|
||||||
case "atlascloud":
|
|
||||||
case "mimo": {
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
baseUrl ||
|
baseUrl ||
|
||||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false
|
||||||
"includes": ["**", "!public"]
|
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -158,7 +158,7 @@ export function ModelSelector({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
<div ref={wrapperRef} className="inline-block">
|
||||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -167,7 +167,7 @@ export function ModelSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
|
||||||
!showLabel && "px-1.5 justify-center",
|
!showLabel && "px-1.5 justify-center",
|
||||||
)}
|
)}
|
||||||
// accessibility: expose label to screen readers
|
// accessibility: expose label to screen readers
|
||||||
@@ -176,7 +176,7 @@ export function ModelSelector({
|
|||||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
{/* show/hide visible label based on measured width */}
|
{/* show/hide visible label based on measured width */}
|
||||||
{showLabel ? (
|
{showLabel ? (
|
||||||
<span className="min-w-0 truncate text-xs">
|
<span className="text-xs truncate">
|
||||||
{selectedModel
|
{selectedModel
|
||||||
? selectedModel.modelId
|
? selectedModel.modelId
|
||||||
: dict.modelConfig.default}
|
: dict.modelConfig.default}
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Key, Link2, Tag } from "lucide-react"
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
|
||||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
|
||||||
// user dialog, write-only masked for the admin panel — supplied via
|
|
||||||
// renderSecret. That (and the optional test action) are the only genuine
|
|
||||||
// differences between the two screens; the field structure is shared here.
|
|
||||||
export type SecretField =
|
|
||||||
| "apiKey"
|
|
||||||
| "awsAccessKeyId"
|
|
||||||
| "awsSecretAccessKey"
|
|
||||||
| "vertexApiKey"
|
|
||||||
|
|
||||||
// AWS regions offered for Bedrock (shared by both screens)
|
|
||||||
const AWS_REGIONS: Array<[string, string]> = [
|
|
||||||
["us-east-1", "N. Virginia"],
|
|
||||||
["us-east-2", "Ohio"],
|
|
||||||
["us-west-2", "Oregon"],
|
|
||||||
["eu-west-1", "Ireland"],
|
|
||||||
["eu-west-2", "London"],
|
|
||||||
["eu-west-3", "Paris"],
|
|
||||||
["eu-central-1", "Frankfurt"],
|
|
||||||
["ap-south-1", "Mumbai"],
|
|
||||||
["ap-northeast-1", "Tokyo"],
|
|
||||||
["ap-northeast-2", "Seoul"],
|
|
||||||
["ap-southeast-1", "Singapore"],
|
|
||||||
["ap-southeast-2", "Sydney"],
|
|
||||||
["sa-east-1", "São Paulo"],
|
|
||||||
]
|
|
||||||
|
|
||||||
interface ProviderCredentialsFieldsProps {
|
|
||||||
provider: ProviderName
|
|
||||||
// Plain (non-secret) field values — secrets are owned by renderSecret
|
|
||||||
name?: string
|
|
||||||
baseUrl?: string
|
|
||||||
awsRegion?: string
|
|
||||||
disabled?: boolean
|
|
||||||
// Update a plain text field
|
|
||||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
|
||||||
// Render the control for a secret field. The caller may include trailing
|
|
||||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
|
||||||
// shared component only supplies the label above it.
|
|
||||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
|
||||||
// Extra content after the fields — used for the Bedrock test row and the
|
|
||||||
// EdgeOne test button, which aren't beside a credential input.
|
|
||||||
footer?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display name + per-provider credential inputs, shared by the user
|
|
||||||
// ModelConfigDialog and the admin Models panel.
|
|
||||||
export function ProviderCredentialsFields({
|
|
||||||
provider,
|
|
||||||
name,
|
|
||||||
baseUrl,
|
|
||||||
awsRegion,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
renderSecret,
|
|
||||||
footer,
|
|
||||||
}: ProviderCredentialsFieldsProps) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const info = PROVIDER_INFO[provider]
|
|
||||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
|
||||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
|
||||||
})
|
|
||||||
|
|
||||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
|
||||||
if (provider === "edgeone") {
|
|
||||||
return <div className="space-y-5">{footer}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Display Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="provider-name"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.displayName}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="provider-name"
|
|
||||||
value={name ?? ""}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => onChange("name", e.target.value)}
|
|
||||||
placeholder={info.label}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{provider === "bedrock" ? (
|
|
||||||
<>
|
|
||||||
{/* AWS Access Key ID */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="aws-access-key-id"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.awsAccessKeyId}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({
|
|
||||||
field: "awsAccessKeyId",
|
|
||||||
id: "aws-access-key-id",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AWS Secret Access Key */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="aws-secret-access-key"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.awsSecretAccessKey}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({
|
|
||||||
field: "awsSecretAccessKey",
|
|
||||||
id: "aws-secret-access-key",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AWS Region */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="aws-region"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.awsRegion}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={awsRegion || ""}
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={(v) => onChange("awsRegion", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="aws-region"
|
|
||||||
className="h-9 font-mono text-xs hover:bg-accent"
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={dict.modelConfig.selectRegion}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-64">
|
|
||||||
{AWS_REGIONS.map(([region, label]) => (
|
|
||||||
<SelectItem key={region} value={region}>
|
|
||||||
{region} ({label})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : provider === "vertexai" ? (
|
|
||||||
<>
|
|
||||||
{/* Vertex AI API Key (Express Mode) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="vertex-api-key"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.apiKey}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({
|
|
||||||
field: "vertexApiKey",
|
|
||||||
id: "vertex-api-key",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL (optional) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="vertex-base-url"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{baseUrlLabel}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="vertex-base-url"
|
|
||||||
value={baseUrl ?? ""}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange("baseUrl", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={dict.modelConfig.customEndpoint}
|
|
||||||
className="h-9 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* API Key */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="api-key"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.apiKey}
|
|
||||||
{provider === "ollama" &&
|
|
||||||
` ${dict.modelConfig.optional}`}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="base-url"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{baseUrlLabel}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="base-url"
|
|
||||||
value={baseUrl ?? ""}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange("baseUrl", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
info.defaultBaseUrl ||
|
|
||||||
dict.modelConfig.customEndpoint
|
|
||||||
}
|
|
||||||
className="h-9 rounded-xl font-mono text-xs"
|
|
||||||
/>
|
|
||||||
{provider === "minimax" && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.modelConfig.minimaxBaseUrlHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{provider === "mimo" && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.modelConfig.mimoBaseUrlHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{footer}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Cloud, Server, Sparkles } from "lucide-react"
|
|
||||||
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
// Provider logo from models.dev, with Lucide fallbacks for providers
|
|
||||||
// that have no logo there
|
|
||||||
export function ProviderLogo({
|
|
||||||
provider,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
provider: ProviderName
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
if (provider === "bedrock") {
|
|
||||||
return <Cloud className={cn("size-4", className)} />
|
|
||||||
}
|
|
||||||
if (provider === "sglang") {
|
|
||||||
return <Server className={cn("size-4", className)} />
|
|
||||||
}
|
|
||||||
if (provider === "doubao") {
|
|
||||||
return <Sparkles className={cn("size-4", className)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("size-4 dark:invert", className)}
|
|
||||||
height={16}
|
|
||||||
src={`https://models.dev/logos/${logoName}.svg`}
|
|
||||||
width={16}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,9 +11,6 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
|
||||||
# Persists admin panel settings (data/settings.json)
|
|
||||||
- ./data:/app/data
|
|
||||||
# environment:
|
# environment:
|
||||||
# # For subdirectory deployment, uncomment and set your path:
|
# # For subdirectory deployment, uncomment and set your path:
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ npm run dev
|
|||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
@@ -217,18 +216,12 @@ npm run dev
|
|||||||
|
|
||||||
### 服务端多模型配置
|
### 服务端多模型配置
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
|
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
||||||
|
|
||||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||||
|
|
||||||
### 管理面板
|
|
||||||
|
|
||||||
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
|
|
||||||
|
|
||||||
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
|
|
||||||
|
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# 管理面板
|
|
||||||
|
|
||||||
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
|
|
||||||
|
|
||||||
## 启用面板
|
|
||||||
|
|
||||||
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
|
|
||||||
2. 访问 `/admin` 并登录。
|
|
||||||
|
|
||||||
## 可配置内容
|
|
||||||
|
|
||||||
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
|
|
||||||
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
|
|
||||||
|
|
||||||
## 优先级
|
|
||||||
|
|
||||||
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
|
|
||||||
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
|
|
||||||
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
|
|
||||||
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
|
|
||||||
@@ -46,21 +46,6 @@ AI_MODEL=gpt-4o
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -68,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
可选的自定义端点:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -237,7 +215,7 @@ MiniMax 支持两种 API 格式:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
MINIMAX_API_KEY=your_api_key
|
MINIMAX_API_KEY=your_api_key
|
||||||
AI_MODEL=MiniMax-M3
|
AI_MODEL=MiniMax-M2.7
|
||||||
```
|
```
|
||||||
|
|
||||||
可选配置:
|
可选配置:
|
||||||
@@ -308,19 +286,6 @@ AI_MODEL=your_model_id
|
|||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
### MiMo (小米)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自动检测
|
## 自动检测
|
||||||
|
|
||||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||||
@@ -328,7 +293,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|||||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
## 服务端多模型配置
|
## 服务端多模型配置
|
||||||
@@ -349,17 +314,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
||||||
|
|
||||||
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
|
|
||||||
|
|
||||||
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=doubao
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider,或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
|
|
||||||
|
|
||||||
### 配置示例
|
### 配置示例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Admin Panel
|
|
||||||
|
|
||||||
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
|
|
||||||
|
|
||||||
## Enabling the panel
|
|
||||||
|
|
||||||
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
|
|
||||||
2. Visit `/admin` and sign in.
|
|
||||||
|
|
||||||
## What you can configure
|
|
||||||
|
|
||||||
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
|
|
||||||
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
|
|
||||||
|
|
||||||
## Precedence
|
|
||||||
|
|
||||||
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
|
|
||||||
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
|
|
||||||
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
|
|
||||||
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
|
|
||||||
@@ -61,21 +61,6 @@ Optional custom endpoint (for OpenAI-compatible services):
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -83,13 +68,6 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
Optional custom endpoint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -252,7 +230,7 @@ MiniMax supports two API formats:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
MINIMAX_API_KEY=your_api_key
|
MINIMAX_API_KEY=your_api_key
|
||||||
AI_MODEL=MiniMax-M3
|
AI_MODEL=MiniMax-M2.7
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional configuration:
|
Optional configuration:
|
||||||
@@ -323,19 +301,6 @@ Optional custom endpoint:
|
|||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
### MiMo (Xiaomi)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Detection
|
## Auto-Detection
|
||||||
|
|
||||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||||
@@ -343,7 +308,7 @@ If you only configure **one** provider's API key, the system will automatically
|
|||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server-Side Multi-Model Configuration
|
## Server-Side Multi-Model Configuration
|
||||||
@@ -364,17 +329,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
||||||
|
|
||||||
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
|
|
||||||
|
|
||||||
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=doubao
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
|
|
||||||
|
|
||||||
### Example Configuration
|
### Example Configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
|||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- AIHubMix
|
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
@@ -216,18 +215,12 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
### サーバーサイドマルチモデル設定
|
### サーバーサイドマルチモデル設定
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
|
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
||||||
|
|
||||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|
||||||
### 管理パネル
|
|
||||||
|
|
||||||
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
|
|
||||||
|
|
||||||
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
|
|
||||||
|
|
||||||
|
|
||||||
## 仕組み
|
## 仕組み
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# 管理パネル
|
|
||||||
|
|
||||||
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
|
|
||||||
|
|
||||||
## パネルの有効化
|
|
||||||
|
|
||||||
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
|
|
||||||
2. `/admin` にアクセスしてサインインします。
|
|
||||||
|
|
||||||
## 設定できる項目
|
|
||||||
|
|
||||||
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
|
|
||||||
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
|
|
||||||
|
|
||||||
## 優先順位
|
|
||||||
|
|
||||||
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
|
|
||||||
|
|
||||||
## 注意事項
|
|
||||||
|
|
||||||
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。
|
|
||||||
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
|
|
||||||
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
|
|
||||||
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。
|
|
||||||
@@ -46,21 +46,6 @@ AI_MODEL=gpt-4o
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
### AIHubMix
|
|
||||||
|
|
||||||
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250929
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -68,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
|
|||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
AI_MODEL=claude-sonnet-4-5-20250514
|
||||||
```
|
```
|
||||||
|
|
||||||
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_AUTH_TOKEN=your_auth_token
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
任意のカスタムエンドポイント:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -237,7 +215,7 @@ MiniMax は 2 つの API 形式をサポートしています:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
MINIMAX_API_KEY=your_api_key
|
MINIMAX_API_KEY=your_api_key
|
||||||
AI_MODEL=MiniMax-M3
|
AI_MODEL=MiniMax-M2.7
|
||||||
```
|
```
|
||||||
|
|
||||||
オプション設定:
|
オプション設定:
|
||||||
@@ -308,19 +286,6 @@ AI_MODEL=your_model_id
|
|||||||
QINIU_BASE_URL=https://your-custom-endpoint
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
### MiMo (Xiaomi)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=mimo-v2.5-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自動検出
|
## 自動検出
|
||||||
|
|
||||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||||
@@ -328,7 +293,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
|||||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
## サーバーサイドマルチモデル設定
|
## サーバーサイドマルチモデル設定
|
||||||
@@ -349,17 +314,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
|||||||
|
|
||||||
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
||||||
|
|
||||||
**方法3:`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
|
|
||||||
|
|
||||||
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=doubao
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
|
|
||||||
|
|
||||||
### 設定例
|
### 設定例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
31
env.example
31
env.example
@@ -1,14 +1,10 @@
|
|||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# AI_PROVIDER: Which provider to use
|
||||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
|
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
||||||
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
|
||||||
# The first one becomes the default and the rest appear in the model picker.
|
|
||||||
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
|
||||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|
||||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
|
||||||
# AWS Bedrock Configuration
|
# AWS Bedrock Configuration
|
||||||
@@ -29,8 +25,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
||||||
|
|
||||||
# Anthropic (Direct) Configuration
|
# Anthropic (Direct) Configuration
|
||||||
# ANTHROPIC_API_KEY=sk-ant-... # Sent as `x-api-key` header
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
# ANTHROPIC_AUTH_TOKEN= # Alternative to ANTHROPIC_API_KEY; sent as `Authorization: Bearer` header (mutually exclusive)
|
|
||||||
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
|
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
|
||||||
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
|
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
|
||||||
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
|
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
|
||||||
@@ -73,10 +68,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
||||||
|
|
||||||
# AIHubMix Configuration
|
|
||||||
# AIHUBMIX_API_KEY=your-aihubmix-api-key
|
|
||||||
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
|
|
||||||
|
|
||||||
# DeepSeek Configuration
|
# DeepSeek Configuration
|
||||||
# DEEPSEEK_API_KEY=sk-...
|
# DEEPSEEK_API_KEY=sk-...
|
||||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||||
@@ -124,14 +115,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Access Control (Optional)
|
# Access Control (Optional)
|
||||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
# ACCESS_CODE_LIST=your-secret-code,another-code
|
||||||
|
|
||||||
# Admin Panel (Optional)
|
|
||||||
# Set a password to enable the web admin panel at /admin, where most of the
|
|
||||||
# settings in this file can be edited at runtime (stored in data/settings.json,
|
|
||||||
# which takes precedence over environment variables).
|
|
||||||
# Leave unset to disable the admin panel entirely.
|
|
||||||
# ADMIN_PASSWORD=your-admin-password
|
|
||||||
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
|
||||||
|
|
||||||
# Draw.io Configuration (Optional)
|
# Draw.io Configuration (Optional)
|
||||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
||||||
# Use this to point to a self-hosted draw.io instance
|
# Use this to point to a self-hosted draw.io instance
|
||||||
@@ -189,13 +172,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Get your API key from: https://novita.ai/dashboard/key
|
# Get your API key from: https://novita.ai/dashboard/key
|
||||||
# NOVITA_API_KEY=your_novita_api_key
|
# NOVITA_API_KEY=your_novita_api_key
|
||||||
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
|
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
|
||||||
|
|
||||||
# MiMo (Xiaomi) Configuration (Optional)
|
|
||||||
# Get your API key from: https://platform.xiaomimimo.com/
|
|
||||||
# MIMO_API_KEY=your_mimo_api_key
|
|
||||||
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
|
|
||||||
|
|
||||||
# Atlas Cloud Configuration (Optional)
|
|
||||||
# Get your API key from: https://www.atlascloud.ai/console/api-keys
|
|
||||||
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
|
|
||||||
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.
|
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||||
|
|
||||||
export async function register() {
|
export function register() {
|
||||||
// Overlay admin settings file onto process.env before anything reads config
|
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
||||||
try {
|
|
||||||
const { applyToEnv } = await import("@/lib/admin/settings")
|
|
||||||
applyToEnv()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[admin-settings] Failed to apply settings:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip telemetry if Langfuse env vars are not configured
|
// Skip telemetry if Langfuse env vars are not configured
|
||||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { timingSafeEqual } from "crypto"
|
|
||||||
|
|
||||||
// Shared auth for admin API routes: compares x-admin-password header
|
|
||||||
// against the ADMIN_PASSWORD env var. Unset password = panel disabled.
|
|
||||||
export function checkAdminAuth(req: Request): Response | null {
|
|
||||||
const password = process.env.ADMIN_PASSWORD
|
|
||||||
if (!password) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
|
|
||||||
},
|
|
||||||
{ status: 403 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const provided = req.headers.get("x-admin-password") || ""
|
|
||||||
const a = Buffer.from(provided)
|
|
||||||
const b = Buffer.from(password)
|
|
||||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Invalid admin password" },
|
|
||||||
{ status: 401 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MaskedSecret {
|
|
||||||
isSet: true
|
|
||||||
hint: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskSecret(value: string): MaskedSecret {
|
|
||||||
return {
|
|
||||||
isSet: true,
|
|
||||||
hint: value.length > 8 ? `…${value.slice(-4)}` : "••••",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
import { z } from "zod"
|
|
||||||
import {
|
|
||||||
ProviderNameSchema,
|
|
||||||
type ServerModelsConfig,
|
|
||||||
} from "@/lib/server-model-config"
|
|
||||||
import {
|
|
||||||
FIXED_CRED_PROVIDERS,
|
|
||||||
PROVIDER_INFO,
|
|
||||||
type ProviderName,
|
|
||||||
} from "@/lib/types/model-config"
|
|
||||||
import { type MaskedSecret, maskSecret } from "./auth"
|
|
||||||
import { loadSettings } from "./settings"
|
|
||||||
|
|
||||||
// Admin-configured providers, mirroring the user ModelConfigDialog's data
|
|
||||||
// model but stored server-side (settings.json, ADMIN_PROVIDERS key).
|
|
||||||
//
|
|
||||||
// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json:
|
|
||||||
// loadRawServerModelsConfig() merges the env baseline with the panel's
|
|
||||||
// providers at read time, so .env stays authoritative for its own entries.
|
|
||||||
// Panel credentials are written to ADMIN_-prefixed env vars (wired up via
|
|
||||||
// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like
|
|
||||||
// OPENAI_API_KEY that env-based entries may rely on.
|
|
||||||
|
|
||||||
export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS"
|
|
||||||
|
|
||||||
// A secret field in transit: plaintext string (new value) or an
|
|
||||||
// {isSet} marker meaning "keep the stored value".
|
|
||||||
const SecretInputSchema = z
|
|
||||||
.union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })])
|
|
||||||
.optional()
|
|
||||||
|
|
||||||
export const AdminProviderSchema = z.object({
|
|
||||||
id: z.string().min(1),
|
|
||||||
provider: ProviderNameSchema,
|
|
||||||
name: z.string().optional(),
|
|
||||||
apiKey: SecretInputSchema,
|
|
||||||
baseUrl: z.string().optional(),
|
|
||||||
awsAccessKeyId: SecretInputSchema,
|
|
||||||
awsSecretAccessKey: SecretInputSchema,
|
|
||||||
awsRegion: z.string().optional(),
|
|
||||||
vertexApiKey: SecretInputSchema,
|
|
||||||
models: z.array(z.string().min(1)),
|
|
||||||
isDefault: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const AdminProvidersSchema = z.array(AdminProviderSchema)
|
|
||||||
|
|
||||||
// Stored shape: secrets are plain strings (never {isSet} markers, which
|
|
||||||
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
|
|
||||||
// hand-edited/corrupted value can't slip a marker object past maskSecret.
|
|
||||||
const StoredAdminProviderSchema = AdminProviderSchema.extend({
|
|
||||||
apiKey: z.string().optional(),
|
|
||||||
awsAccessKeyId: z.string().optional(),
|
|
||||||
awsSecretAccessKey: z.string().optional(),
|
|
||||||
vertexApiKey: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
|
|
||||||
|
|
||||||
// Stored form: secrets are plain strings
|
|
||||||
export interface StoredAdminProvider {
|
|
||||||
id: string
|
|
||||||
provider: ProviderName
|
|
||||||
name?: string
|
|
||||||
apiKey?: string
|
|
||||||
baseUrl?: string
|
|
||||||
awsAccessKeyId?: string
|
|
||||||
awsSecretAccessKey?: string
|
|
||||||
awsRegion?: string
|
|
||||||
vertexApiKey?: string
|
|
||||||
models: string[]
|
|
||||||
isDefault?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECRET_FIELDS = [
|
|
||||||
"apiKey",
|
|
||||||
"awsAccessKeyId",
|
|
||||||
"awsSecretAccessKey",
|
|
||||||
"vertexApiKey",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider
|
|
||||||
function credEnvNames(
|
|
||||||
provider: ProviderName,
|
|
||||||
index: number,
|
|
||||||
): { key?: string; url?: string } {
|
|
||||||
if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
const prefix =
|
|
||||||
provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()
|
|
||||||
const suffix = index === 0 ? "" : `_${index + 1}`
|
|
||||||
return {
|
|
||||||
key: `ADMIN_${prefix}_API_KEY${suffix}`,
|
|
||||||
url: `ADMIN_${prefix}_BASE_URL${suffix}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadAdminProviders(): StoredAdminProvider[] {
|
|
||||||
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
|
|
||||||
if (!raw) return []
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (!Array.isArray(parsed)) return []
|
|
||||||
// Validate each entry's shape — a malformed/hand-edited value must
|
|
||||||
// not reach runtime code that assumes provider/models exist.
|
|
||||||
return parsed.flatMap((entry) => {
|
|
||||||
const result = StoredAdminProviderSchema.safeParse(entry)
|
|
||||||
return result.success ? [result.data as StoredAdminProvider] : []
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
console.error("[admin-providers] Failed to parse stored providers")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MaskedAdminProvider = Omit<
|
|
||||||
StoredAdminProvider,
|
|
||||||
(typeof SECRET_FIELDS)[number]
|
|
||||||
> & {
|
|
||||||
apiKey?: MaskedSecret
|
|
||||||
awsAccessKeyId?: MaskedSecret
|
|
||||||
awsSecretAccessKey?: MaskedSecret
|
|
||||||
vertexApiKey?: MaskedSecret
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskAdminProviders(
|
|
||||||
list: StoredAdminProvider[],
|
|
||||||
): MaskedAdminProvider[] {
|
|
||||||
return list.map((p) => {
|
|
||||||
const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider
|
|
||||||
for (const field of SECRET_FIELDS) {
|
|
||||||
const value = p[field]
|
|
||||||
masked[field] = value ? maskSecret(value) : undefined
|
|
||||||
}
|
|
||||||
return masked
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve {isSet} markers in incoming secrets against the stored list
|
|
||||||
export function mergeSecrets(
|
|
||||||
incoming: AdminProviderInput[],
|
|
||||||
stored: StoredAdminProvider[],
|
|
||||||
): StoredAdminProvider[] {
|
|
||||||
const storedById = new Map(stored.map((p) => [p.id, p]))
|
|
||||||
return incoming.map((p) => {
|
|
||||||
const prev = storedById.get(p.id)
|
|
||||||
const merged = { ...p } as StoredAdminProvider
|
|
||||||
for (const field of SECRET_FIELDS) {
|
|
||||||
const value = p[field]
|
|
||||||
if (typeof value === "string") {
|
|
||||||
merged[field] = value || undefined
|
|
||||||
} else if (value?.isSet) {
|
|
||||||
merged[field] = prev?.[field]
|
|
||||||
} else {
|
|
||||||
merged[field] = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayName(p: StoredAdminProvider): string {
|
|
||||||
return p.name?.trim() || PROVIDER_INFO[p.provider].label
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateAdminProviders(
|
|
||||||
list: StoredAdminProvider[],
|
|
||||||
envConfig: ServerModelsConfig | null = null,
|
|
||||||
): string | null {
|
|
||||||
const envProviders = envConfig?.providers ?? []
|
|
||||||
for (const single of FIXED_CRED_PROVIDERS) {
|
|
||||||
if (list.filter((p) => p.provider === single).length > 1) {
|
|
||||||
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
|
|
||||||
}
|
|
||||||
// Its credentials are global; a panel instance would silently
|
|
||||||
// override the credentials env-configured models rely on
|
|
||||||
if (
|
|
||||||
list.some((p) => p.provider === single) &&
|
|
||||||
envProviders.some((p) => p.provider === single)
|
|
||||||
) {
|
|
||||||
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const names = list.map((p) => displayName(p))
|
|
||||||
if (new Set(names).size !== names.length) {
|
|
||||||
return "Provider display names must be unique."
|
|
||||||
}
|
|
||||||
const envNames = new Set(envProviders.map((p) => p.name))
|
|
||||||
const clash = names.find((n) => envNames.has(n))
|
|
||||||
if (clash) {
|
|
||||||
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
|
|
||||||
}
|
|
||||||
if (list.filter((p) => p.isDefault).length > 1) {
|
|
||||||
return "Only one provider can be the default."
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// The panel's contribution to the server models config, derived at read
|
|
||||||
// time and merged with the env baseline by loadRawServerModelsConfig().
|
|
||||||
export function adminProvidersToConfig(
|
|
||||||
list: StoredAdminProvider[],
|
|
||||||
): ServerModelsConfig {
|
|
||||||
const config: ServerModelsConfig = { providers: [] }
|
|
||||||
const indexByProvider = new Map<ProviderName, number>()
|
|
||||||
for (const p of list) {
|
|
||||||
const index = indexByProvider.get(p.provider) ?? 0
|
|
||||||
indexByProvider.set(p.provider, index + 1)
|
|
||||||
if (p.models.length === 0) continue
|
|
||||||
const env = credEnvNames(p.provider, index)
|
|
||||||
config.providers.push({
|
|
||||||
name: displayName(p),
|
|
||||||
provider: p.provider,
|
|
||||||
models: p.models,
|
|
||||||
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
|
|
||||||
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
|
|
||||||
...(p.isDefault ? { default: true } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings updates derived from the provider list: credential env vars,
|
|
||||||
// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set.
|
|
||||||
// Keys derived from `previous` but absent now are set to null (removed,
|
|
||||||
// falling back to the environment).
|
|
||||||
export function deriveEnvUpdates(
|
|
||||||
list: StoredAdminProvider[],
|
|
||||||
previous: StoredAdminProvider[],
|
|
||||||
): Record<string, string | null> {
|
|
||||||
const updates: Record<string, string | null> = {}
|
|
||||||
|
|
||||||
// Clear everything the previous list owned, then overwrite below
|
|
||||||
for (const key of derivedEnvKeys(previous)) updates[key] = null
|
|
||||||
|
|
||||||
const indexByProvider = new Map<ProviderName, number>()
|
|
||||||
for (const p of list) {
|
|
||||||
const index = indexByProvider.get(p.provider) ?? 0
|
|
||||||
indexByProvider.set(p.provider, index + 1)
|
|
||||||
|
|
||||||
if (p.provider === "bedrock") {
|
|
||||||
if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
|
|
||||||
if (p.awsSecretAccessKey)
|
|
||||||
updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
|
|
||||||
if (p.awsRegion) updates.AWS_REGION = p.awsRegion
|
|
||||||
} else if (p.provider === "vertexai") {
|
|
||||||
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
|
|
||||||
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
|
|
||||||
} else if (p.provider === "ollama") {
|
|
||||||
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
|
|
||||||
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
|
|
||||||
} else {
|
|
||||||
const env = credEnvNames(p.provider, index)
|
|
||||||
if (env.key && p.apiKey) updates[env.key] = p.apiKey
|
|
||||||
if (env.url && p.baseUrl) updates[env.url] = p.baseUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null
|
|
||||||
|
|
||||||
// The panel's default also becomes the server-wide default model;
|
|
||||||
// without one, the env-configured default applies.
|
|
||||||
const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0)
|
|
||||||
if (defaultEntry) {
|
|
||||||
updates.AI_PROVIDER = defaultEntry.provider
|
|
||||||
updates.AI_MODEL = defaultEntry.models[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
return updates
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every settings key the panel may have written for a given list.
|
|
||||||
// AI_MODELS_CONFIG is included to clean up values written by older
|
|
||||||
// versions of the panel (it is no longer written).
|
|
||||||
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
|
||||||
const keys = new Set<string>([
|
|
||||||
"AI_MODELS_CONFIG",
|
|
||||||
"AI_PROVIDER",
|
|
||||||
"AI_MODEL",
|
|
||||||
])
|
|
||||||
const indexByProvider = new Map<ProviderName, number>()
|
|
||||||
for (const p of list) {
|
|
||||||
const index = indexByProvider.get(p.provider) ?? 0
|
|
||||||
indexByProvider.set(p.provider, index + 1)
|
|
||||||
if (p.provider === "bedrock") {
|
|
||||||
keys.add("AWS_ACCESS_KEY_ID")
|
|
||||||
keys.add("AWS_SECRET_ACCESS_KEY")
|
|
||||||
keys.add("AWS_REGION")
|
|
||||||
} else if (p.provider === "vertexai") {
|
|
||||||
keys.add("GOOGLE_VERTEX_API_KEY")
|
|
||||||
keys.add("GOOGLE_VERTEX_BASE_URL")
|
|
||||||
} else if (p.provider === "ollama") {
|
|
||||||
keys.add("OLLAMA_API_KEY")
|
|
||||||
keys.add("OLLAMA_BASE_URL")
|
|
||||||
} else {
|
|
||||||
const env = credEnvNames(p.provider, index)
|
|
||||||
if (env.key) keys.add(env.key)
|
|
||||||
if (env.url) keys.add(env.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...keys]
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
// Declarative registry of the general env vars editable in the admin panel.
|
|
||||||
// Drives both server-side validation (app/api/admin/settings) and UI
|
|
||||||
// rendering (app/[lang]/admin). Keys are exactly the env var names.
|
|
||||||
//
|
|
||||||
// AI providers and models are managed separately in the panel's Models
|
|
||||||
// section (lib/admin/providers.ts), not here.
|
|
||||||
//
|
|
||||||
// Not listed here (and therefore rejected by the API):
|
|
||||||
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
|
|
||||||
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
|
|
||||||
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
|
|
||||||
|
|
||||||
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
|
|
||||||
|
|
||||||
export interface SettingDef {
|
|
||||||
key: string
|
|
||||||
group: string
|
|
||||||
type: SettingType
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
options?: string[]
|
|
||||||
min?: number
|
|
||||||
max?: number
|
|
||||||
placeholder?: string
|
|
||||||
// Built-in default applied at runtime when the value is unset, so the UI
|
|
||||||
// can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true").
|
|
||||||
default?: string
|
|
||||||
// Value is only picked up at process start (module-load readers)
|
|
||||||
restartRequired?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingGroup {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
// Optional sections gated by an on/off switch in the panel; fields are
|
|
||||||
// grayed out until enabled. Starts on when any field is already set.
|
|
||||||
toggleable?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SETTING_GROUPS: SettingGroup[] = [
|
|
||||||
{
|
|
||||||
id: "generation",
|
|
||||||
title: "Generation",
|
|
||||||
description: "Output parameters applied to all chat requests.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "access",
|
|
||||||
title: "Access Control",
|
|
||||||
description: "Restrict who can use this deployment.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "features",
|
|
||||||
title: "Features",
|
|
||||||
description: "Optional features and security toggles.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "observability",
|
|
||||||
title: "Observability",
|
|
||||||
description: "Langfuse tracing for LLM calls.",
|
|
||||||
toggleable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "quota",
|
|
||||||
title: "Quota & Rate Limits",
|
|
||||||
description:
|
|
||||||
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
|
|
||||||
toggleable: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SETTINGS_REGISTRY: SettingDef[] = [
|
|
||||||
// ── Generation ───────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "TEMPERATURE",
|
|
||||||
group: "generation",
|
|
||||||
type: "number",
|
|
||||||
label: "Temperature",
|
|
||||||
description:
|
|
||||||
"Leave unset for reasoning models that reject temperature.",
|
|
||||||
min: 0,
|
|
||||||
max: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "MAX_OUTPUT_TOKENS",
|
|
||||||
group: "generation",
|
|
||||||
type: "number",
|
|
||||||
label: "Max Output Tokens",
|
|
||||||
min: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Access Control ───────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "ACCESS_CODE_LIST",
|
|
||||||
group: "access",
|
|
||||||
type: "string",
|
|
||||||
label: "Access Codes",
|
|
||||||
description:
|
|
||||||
"Comma-separated list. Users must enter one to chat. Empty = open access.",
|
|
||||||
placeholder: "code1,code2",
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Features ─────────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "ENABLE_VLM_VALIDATION",
|
|
||||||
group: "features",
|
|
||||||
type: "boolean",
|
|
||||||
label: "VLM Diagram Validation",
|
|
||||||
description:
|
|
||||||
"Visually validate generated diagrams with a vision model.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "VALIDATION_MODEL",
|
|
||||||
group: "features",
|
|
||||||
type: "string",
|
|
||||||
label: "Validation Model",
|
|
||||||
description: "Falls back to the default AI model when empty.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "VALIDATION_TIMEOUT",
|
|
||||||
group: "features",
|
|
||||||
type: "number",
|
|
||||||
label: "Validation Timeout (ms)",
|
|
||||||
min: 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ENABLE_HISTORY_XML_REPLACE",
|
|
||||||
group: "features",
|
|
||||||
type: "boolean",
|
|
||||||
label: "History XML Compression",
|
|
||||||
description: "Replace old diagram XML in history with placeholders.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ALLOW_PRIVATE_URLS",
|
|
||||||
group: "features",
|
|
||||||
type: "boolean",
|
|
||||||
label: "Allow Private URLs",
|
|
||||||
description:
|
|
||||||
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
|
|
||||||
// Unset means allowed at runtime (ssrf-protection: !== "false")
|
|
||||||
default: "true",
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Observability ────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "LANGFUSE_PUBLIC_KEY",
|
|
||||||
group: "observability",
|
|
||||||
type: "string",
|
|
||||||
label: "Langfuse Public Key",
|
|
||||||
placeholder: "pk-lf-…",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "LANGFUSE_SECRET_KEY",
|
|
||||||
group: "observability",
|
|
||||||
type: "secret",
|
|
||||||
label: "Langfuse Secret Key",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "LANGFUSE_BASEURL",
|
|
||||||
group: "observability",
|
|
||||||
type: "string",
|
|
||||||
label: "Langfuse Base URL",
|
|
||||||
placeholder: "https://cloud.langfuse.com",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Quota ────────────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
key: "DAILY_REQUEST_LIMIT",
|
|
||||||
group: "quota",
|
|
||||||
type: "number",
|
|
||||||
label: "Daily Request Limit",
|
|
||||||
description: "Per IP per day.",
|
|
||||||
min: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "DAILY_TOKEN_LIMIT",
|
|
||||||
group: "quota",
|
|
||||||
type: "number",
|
|
||||||
label: "Daily Token Limit",
|
|
||||||
description: "Per IP per day.",
|
|
||||||
min: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "TPM_LIMIT",
|
|
||||||
group: "quota",
|
|
||||||
type: "number",
|
|
||||||
label: "Tokens Per Minute",
|
|
||||||
min: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "DYNAMODB_QUOTA_TABLE",
|
|
||||||
group: "quota",
|
|
||||||
type: "string",
|
|
||||||
label: "DynamoDB Table",
|
|
||||||
description: "Quota enforcement is disabled when empty.",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "DYNAMODB_REGION",
|
|
||||||
group: "quota",
|
|
||||||
type: "string",
|
|
||||||
label: "DynamoDB Region",
|
|
||||||
placeholder: "ap-northeast-1",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "QUOTA_TIMEZONE",
|
|
||||||
group: "quota",
|
|
||||||
type: "string",
|
|
||||||
label: "Quota Timezone",
|
|
||||||
description: "Timezone for the daily reset boundary.",
|
|
||||||
placeholder: "UTC",
|
|
||||||
restartRequired: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
|
|
||||||
SETTINGS_REGISTRY.map((def) => [def.key, def]),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
|
|
||||||
SETTING_GROUPS.map((g) => [
|
|
||||||
g.id,
|
|
||||||
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
// File-based admin settings, overlaid onto process.env (dotenv-style).
|
|
||||||
// Precedence: settings file > env var > built-in default.
|
|
||||||
// Keys are exactly the env var names.
|
|
||||||
|
|
||||||
interface SettingsFile {
|
|
||||||
version: 1
|
|
||||||
values: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Original env values snapshotted before the first overlay, so removing a
|
|
||||||
// key from the settings file restores the env default. null = was unset.
|
|
||||||
const originalEnv: Record<string, string | null> = {}
|
|
||||||
// Keys currently overlaid, so we can restore ones removed from the file.
|
|
||||||
let overlaidKeys = new Set<string>()
|
|
||||||
|
|
||||||
let cachedSettings: Record<string, string> | null = null
|
|
||||||
|
|
||||||
export function getSettingsPath(): string {
|
|
||||||
const custom = process.env.SETTINGS_FILE
|
|
||||||
if (custom && custom.trim().length > 0) return custom
|
|
||||||
return path.join(process.cwd(), "data", "settings.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadSettings(): Record<string, string> {
|
|
||||||
if (cachedSettings) return cachedSettings
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(getSettingsPath(), "utf8")
|
|
||||||
const parsed = JSON.parse(raw) as SettingsFile
|
|
||||||
// Keep only string values — a hand-edited or corrupted file could
|
|
||||||
// hold null/arrays/numbers that would otherwise be overlaid onto
|
|
||||||
// process.env and coerce to junk like "[object Object]".
|
|
||||||
const values: Record<string, string> = {}
|
|
||||||
const rawValues =
|
|
||||||
parsed &&
|
|
||||||
typeof parsed.values === "object" &&
|
|
||||||
parsed.values &&
|
|
||||||
!Array.isArray(parsed.values)
|
|
||||||
? parsed.values
|
|
||||||
: {}
|
|
||||||
for (const [key, value] of Object.entries(rawValues)) {
|
|
||||||
if (typeof value === "string") values[key] = value
|
|
||||||
}
|
|
||||||
cachedSettings = values
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code !== "ENOENT") {
|
|
||||||
console.error("[admin-settings] Failed to read settings file:", err)
|
|
||||||
}
|
|
||||||
cachedSettings = {}
|
|
||||||
}
|
|
||||||
return cachedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyToEnv(): void {
|
|
||||||
const values = loadSettings()
|
|
||||||
|
|
||||||
// Restore env for keys that were overlaid before but are now gone
|
|
||||||
for (const key of overlaidKeys) {
|
|
||||||
if (!(key in values)) {
|
|
||||||
const original = originalEnv[key]
|
|
||||||
if (original === null) delete process.env[key]
|
|
||||||
else process.env[key] = original
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
if (!(key in originalEnv)) {
|
|
||||||
originalEnv[key] = process.env[key] ?? null
|
|
||||||
}
|
|
||||||
process.env[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
overlaidKeys = new Set(Object.keys(values))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The effective env value if the file entry were removed (for fallback display)
|
|
||||||
export function getEnvFallback(key: string): string | null {
|
|
||||||
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
|
|
||||||
return process.env[key] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether a key's current value comes from the file, the environment, or is unset
|
|
||||||
export function getValueSource(key: string): "file" | "env" | "default" {
|
|
||||||
if (key in loadSettings()) return "file"
|
|
||||||
return getEnvFallback(key) !== null ? "env" : "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSettings(updates: Record<string, string | null>): void {
|
|
||||||
const current = { ...loadSettings() }
|
|
||||||
for (const [key, value] of Object.entries(updates)) {
|
|
||||||
if (value === null) delete current[key]
|
|
||||||
else current[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = getSettingsPath()
|
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
||||||
const tmpPath = `${filePath}.tmp`
|
|
||||||
const data: SettingsFile = { version: 1, values: current }
|
|
||||||
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
|
|
||||||
fs.renameSync(tmpPath, filePath)
|
|
||||||
|
|
||||||
cachedSettings = current
|
|
||||||
applyToEnv()
|
|
||||||
}
|
|
||||||
|
|
||||||
let writableCache: boolean | null = null
|
|
||||||
|
|
||||||
export function isSettingsWritable(): boolean {
|
|
||||||
if (writableCache !== null) return writableCache
|
|
||||||
try {
|
|
||||||
const dir = path.dirname(getSettingsPath())
|
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
|
||||||
fs.accessSync(dir, fs.constants.W_OK)
|
|
||||||
writableCache = true
|
|
||||||
} catch {
|
|
||||||
writableCache = false
|
|
||||||
}
|
|
||||||
return writableCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test-only: reset module state
|
|
||||||
export function _resetForTests(): void {
|
|
||||||
cachedSettings = null
|
|
||||||
writableCache = null
|
|
||||||
for (const key of overlaidKeys) {
|
|
||||||
const original = originalEnv[key]
|
|
||||||
if (original === null) delete process.env[key]
|
|
||||||
else if (original !== undefined) process.env[key] = original
|
|
||||||
}
|
|
||||||
overlaidKeys = new Set()
|
|
||||||
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import { createGateway, gateway } from "@ai-sdk/gateway"
|
|||||||
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
import { createVertex } from "@ai-sdk/google-vertex"
|
||||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
import { createOpenAI, openai } from "@ai-sdk/openai"
|
||||||
import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider"
|
|
||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||||
@@ -14,8 +13,6 @@ import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
|||||||
|
|
||||||
export type { ProviderName }
|
export type { ProviderName }
|
||||||
|
|
||||||
export const AIHUBMIX_APP_CODE = "MSBS9675"
|
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
providerOptions?: any
|
providerOptions?: any
|
||||||
@@ -32,7 +29,6 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
|||||||
"kimi",
|
"kimi",
|
||||||
"qiniu",
|
"qiniu",
|
||||||
"novita",
|
"novita",
|
||||||
"mimo",
|
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,18 +57,6 @@ export function normalizeMiniMaxBaseURL(rawUrl: string): {
|
|||||||
return { baseURL, isAnthropicCompatible }
|
return { baseURL, isAnthropicCompatible }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAihubmixStandardBaseURL(
|
|
||||||
rawUrl: string | null | undefined,
|
|
||||||
): boolean {
|
|
||||||
if (!rawUrl) return true
|
|
||||||
|
|
||||||
const baseURL = rawUrl.replace(/\/+$/, "")
|
|
||||||
return (
|
|
||||||
baseURL === "https://aihubmix.com" ||
|
|
||||||
baseURL === "https://aihubmix.com/v1"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientOverrides {
|
export interface ClientOverrides {
|
||||||
provider?: string | null
|
provider?: string | null
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
@@ -102,7 +86,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"azure",
|
"azure",
|
||||||
"bedrock",
|
"bedrock",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"aihubmix",
|
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"siliconflow",
|
"siliconflow",
|
||||||
"sglang",
|
"sglang",
|
||||||
@@ -117,8 +100,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"kimi",
|
"kimi",
|
||||||
"minimax",
|
"minimax",
|
||||||
"novita",
|
"novita",
|
||||||
"mimo",
|
|
||||||
"atlascloud",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
@@ -532,7 +513,6 @@ function buildProviderOptions(
|
|||||||
|
|
||||||
case "deepseek":
|
case "deepseek":
|
||||||
case "openrouter":
|
case "openrouter":
|
||||||
case "aihubmix":
|
|
||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
case "sglang":
|
case "sglang":
|
||||||
case "gateway":
|
case "gateway":
|
||||||
@@ -543,9 +523,7 @@ function buildProviderOptions(
|
|||||||
case "qwen":
|
case "qwen":
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "qiniu":
|
case "qiniu":
|
||||||
case "novita":
|
case "novita": {
|
||||||
case "atlascloud":
|
|
||||||
case "mimo": {
|
|
||||||
// These providers don't have reasoning configs in AI SDK yet
|
// These providers don't have reasoning configs in AI SDK yet
|
||||||
// Gateway passes through to underlying providers which handle their own configs
|
// Gateway passes through to underlying providers which handle their own configs
|
||||||
break
|
break
|
||||||
@@ -559,7 +537,7 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map of provider to required environment variable
|
// Map of provider to required environment variable
|
||||||
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
@@ -568,7 +546,6 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
azure: "AZURE_API_KEY",
|
azure: "AZURE_API_KEY",
|
||||||
ollama: null, // No credentials needed for local Ollama
|
ollama: null, // No credentials needed for local Ollama
|
||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
aihubmix: "AIHUBMIX_API_KEY",
|
|
||||||
deepseek: "DEEPSEEK_API_KEY",
|
deepseek: "DEEPSEEK_API_KEY",
|
||||||
siliconflow: "SILICONFLOW_API_KEY",
|
siliconflow: "SILICONFLOW_API_KEY",
|
||||||
sglang: "SGLANG_API_KEY",
|
sglang: "SGLANG_API_KEY",
|
||||||
@@ -582,8 +559,6 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
kimi: "KIMI_API_KEY",
|
kimi: "KIMI_API_KEY",
|
||||||
minimax: "MINIMAX_API_KEY",
|
minimax: "MINIMAX_API_KEY",
|
||||||
novita: "NOVITA_API_KEY",
|
novita: "NOVITA_API_KEY",
|
||||||
mimo: "MIMO_API_KEY",
|
|
||||||
atlascloud: "ATLASCLOUD_API_KEY",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -598,15 +573,7 @@ function detectProvider(): ProviderName | null {
|
|||||||
// Skip ollama - it doesn't require credentials
|
// Skip ollama - it doesn't require credentials
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY
|
if (process.env[envVar]) {
|
||||||
const hasCredential =
|
|
||||||
provider === "anthropic"
|
|
||||||
? !!(
|
|
||||||
process.env.ANTHROPIC_API_KEY ||
|
|
||||||
process.env.ANTHROPIC_AUTH_TOKEN
|
|
||||||
)
|
|
||||||
: !!process.env[envVar]
|
|
||||||
if (hasCredential) {
|
|
||||||
// Azure requires additional config (baseURL or resourceName)
|
// Azure requires additional config (baseURL or resourceName)
|
||||||
if (provider === "azure") {
|
if (provider === "azure") {
|
||||||
const hasBaseUrl = !!process.env.AZURE_BASE_URL
|
const hasBaseUrl = !!process.env.AZURE_BASE_URL
|
||||||
@@ -648,18 +615,6 @@ function validateProviderCredentials(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY
|
|
||||||
if (provider === "anthropic" && !customApiKeyEnv) {
|
|
||||||
const hasCredential = !!(
|
|
||||||
process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
|
|
||||||
)
|
|
||||||
if (!hasCredential) {
|
|
||||||
throw new Error(
|
|
||||||
`Either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable is required for anthropic provider. ` +
|
|
||||||
`Please set one in your .env.local file.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use custom env var name if provided, otherwise use default
|
// Use custom env var name if provided, otherwise use default
|
||||||
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
||||||
if (requiredVar && !process.env[requiredVar]) {
|
if (requiredVar && !process.env[requiredVar]) {
|
||||||
@@ -668,7 +623,6 @@ function validateProviderCredentials(
|
|||||||
`Please set it in your .env.local file.`,
|
`Please set it in your .env.local file.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key
|
// Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key
|
||||||
if (provider === "azure") {
|
if (provider === "azure") {
|
||||||
@@ -687,7 +641,7 @@ function validateProviderCredentials(
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope)
|
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
|
||||||
* - AI_MODEL: The model ID/name for the selected provider
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -699,7 +653,6 @@ function validateProviderCredentials(
|
|||||||
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
|
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
|
||||||
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
|
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
|
||||||
* - OPENROUTER_API_KEY: OpenRouter API key
|
* - OPENROUTER_API_KEY: OpenRouter API key
|
||||||
* - AIHUBMIX_API_KEY: AIHubMix API key
|
|
||||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||||
@@ -736,10 +689,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use client override if provided, otherwise fall back to env vars.
|
// Use client override if provided, otherwise fall back to env vars
|
||||||
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
|
const modelId = overrides?.modelId || process.env.AI_MODEL
|
||||||
const envModel = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
|
|
||||||
const modelId = overrides?.modelId || envModel
|
|
||||||
|
|
||||||
if (!modelId) {
|
if (!modelId) {
|
||||||
if (isClientOverride) {
|
if (isClientOverride) {
|
||||||
@@ -789,7 +740,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
||||||
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
||||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||||
`- AIHUBMIX_API_KEY for AIHubMix\n` +
|
|
||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||||
`- SGLANG_API_KEY for SGLang\n` +
|
`- SGLANG_API_KEY for SGLang\n` +
|
||||||
@@ -895,16 +845,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
serverBaseUrl,
|
serverBaseUrl,
|
||||||
"https://api.anthropic.com/v1",
|
"https://api.anthropic.com/v1",
|
||||||
)
|
)
|
||||||
// Anthropic supports two auth methods (mutually exclusive):
|
|
||||||
// - apiKey: sends as `x-api-key` header
|
|
||||||
// - authToken: sends as `Authorization: Bearer <token>` header
|
|
||||||
// Prefer apiKey if present (including client overrides); fall back
|
|
||||||
// to ANTHROPIC_AUTH_TOKEN env var only when no apiKey is available.
|
|
||||||
const authToken = !apiKey
|
|
||||||
? process.env.ANTHROPIC_AUTH_TOKEN
|
|
||||||
: undefined
|
|
||||||
const customProvider = createAnthropic({
|
const customProvider = createAnthropic({
|
||||||
...(authToken ? { authToken } : { apiKey }),
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
headers: ANTHROPIC_BETA_HEADERS,
|
headers: ANTHROPIC_BETA_HEADERS,
|
||||||
})
|
})
|
||||||
@@ -1032,42 +974,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "aihubmix": {
|
|
||||||
const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY")
|
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
|
||||||
overrides,
|
|
||||||
"AIHUBMIX_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
PROVIDER_INFO.aihubmix.defaultBaseUrl,
|
|
||||||
)
|
|
||||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
|
||||||
|
|
||||||
if (
|
|
||||||
isAihubmixStandardBaseURL(baseURL) ||
|
|
||||||
baseURL === defaultBaseURL
|
|
||||||
) {
|
|
||||||
const aihubmixProvider =
|
|
||||||
overrides?.apiKey || apiKey
|
|
||||||
? createAihubmix({
|
|
||||||
apiKey,
|
|
||||||
appCode: AIHUBMIX_APP_CODE,
|
|
||||||
})
|
|
||||||
: aihubmix
|
|
||||||
model = aihubmixProvider(modelId)
|
|
||||||
} else {
|
|
||||||
const aihubmixCompatibleProvider = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL,
|
|
||||||
})
|
|
||||||
model = aihubmixCompatibleProvider.chat(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "deepseek": {
|
case "deepseek": {
|
||||||
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
@@ -1353,28 +1259,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "mimo": {
|
|
||||||
const apiKey = resolveApiKey(overrides, "MIMO_API_KEY")
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
resolveBaseUrlEnv(overrides, "MIMO_BASE_URL"),
|
|
||||||
PROVIDER_INFO.mimo?.defaultBaseUrl,
|
|
||||||
)
|
|
||||||
// Use createDeepSeek to properly handle reasoning_content for MiMo
|
|
||||||
// thinking models (e.g., mimo-v2.5-pro). MiMo's API requires
|
|
||||||
// reasoning_content to be passed back during multi-turn tool calls
|
|
||||||
// (returns 400 otherwise), same convention as DeepSeek and Kimi.
|
|
||||||
const mimoProvider = createDeepSeek({ apiKey, baseURL })
|
|
||||||
model = mimoProvider(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "glm":
|
case "glm":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
case "qiniu":
|
case "qiniu":
|
||||||
case "novita":
|
case "novita": {
|
||||||
case "atlascloud": {
|
|
||||||
const envVar = PROVIDER_ENV_VARS[provider]
|
const envVar = PROVIDER_ENV_VARS[provider]
|
||||||
if (!envVar) {
|
if (!envVar) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1405,7 +1293,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
overrides?.apiKey,
|
overrides?.apiKey,
|
||||||
overrides?.baseUrl,
|
overrides?.baseUrl,
|
||||||
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
|
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
|
||||||
PROVIDER_INFO.kimi?.defaultBaseUrl,
|
PROVIDER_INFO["kimi"]?.defaultBaseUrl,
|
||||||
)
|
)
|
||||||
// Use createDeepSeek to properly handle reasoning_content for Kimi
|
// Use createDeepSeek to properly handle reasoning_content for Kimi
|
||||||
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
|
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
|
||||||
@@ -1418,7 +1306,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,19 +1332,76 @@ export function supportsPromptCaching(modelId: string): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model supports image/vision input.
|
||||||
|
* Some models silently drop image parts without error (AI SDK warning only).
|
||||||
|
*/
|
||||||
|
export function supportsImageInput(modelId: string): boolean {
|
||||||
|
const lowerModelId = modelId.toLowerCase()
|
||||||
|
|
||||||
|
// Helper to check if model has vision capability indicator
|
||||||
|
const hasVisionIndicator =
|
||||||
|
lowerModelId.includes("vision") || lowerModelId.includes("vl")
|
||||||
|
|
||||||
|
// Models that DON'T support image/vision input (unless vision variant)
|
||||||
|
// Kimi K2 doesn't support images, but K2.5 does
|
||||||
|
// Only block kimi-k2 specifically, not other Kimi models
|
||||||
|
if (
|
||||||
|
(lowerModelId.includes("kimi-k2") ||
|
||||||
|
lowerModelId.includes("kimi_k2")) &&
|
||||||
|
!hasVisionIndicator &&
|
||||||
|
!lowerModelId.includes("2.5") &&
|
||||||
|
!lowerModelId.includes("k2.5")
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moonshot text models (moonshot-v1 series are text-only)
|
||||||
|
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniMax text models (MiniMax-M2.x series are text-only)
|
||||||
|
if (lowerModelId.includes("minimax") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepSeek text models (not vision variants)
|
||||||
|
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qwen text models (not vision variants like qwen-vl)
|
||||||
|
// Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
|
||||||
|
// QvQ (Qwen Visual QA) models are vision models — exclude them even when prefixed with "qwen/"
|
||||||
|
if (
|
||||||
|
lowerModelId.includes("qwen") &&
|
||||||
|
!hasVisionIndicator &&
|
||||||
|
!lowerModelId.includes("qwen3.5") &&
|
||||||
|
!lowerModelId.includes("qvq")
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLM text models (not vision variants)
|
||||||
|
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
|
||||||
|
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
|
||||||
|
if (!/[\d.]v/.test(lowerModelId)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: assume model supports images
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the AI model for diagram validation.
|
* Get the AI model for diagram validation.
|
||||||
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
|
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
|
||||||
*
|
* Throws if the model doesn't support image input.
|
||||||
* Note: we no longer guess whether the model supports image input from its
|
|
||||||
* name — that heuristic misfired on newer models (see issue #874). If a
|
|
||||||
* configured validation model can't handle images, the API call simply errors
|
|
||||||
* and the validate-diagram route falls back to "valid".
|
|
||||||
*/
|
*/
|
||||||
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
||||||
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
|
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
|
||||||
const envFallback = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
|
|
||||||
const modelId = process.env.VALIDATION_MODEL || envFallback
|
|
||||||
|
|
||||||
if (!modelId) {
|
if (!modelId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1464,6 +1409,12 @@ export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!supportsImageInput(modelId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const { model } = getAIModel({ modelId })
|
const { model } = getAIModel({ modelId })
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
|
|
||||||
|
|
||||||
const NON_CHAT_MODEL_TYPES = new Set([
|
|
||||||
"embedding",
|
|
||||||
"image_generation",
|
|
||||||
"rerank",
|
|
||||||
"transcription",
|
|
||||||
"tts",
|
|
||||||
"video",
|
|
||||||
])
|
|
||||||
|
|
||||||
type AihubmixModelListPayload = {
|
|
||||||
data?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
type AihubmixModelRecord = {
|
|
||||||
model_id?: unknown
|
|
||||||
types?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModelTypes(types: unknown): Set<string> {
|
|
||||||
if (typeof types !== "string") {
|
|
||||||
return new Set()
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Set(
|
|
||||||
types
|
|
||||||
.split(",")
|
|
||||||
.map((type) => type.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChatModel(record: AihubmixModelRecord): record is {
|
|
||||||
model_id: string
|
|
||||||
types: string
|
|
||||||
} {
|
|
||||||
if (typeof record.model_id !== "string" || !record.model_id.trim()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const types = getModelTypes(record.types)
|
|
||||||
if (!types.has("llm")) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractAihubmixModelIds(payload: unknown): string[] {
|
|
||||||
const data = (payload as AihubmixModelListPayload)?.data
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const modelIds: string[] = []
|
|
||||||
|
|
||||||
for (const item of data) {
|
|
||||||
if (!item || typeof item !== "object") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = item as AihubmixModelRecord
|
|
||||||
if (!isChatModel(record)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelId = record.model_id.trim()
|
|
||||||
if (seen.has(modelId)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(modelId)
|
|
||||||
modelIds.push(modelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return modelIds
|
|
||||||
}
|
|
||||||
@@ -34,8 +34,7 @@
|
|||||||
"glm": "GLM",
|
"glm": "GLM",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"kimi": "Kimi",
|
"kimi": "Kimi",
|
||||||
"qiniu": "Qiniu",
|
"qiniu": "Qiniu"
|
||||||
"mimo": "MiMo (Xiaomi)"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Describe your diagram or upload a file...",
|
"placeholder": "Describe your diagram or upload a file...",
|
||||||
@@ -372,7 +371,6 @@
|
|||||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||||
"customEndpoint": "Custom endpoint URL",
|
"customEndpoint": "Custom endpoint URL",
|
||||||
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
||||||
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
|
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"customModelId": "Custom model ID...",
|
"customModelId": "Custom model ID...",
|
||||||
"allAdded": "All added",
|
"allAdded": "All added",
|
||||||
@@ -404,152 +402,6 @@
|
|||||||
"showUnvalidatedModels": "Show unvalidated models",
|
"showUnvalidatedModels": "Show unvalidated models",
|
||||||
"allModelsShown": "All models are shown (including unvalidated)",
|
"allModelsShown": "All models are shown (including unvalidated)",
|
||||||
"unvalidatedModelWarning": "This model has not been validated",
|
"unvalidatedModelWarning": "This model has not been validated",
|
||||||
"serverDefaultModel": "Server default model",
|
"serverDefaultModel": "Server default model"
|
||||||
"showValue": "Show value",
|
|
||||||
"hideValue": "Hide value"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"title": "Admin Settings",
|
|
||||||
"loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable) to manage server settings.",
|
|
||||||
"password": "Password",
|
|
||||||
"signIn": "Sign In",
|
|
||||||
"signingIn": "Signing In…",
|
|
||||||
"loginFailed": "Login failed",
|
|
||||||
"precedence": "File overrides env · env overrides defaults",
|
|
||||||
"notWritable": "The settings file is not writable on this deployment (serverless platforms have no persistent disk). Settings are shown read-only — configure via environment variables instead.",
|
|
||||||
"settingGroups": "Setting groups",
|
|
||||||
"enabled": "Enabled",
|
|
||||||
"disabled": "Disabled",
|
|
||||||
"enableGroup": "Enable {group}",
|
|
||||||
"unsavedChanges": "Unsaved changes",
|
|
||||||
"saved": "Settings saved. Changes apply immediately.",
|
|
||||||
"saveFailed": "Save failed. Check your connection and try again.",
|
|
||||||
"invalidSettings": "Some settings are invalid.",
|
|
||||||
"discard": "Discard",
|
|
||||||
"saveChanges": "Save Changes",
|
|
||||||
"saving": "Saving…",
|
|
||||||
"sourceSaved": "Saved",
|
|
||||||
"sourceEnv": "Env",
|
|
||||||
"sourceSavedTitle": "Set in the admin settings file",
|
|
||||||
"sourceEnvTitle": "Set by an environment variable",
|
|
||||||
"restartRequired": "Restart Required",
|
|
||||||
"modified": "Modified",
|
|
||||||
"notSet": "Not set",
|
|
||||||
"savedReplace": "Saved ({hint}) — type to replace",
|
|
||||||
"showValue": "Show value",
|
|
||||||
"hideValue": "Hide value",
|
|
||||||
"removeValue": "Remove value",
|
|
||||||
"removeValueTitle": "Remove the stored value",
|
|
||||||
"resetToDefault": "Reset to default",
|
|
||||||
"models": "Models",
|
|
||||||
"modelsDescription": "Server-side providers and models available to all users — no personal API key needed. The default provider's first model is used when users don't pick one.",
|
|
||||||
"addProviderHint": "Add a provider to offer server-side models to all users.",
|
|
||||||
"selectProviderHint": "Select or add a provider to configure its credentials and models.",
|
|
||||||
"addProviderToOfferModels": "Add at least one model to expose this provider to users.",
|
|
||||||
"managedViaEnv": "(managed via env)",
|
|
||||||
"envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json — read-only here. Edit the environment configuration to change it.",
|
|
||||||
"defaultModel": "Default Model",
|
|
||||||
"noModelsConfigured": "No models configured",
|
|
||||||
"modelCount": "{count} model",
|
|
||||||
"modelCountPlural": "{count} models",
|
|
||||||
"default": "Default",
|
|
||||||
"setAsDefault": "Set as default provider",
|
|
||||||
"defaultProvider": "Default provider",
|
|
||||||
"modelIdPlaceholder": "Model ID…",
|
|
||||||
"addModel": "Add model",
|
|
||||||
"suggested": "Suggested",
|
|
||||||
"test": "Test",
|
|
||||||
"testOk": "OK ({ms}ms)",
|
|
||||||
"testFailed": "Failed",
|
|
||||||
"removeModel": "Remove {model}",
|
|
||||||
"deleteProviderTitle": "Delete {name}?",
|
|
||||||
"deleteProviderDesc": "Its credentials and models will be removed from the server after you save.",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"delete": "Delete",
|
|
||||||
"groups": {
|
|
||||||
"generation": {
|
|
||||||
"title": "Generation",
|
|
||||||
"description": "Output parameters applied to all chat requests."
|
|
||||||
},
|
|
||||||
"access": {
|
|
||||||
"title": "Access Control",
|
|
||||||
"description": "Restrict who can use this deployment."
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"title": "Features",
|
|
||||||
"description": "Optional features and security toggles."
|
|
||||||
},
|
|
||||||
"observability": {
|
|
||||||
"title": "Observability",
|
|
||||||
"description": "Langfuse tracing for LLM calls."
|
|
||||||
},
|
|
||||||
"quota": {
|
|
||||||
"title": "Quota & Rate Limits",
|
|
||||||
"description": "Per-IP usage limits. Enforcement requires a DynamoDB table."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"TEMPERATURE": {
|
|
||||||
"label": "Temperature",
|
|
||||||
"description": "Leave unset for reasoning models that reject temperature."
|
|
||||||
},
|
|
||||||
"MAX_OUTPUT_TOKENS": {
|
|
||||||
"label": "Max Output Tokens"
|
|
||||||
},
|
|
||||||
"ACCESS_CODE_LIST": {
|
|
||||||
"label": "Access Codes",
|
|
||||||
"description": "Comma-separated list. Users must enter one to chat. Empty = open access."
|
|
||||||
},
|
|
||||||
"ENABLE_VLM_VALIDATION": {
|
|
||||||
"label": "VLM Diagram Validation",
|
|
||||||
"description": "Visually validate generated diagrams with a vision model."
|
|
||||||
},
|
|
||||||
"VALIDATION_MODEL": {
|
|
||||||
"label": "Validation Model",
|
|
||||||
"description": "Falls back to the default AI model when empty."
|
|
||||||
},
|
|
||||||
"VALIDATION_TIMEOUT": {
|
|
||||||
"label": "Validation Timeout (ms)"
|
|
||||||
},
|
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
|
||||||
"label": "History XML Compression",
|
|
||||||
"description": "Replace old diagram XML in history with placeholders."
|
|
||||||
},
|
|
||||||
"ALLOW_PRIVATE_URLS": {
|
|
||||||
"label": "Allow Private URLs",
|
|
||||||
"description": "Turn off to block requests to private IPs and internal hostnames (SSRF protection)."
|
|
||||||
},
|
|
||||||
"LANGFUSE_PUBLIC_KEY": {
|
|
||||||
"label": "Langfuse Public Key"
|
|
||||||
},
|
|
||||||
"LANGFUSE_SECRET_KEY": {
|
|
||||||
"label": "Langfuse Secret Key"
|
|
||||||
},
|
|
||||||
"LANGFUSE_BASEURL": {
|
|
||||||
"label": "Langfuse Base URL"
|
|
||||||
},
|
|
||||||
"DAILY_REQUEST_LIMIT": {
|
|
||||||
"label": "Daily Request Limit",
|
|
||||||
"description": "Per IP per day."
|
|
||||||
},
|
|
||||||
"DAILY_TOKEN_LIMIT": {
|
|
||||||
"label": "Daily Token Limit",
|
|
||||||
"description": "Per IP per day."
|
|
||||||
},
|
|
||||||
"TPM_LIMIT": {
|
|
||||||
"label": "Tokens Per Minute"
|
|
||||||
},
|
|
||||||
"DYNAMODB_QUOTA_TABLE": {
|
|
||||||
"label": "DynamoDB Table",
|
|
||||||
"description": "Quota enforcement is disabled when empty."
|
|
||||||
},
|
|
||||||
"DYNAMODB_REGION": {
|
|
||||||
"label": "DynamoDB Region"
|
|
||||||
},
|
|
||||||
"QUOTA_TIMEZONE": {
|
|
||||||
"label": "Quota Timezone",
|
|
||||||
"description": "Timezone for the daily reset boundary."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,7 @@
|
|||||||
"glm": "GLM",
|
"glm": "GLM",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"kimi": "Kimi",
|
"kimi": "Kimi",
|
||||||
"qiniu": "Qiniu",
|
"qiniu": "Qiniu"
|
||||||
"mimo": "MiMo (Xiaomi)"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||||
@@ -326,7 +325,6 @@
|
|||||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||||
"customEndpoint": "カスタムエンドポイント URL",
|
"customEndpoint": "カスタムエンドポイント URL",
|
||||||
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||||
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
|
|
||||||
"models": "モデル",
|
"models": "モデル",
|
||||||
"customModelId": "カスタムモデル ID...",
|
"customModelId": "カスタムモデル ID...",
|
||||||
"allAdded": "すべて追加済み",
|
"allAdded": "すべて追加済み",
|
||||||
@@ -358,9 +356,7 @@
|
|||||||
"showUnvalidatedModels": "未検証のモデルを表示",
|
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||||
"serverDefaultModel": "サーバーデフォルトモデル",
|
"serverDefaultModel": "サーバーデフォルトモデル"
|
||||||
"showValue": "値を表示",
|
|
||||||
"hideValue": "値を非表示"
|
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"title": "マイテンプレート",
|
"title": "マイテンプレート",
|
||||||
@@ -407,149 +403,5 @@
|
|||||||
"importNoFile": "JSON ファイルを選択してください",
|
"importNoFile": "JSON ファイルを選択してください",
|
||||||
"importFailed": "インポートに失敗しました:{error}",
|
"importFailed": "インポートに失敗しました:{error}",
|
||||||
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
|
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"title": "管理者設定",
|
|
||||||
"loginPrompt": "サーバー設定を管理するには、管理者パスワード(ADMIN_PASSWORD 環境変数)を入力してください。",
|
|
||||||
"password": "パスワード",
|
|
||||||
"signIn": "ログイン",
|
|
||||||
"signingIn": "ログイン中…",
|
|
||||||
"loginFailed": "ログインに失敗しました",
|
|
||||||
"precedence": "ファイルが環境変数を上書き · 環境変数がデフォルトを上書き",
|
|
||||||
"notWritable": "このデプロイ環境では設定ファイルに書き込めません(サーバーレス環境には永続ディスクがありません)。設定は読み取り専用で表示されます——代わりに環境変数で構成してください。",
|
|
||||||
"settingGroups": "設定グループ",
|
|
||||||
"enabled": "有効",
|
|
||||||
"disabled": "無効",
|
|
||||||
"enableGroup": "{group} を有効化",
|
|
||||||
"unsavedChanges": "未保存の変更があります",
|
|
||||||
"saved": "設定を保存しました。変更は即座に反映されます。",
|
|
||||||
"saveFailed": "保存に失敗しました。接続を確認して再試行してください。",
|
|
||||||
"invalidSettings": "一部の設定が無効です。",
|
|
||||||
"discard": "破棄",
|
|
||||||
"saveChanges": "変更を保存",
|
|
||||||
"saving": "保存中…",
|
|
||||||
"sourceSaved": "保存済み",
|
|
||||||
"sourceEnv": "環境変数",
|
|
||||||
"sourceSavedTitle": "管理者設定ファイルで設定",
|
|
||||||
"sourceEnvTitle": "環境変数で設定",
|
|
||||||
"restartRequired": "再起動が必要",
|
|
||||||
"modified": "変更済み",
|
|
||||||
"notSet": "未設定",
|
|
||||||
"savedReplace": "保存済み({hint})——入力して置き換え",
|
|
||||||
"showValue": "値を表示",
|
|
||||||
"hideValue": "値を非表示",
|
|
||||||
"removeValue": "値を削除",
|
|
||||||
"removeValueTitle": "保存された値を削除",
|
|
||||||
"resetToDefault": "デフォルトに戻す",
|
|
||||||
"models": "モデル",
|
|
||||||
"modelsDescription": "全ユーザーが利用できるサーバー側のプロバイダーとモデル——個人の API キーは不要です。ユーザーがモデルを選択しない場合、デフォルトプロバイダーの最初のモデルが使用されます。",
|
|
||||||
"addProviderHint": "プロバイダーを追加して、全ユーザーにサーバー側モデルを提供します。",
|
|
||||||
"selectProviderHint": "プロバイダーを選択または追加して、その資格情報とモデルを構成します。",
|
|
||||||
"addProviderToOfferModels": "ユーザーにこのプロバイダーを公開するには、モデルを少なくとも 1 つ追加してください。",
|
|
||||||
"managedViaEnv": "(環境変数で管理)",
|
|
||||||
"envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義——ここでは読み取り専用です。変更するには環境構成を編集してください。",
|
|
||||||
"defaultModel": "デフォルトモデル",
|
|
||||||
"noModelsConfigured": "モデルが構成されていません",
|
|
||||||
"modelCount": "{count} 個のモデル",
|
|
||||||
"modelCountPlural": "{count} 個のモデル",
|
|
||||||
"default": "デフォルト",
|
|
||||||
"setAsDefault": "デフォルトプロバイダーに設定",
|
|
||||||
"defaultProvider": "デフォルトプロバイダー",
|
|
||||||
"modelIdPlaceholder": "モデル ID…",
|
|
||||||
"addModel": "モデルを追加",
|
|
||||||
"suggested": "おすすめ",
|
|
||||||
"test": "テスト",
|
|
||||||
"testOk": "正常({ms}ms)",
|
|
||||||
"testFailed": "失敗",
|
|
||||||
"removeModel": "{model} を削除",
|
|
||||||
"deleteProviderTitle": "{name} を削除しますか?",
|
|
||||||
"deleteProviderDesc": "保存後、その資格情報とモデルはサーバーから削除されます。",
|
|
||||||
"cancel": "キャンセル",
|
|
||||||
"delete": "削除",
|
|
||||||
"groups": {
|
|
||||||
"generation": {
|
|
||||||
"title": "生成",
|
|
||||||
"description": "すべてのチャットリクエストに適用される出力パラメーター。"
|
|
||||||
},
|
|
||||||
"access": {
|
|
||||||
"title": "アクセス制御",
|
|
||||||
"description": "このデプロイを使用できるユーザーを制限します。"
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"title": "機能",
|
|
||||||
"description": "オプション機能とセキュリティの切り替え。"
|
|
||||||
},
|
|
||||||
"observability": {
|
|
||||||
"title": "オブザーバビリティ",
|
|
||||||
"description": "LLM 呼び出しの Langfuse トレース。"
|
|
||||||
},
|
|
||||||
"quota": {
|
|
||||||
"title": "クォータとレート制限",
|
|
||||||
"description": "IP ごとの使用制限。強制には DynamoDB テーブルが必要です。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"TEMPERATURE": {
|
|
||||||
"label": "温度",
|
|
||||||
"description": "温度を受け付けない推論モデルの場合は未設定のままにしてください。"
|
|
||||||
},
|
|
||||||
"MAX_OUTPUT_TOKENS": {
|
|
||||||
"label": "最大出力トークン数"
|
|
||||||
},
|
|
||||||
"ACCESS_CODE_LIST": {
|
|
||||||
"label": "アクセスコード",
|
|
||||||
"description": "カンマ区切りのリスト。チャットにはいずれかの入力が必要です。空 = オープンアクセス。"
|
|
||||||
},
|
|
||||||
"ENABLE_VLM_VALIDATION": {
|
|
||||||
"label": "VLM 図検証",
|
|
||||||
"description": "ビジョンモデルで生成された図を視覚的に検証します。"
|
|
||||||
},
|
|
||||||
"VALIDATION_MODEL": {
|
|
||||||
"label": "検証モデル",
|
|
||||||
"description": "空の場合はデフォルトの AI モデルにフォールバックします。"
|
|
||||||
},
|
|
||||||
"VALIDATION_TIMEOUT": {
|
|
||||||
"label": "検証タイムアウト(ms)"
|
|
||||||
},
|
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
|
||||||
"label": "履歴 XML 圧縮",
|
|
||||||
"description": "履歴内の古い図 XML をプレースホルダーで置き換えます。"
|
|
||||||
},
|
|
||||||
"ALLOW_PRIVATE_URLS": {
|
|
||||||
"label": "プライベート URL を許可",
|
|
||||||
"description": "オフにすると、プライベート IP や内部ホスト名へのリクエストをブロックします(SSRF 保護)。"
|
|
||||||
},
|
|
||||||
"LANGFUSE_PUBLIC_KEY": {
|
|
||||||
"label": "Langfuse Public Key"
|
|
||||||
},
|
|
||||||
"LANGFUSE_SECRET_KEY": {
|
|
||||||
"label": "Langfuse Secret Key"
|
|
||||||
},
|
|
||||||
"LANGFUSE_BASEURL": {
|
|
||||||
"label": "Langfuse Base URL"
|
|
||||||
},
|
|
||||||
"DAILY_REQUEST_LIMIT": {
|
|
||||||
"label": "1 日あたりのリクエスト上限",
|
|
||||||
"description": "IP ごと 1 日あたり。"
|
|
||||||
},
|
|
||||||
"DAILY_TOKEN_LIMIT": {
|
|
||||||
"label": "1 日あたりのトークン上限",
|
|
||||||
"description": "IP ごと 1 日あたり。"
|
|
||||||
},
|
|
||||||
"TPM_LIMIT": {
|
|
||||||
"label": "1 分あたりのトークン数"
|
|
||||||
},
|
|
||||||
"DYNAMODB_QUOTA_TABLE": {
|
|
||||||
"label": "DynamoDB テーブル",
|
|
||||||
"description": "空の場合、クォータの強制は無効になります。"
|
|
||||||
},
|
|
||||||
"DYNAMODB_REGION": {
|
|
||||||
"label": "DynamoDB リージョン"
|
|
||||||
},
|
|
||||||
"QUOTA_TIMEZONE": {
|
|
||||||
"label": "クォータタイムゾーン",
|
|
||||||
"description": "1 日のリセット境界に使用するタイムゾーン。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,7 @@
|
|||||||
"glm": "GLM",
|
"glm": "GLM",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"kimi": "Kimi",
|
"kimi": "Kimi",
|
||||||
"qiniu": "Qiniu",
|
"qiniu": "Qiniu"
|
||||||
"mimo": "MiMo (小米)"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的圖表或上傳檔案...",
|
"placeholder": "描述您的圖表或上傳檔案...",
|
||||||
@@ -372,7 +371,6 @@
|
|||||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||||
"customEndpoint": "自訂端點 URL",
|
"customEndpoint": "自訂端點 URL",
|
||||||
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||||
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
|
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自訂模型 ID...",
|
"customModelId": "自訂模型 ID...",
|
||||||
"allAdded": "已全部新增",
|
"allAdded": "已全部新增",
|
||||||
@@ -404,152 +402,6 @@
|
|||||||
"showUnvalidatedModels": "顯示未驗證的模型",
|
"showUnvalidatedModels": "顯示未驗證的模型",
|
||||||
"allModelsShown": "顯示所有模型(包括未驗證的)",
|
"allModelsShown": "顯示所有模型(包括未驗證的)",
|
||||||
"unvalidatedModelWarning": "此模型尚未驗證",
|
"unvalidatedModelWarning": "此模型尚未驗證",
|
||||||
"serverDefaultModel": "伺服器預設模型",
|
"serverDefaultModel": "伺服器預設模型"
|
||||||
"showValue": "顯示值",
|
|
||||||
"hideValue": "隱藏值"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"title": "管理員設定",
|
|
||||||
"loginPrompt": "輸入管理員密碼(即 ADMIN_PASSWORD 環境變數)以管理伺服器設定。",
|
|
||||||
"password": "密碼",
|
|
||||||
"signIn": "登入",
|
|
||||||
"signingIn": "正在登入…",
|
|
||||||
"loginFailed": "登入失敗",
|
|
||||||
"precedence": "檔案覆蓋環境變數 · 環境變數覆蓋預設值",
|
|
||||||
"notWritable": "此部署環境下設定檔不可寫入(無伺服器平台沒有持久化磁碟)。設定以唯讀方式顯示——請改用環境變數進行設定。",
|
|
||||||
"settingGroups": "設定分組",
|
|
||||||
"enabled": "已啟用",
|
|
||||||
"disabled": "已停用",
|
|
||||||
"enableGroup": "啟用 {group}",
|
|
||||||
"unsavedChanges": "有未儲存的變更",
|
|
||||||
"saved": "設定已儲存,變更立即生效。",
|
|
||||||
"saveFailed": "儲存失敗。請檢查網路連線後重試。",
|
|
||||||
"invalidSettings": "部分設定無效。",
|
|
||||||
"discard": "捨棄",
|
|
||||||
"saveChanges": "儲存變更",
|
|
||||||
"saving": "正在儲存…",
|
|
||||||
"sourceSaved": "已儲存",
|
|
||||||
"sourceEnv": "環境變數",
|
|
||||||
"sourceSavedTitle": "在管理員設定檔中設定",
|
|
||||||
"sourceEnvTitle": "透過環境變數設定",
|
|
||||||
"restartRequired": "需要重新啟動",
|
|
||||||
"modified": "已修改",
|
|
||||||
"notSet": "未設定",
|
|
||||||
"savedReplace": "已儲存({hint})——輸入以取代",
|
|
||||||
"showValue": "顯示值",
|
|
||||||
"hideValue": "隱藏值",
|
|
||||||
"removeValue": "移除值",
|
|
||||||
"removeValueTitle": "移除已儲存的值",
|
|
||||||
"resetToDefault": "重設為預設",
|
|
||||||
"models": "模型",
|
|
||||||
"modelsDescription": "面向所有使用者的伺服器端 provider 與模型——無需個人 API 金鑰。當使用者未選擇模型時,使用預設 provider 的第一個模型。",
|
|
||||||
"addProviderHint": "新增一個 provider,為所有使用者提供伺服器端模型。",
|
|
||||||
"selectProviderHint": "選擇或新增一個 provider 以設定其憑證和模型。",
|
|
||||||
"addProviderToOfferModels": "至少新增一個模型,才能向使用者開放此 provider。",
|
|
||||||
"managedViaEnv": "(透過環境變數管理)",
|
|
||||||
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定義——此處唯讀。請編輯環境設定以變更。",
|
|
||||||
"defaultModel": "預設模型",
|
|
||||||
"noModelsConfigured": "未設定模型",
|
|
||||||
"modelCount": "{count} 個模型",
|
|
||||||
"modelCountPlural": "{count} 個模型",
|
|
||||||
"default": "預設",
|
|
||||||
"setAsDefault": "設為預設 provider",
|
|
||||||
"defaultProvider": "預設 provider",
|
|
||||||
"modelIdPlaceholder": "模型 ID…",
|
|
||||||
"addModel": "新增模型",
|
|
||||||
"suggested": "推薦",
|
|
||||||
"test": "測試",
|
|
||||||
"testOk": "正常({ms} 毫秒)",
|
|
||||||
"testFailed": "失敗",
|
|
||||||
"removeModel": "移除 {model}",
|
|
||||||
"deleteProviderTitle": "刪除 {name}?",
|
|
||||||
"deleteProviderDesc": "儲存後,其憑證和模型將從伺服器上移除。",
|
|
||||||
"cancel": "取消",
|
|
||||||
"delete": "刪除",
|
|
||||||
"groups": {
|
|
||||||
"generation": {
|
|
||||||
"title": "生成",
|
|
||||||
"description": "套用於所有聊天請求的輸出參數。"
|
|
||||||
},
|
|
||||||
"access": {
|
|
||||||
"title": "存取控制",
|
|
||||||
"description": "限制誰可以使用此部署。"
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"title": "功能",
|
|
||||||
"description": "選用功能和安全開關。"
|
|
||||||
},
|
|
||||||
"observability": {
|
|
||||||
"title": "可觀測性",
|
|
||||||
"description": "對 LLM 呼叫進行 Langfuse 追蹤。"
|
|
||||||
},
|
|
||||||
"quota": {
|
|
||||||
"title": "配額與速率限制",
|
|
||||||
"description": "按 IP 的用量限制。強制執行需要 DynamoDB 表。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"TEMPERATURE": {
|
|
||||||
"label": "溫度",
|
|
||||||
"description": "對於拒絕溫度參數的推理模型,請留空。"
|
|
||||||
},
|
|
||||||
"MAX_OUTPUT_TOKENS": {
|
|
||||||
"label": "最大輸出 token 數"
|
|
||||||
},
|
|
||||||
"ACCESS_CODE_LIST": {
|
|
||||||
"label": "存取碼",
|
|
||||||
"description": "以逗號分隔的清單。使用者需輸入其中之一才能聊天。留空 = 開放存取。"
|
|
||||||
},
|
|
||||||
"ENABLE_VLM_VALIDATION": {
|
|
||||||
"label": "VLM 圖表驗證",
|
|
||||||
"description": "使用視覺模型對產生的圖表進行視覺化驗證。"
|
|
||||||
},
|
|
||||||
"VALIDATION_MODEL": {
|
|
||||||
"label": "驗證模型",
|
|
||||||
"description": "留空時回退到預設 AI 模型。"
|
|
||||||
},
|
|
||||||
"VALIDATION_TIMEOUT": {
|
|
||||||
"label": "驗證逾時(毫秒)"
|
|
||||||
},
|
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
|
||||||
"label": "歷史 XML 壓縮",
|
|
||||||
"description": "用占位符取代歷史記錄中的舊圖表 XML。"
|
|
||||||
},
|
|
||||||
"ALLOW_PRIVATE_URLS": {
|
|
||||||
"label": "允許私有 URL",
|
|
||||||
"description": "關閉以阻擋對私有 IP 和內部主機名的請求(SSRF 防護)。"
|
|
||||||
},
|
|
||||||
"LANGFUSE_PUBLIC_KEY": {
|
|
||||||
"label": "Langfuse Public Key"
|
|
||||||
},
|
|
||||||
"LANGFUSE_SECRET_KEY": {
|
|
||||||
"label": "Langfuse Secret Key"
|
|
||||||
},
|
|
||||||
"LANGFUSE_BASEURL": {
|
|
||||||
"label": "Langfuse Base URL"
|
|
||||||
},
|
|
||||||
"DAILY_REQUEST_LIMIT": {
|
|
||||||
"label": "每日請求上限",
|
|
||||||
"description": "每個 IP 每天。"
|
|
||||||
},
|
|
||||||
"DAILY_TOKEN_LIMIT": {
|
|
||||||
"label": "每日 token 上限",
|
|
||||||
"description": "每個 IP 每天。"
|
|
||||||
},
|
|
||||||
"TPM_LIMIT": {
|
|
||||||
"label": "每分鐘 token 數"
|
|
||||||
},
|
|
||||||
"DYNAMODB_QUOTA_TABLE": {
|
|
||||||
"label": "DynamoDB 表",
|
|
||||||
"description": "留空時配額強制執行被停用。"
|
|
||||||
},
|
|
||||||
"DYNAMODB_REGION": {
|
|
||||||
"label": "DynamoDB 區域"
|
|
||||||
},
|
|
||||||
"QUOTA_TIMEZONE": {
|
|
||||||
"label": "配額時區",
|
|
||||||
"description": "每日重置邊界所用的時區。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,7 @@
|
|||||||
"glm": "GLM",
|
"glm": "GLM",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"kimi": "Kimi",
|
"kimi": "Kimi",
|
||||||
"qiniu": "Qiniu",
|
"qiniu": "Qiniu"
|
||||||
"mimo": "MiMo (小米)"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的图表或上传文件...",
|
"placeholder": "描述您的图表或上传文件...",
|
||||||
@@ -372,7 +371,6 @@
|
|||||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||||
"customEndpoint": "自定义端点 URL",
|
"customEndpoint": "自定义端点 URL",
|
||||||
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||||
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
|
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自定义模型 ID...",
|
"customModelId": "自定义模型 ID...",
|
||||||
"allAdded": "已全部添加",
|
"allAdded": "已全部添加",
|
||||||
@@ -404,152 +402,6 @@
|
|||||||
"showUnvalidatedModels": "显示未验证的模型",
|
"showUnvalidatedModels": "显示未验证的模型",
|
||||||
"allModelsShown": "显示所有模型(包括未验证的)",
|
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||||
"unvalidatedModelWarning": "此模型尚未验证",
|
"unvalidatedModelWarning": "此模型尚未验证",
|
||||||
"serverDefaultModel": "服务器默认模型",
|
"serverDefaultModel": "服务器默认模型"
|
||||||
"showValue": "显示值",
|
|
||||||
"hideValue": "隐藏值"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"title": "管理员设置",
|
|
||||||
"loginPrompt": "输入管理员密码(即 ADMIN_PASSWORD 环境变量)以管理服务器设置。",
|
|
||||||
"password": "密码",
|
|
||||||
"signIn": "登录",
|
|
||||||
"signingIn": "正在登录…",
|
|
||||||
"loginFailed": "登录失败",
|
|
||||||
"precedence": "文件覆盖环境变量 · 环境变量覆盖默认值",
|
|
||||||
"notWritable": "此部署环境下设置文件不可写(无服务器平台没有持久化磁盘)。设置以只读方式显示——请改用环境变量进行配置。",
|
|
||||||
"settingGroups": "设置分组",
|
|
||||||
"enabled": "已启用",
|
|
||||||
"disabled": "已禁用",
|
|
||||||
"enableGroup": "启用 {group}",
|
|
||||||
"unsavedChanges": "有未保存的更改",
|
|
||||||
"saved": "设置已保存,更改立即生效。",
|
|
||||||
"saveFailed": "保存失败。请检查网络连接后重试。",
|
|
||||||
"invalidSettings": "部分设置无效。",
|
|
||||||
"discard": "放弃",
|
|
||||||
"saveChanges": "保存更改",
|
|
||||||
"saving": "正在保存…",
|
|
||||||
"sourceSaved": "已保存",
|
|
||||||
"sourceEnv": "环境变量",
|
|
||||||
"sourceSavedTitle": "在管理员设置文件中设置",
|
|
||||||
"sourceEnvTitle": "通过环境变量设置",
|
|
||||||
"restartRequired": "需要重启",
|
|
||||||
"modified": "已修改",
|
|
||||||
"notSet": "未设置",
|
|
||||||
"savedReplace": "已保存({hint})——输入以替换",
|
|
||||||
"showValue": "显示值",
|
|
||||||
"hideValue": "隐藏值",
|
|
||||||
"removeValue": "移除值",
|
|
||||||
"removeValueTitle": "移除已保存的值",
|
|
||||||
"resetToDefault": "恢复默认",
|
|
||||||
"models": "模型",
|
|
||||||
"modelsDescription": "面向所有用户的服务端 provider 和模型——无需个人 API 密钥。当用户未选择模型时,使用默认 provider 的第一个模型。",
|
|
||||||
"addProviderHint": "添加一个 provider,为所有用户提供服务端模型。",
|
|
||||||
"selectProviderHint": "选择或添加一个 provider 以配置其凭证和模型。",
|
|
||||||
"addProviderToOfferModels": "至少添加一个模型,才能向用户开放此 provider。",
|
|
||||||
"managedViaEnv": "(通过环境变量管理)",
|
|
||||||
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定义——此处只读。请编辑环境配置以更改。",
|
|
||||||
"defaultModel": "默认模型",
|
|
||||||
"noModelsConfigured": "未配置模型",
|
|
||||||
"modelCount": "{count} 个模型",
|
|
||||||
"modelCountPlural": "{count} 个模型",
|
|
||||||
"default": "默认",
|
|
||||||
"setAsDefault": "设为默认 provider",
|
|
||||||
"defaultProvider": "默认 provider",
|
|
||||||
"modelIdPlaceholder": "模型 ID…",
|
|
||||||
"addModel": "添加模型",
|
|
||||||
"suggested": "推荐",
|
|
||||||
"test": "测试",
|
|
||||||
"testOk": "正常({ms} 毫秒)",
|
|
||||||
"testFailed": "失败",
|
|
||||||
"removeModel": "移除 {model}",
|
|
||||||
"deleteProviderTitle": "删除 {name}?",
|
|
||||||
"deleteProviderDesc": "保存后,其凭证和模型将从服务器上移除。",
|
|
||||||
"cancel": "取消",
|
|
||||||
"delete": "删除",
|
|
||||||
"groups": {
|
|
||||||
"generation": {
|
|
||||||
"title": "生成",
|
|
||||||
"description": "应用于所有聊天请求的输出参数。"
|
|
||||||
},
|
|
||||||
"access": {
|
|
||||||
"title": "访问控制",
|
|
||||||
"description": "限制谁可以使用此部署。"
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"title": "功能",
|
|
||||||
"description": "可选功能和安全开关。"
|
|
||||||
},
|
|
||||||
"observability": {
|
|
||||||
"title": "可观测性",
|
|
||||||
"description": "对 LLM 调用进行 Langfuse 追踪。"
|
|
||||||
},
|
|
||||||
"quota": {
|
|
||||||
"title": "配额与速率限制",
|
|
||||||
"description": "按 IP 的用量限制。强制执行需要 DynamoDB 表。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"TEMPERATURE": {
|
|
||||||
"label": "温度",
|
|
||||||
"description": "对于拒绝温度参数的推理模型,请留空。"
|
|
||||||
},
|
|
||||||
"MAX_OUTPUT_TOKENS": {
|
|
||||||
"label": "最大输出 token 数"
|
|
||||||
},
|
|
||||||
"ACCESS_CODE_LIST": {
|
|
||||||
"label": "访问码",
|
|
||||||
"description": "以逗号分隔的列表。用户需输入其中之一才能聊天。留空 = 开放访问。"
|
|
||||||
},
|
|
||||||
"ENABLE_VLM_VALIDATION": {
|
|
||||||
"label": "VLM 图表验证",
|
|
||||||
"description": "使用视觉模型对生成的图表进行可视化验证。"
|
|
||||||
},
|
|
||||||
"VALIDATION_MODEL": {
|
|
||||||
"label": "验证模型",
|
|
||||||
"description": "留空时回退到默认 AI 模型。"
|
|
||||||
},
|
|
||||||
"VALIDATION_TIMEOUT": {
|
|
||||||
"label": "验证超时(毫秒)"
|
|
||||||
},
|
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
|
||||||
"label": "历史 XML 压缩",
|
|
||||||
"description": "用占位符替换历史记录中的旧图表 XML。"
|
|
||||||
},
|
|
||||||
"ALLOW_PRIVATE_URLS": {
|
|
||||||
"label": "允许私有 URL",
|
|
||||||
"description": "关闭以阻止对私有 IP 和内部主机名的请求(SSRF 防护)。"
|
|
||||||
},
|
|
||||||
"LANGFUSE_PUBLIC_KEY": {
|
|
||||||
"label": "Langfuse Public Key"
|
|
||||||
},
|
|
||||||
"LANGFUSE_SECRET_KEY": {
|
|
||||||
"label": "Langfuse Secret Key"
|
|
||||||
},
|
|
||||||
"LANGFUSE_BASEURL": {
|
|
||||||
"label": "Langfuse Base URL"
|
|
||||||
},
|
|
||||||
"DAILY_REQUEST_LIMIT": {
|
|
||||||
"label": "每日请求上限",
|
|
||||||
"description": "每个 IP 每天。"
|
|
||||||
},
|
|
||||||
"DAILY_TOKEN_LIMIT": {
|
|
||||||
"label": "每日 token 上限",
|
|
||||||
"description": "每个 IP 每天。"
|
|
||||||
},
|
|
||||||
"TPM_LIMIT": {
|
|
||||||
"label": "每分钟 token 数"
|
|
||||||
},
|
|
||||||
"DYNAMODB_QUOTA_TABLE": {
|
|
||||||
"label": "DynamoDB 表",
|
|
||||||
"description": "留空时配额强制执行被禁用。"
|
|
||||||
},
|
|
||||||
"DYNAMODB_REGION": {
|
|
||||||
"label": "DynamoDB 区域"
|
|
||||||
},
|
|
||||||
"QUOTA_TIMEZONE": {
|
|
||||||
"label": "配额时区",
|
|
||||||
"description": "每日重置边界所用的时区。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,54 +62,7 @@ function getConfigPath(): string {
|
|||||||
return path.join(process.cwd(), "ai-models.json")
|
return path.join(process.cwd(), "ai-models.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||||
* Synthesize a config from a comma-separated AI_MODEL value (Priority 3 fallback).
|
|
||||||
* Lets users expose multiple models without authoring AI_MODELS_CONFIG / ai-models.json.
|
|
||||||
* Triggers only when AI_MODEL contains a comma AND AI_PROVIDER is set to a known provider.
|
|
||||||
*/
|
|
||||||
function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
|
|
||||||
const aiModel = process.env.AI_MODEL
|
|
||||||
if (!aiModel || !aiModel.includes(",")) return null
|
|
||||||
|
|
||||||
const aiProvider = process.env.AI_PROVIDER
|
|
||||||
if (!aiProvider) {
|
|
||||||
console.warn(
|
|
||||||
"[server-model-config] AI_MODEL contains commas but AI_PROVIDER is not set; " +
|
|
||||||
"skipping multi-model fallback. Set AI_PROVIDER, or use AI_MODELS_CONFIG / ai-models.json.",
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (!(aiProvider in PROVIDER_INFO)) {
|
|
||||||
console.warn(
|
|
||||||
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const models = Array.from(
|
|
||||||
new Set(
|
|
||||||
aiModel
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s.length > 0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (models.length === 0) return null
|
|
||||||
|
|
||||||
const providerName = aiProvider as ProviderName
|
|
||||||
return {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: PROVIDER_INFO[providerName]?.label || providerName,
|
|
||||||
provider: providerName,
|
|
||||||
models,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
|
||||||
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
||||||
const envConfig = process.env.AI_MODELS_CONFIG
|
const envConfig = process.env.AI_MODELS_CONFIG
|
||||||
if (envConfig && envConfig.trim().length > 0) {
|
if (envConfig && envConfig.trim().length > 0) {
|
||||||
@@ -132,51 +85,15 @@ export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig |
|
|||||||
const json = JSON.parse(jsonStr)
|
const json = JSON.parse(jsonStr)
|
||||||
return ServerModelsConfigSchema.parse(json)
|
return ServerModelsConfigSchema.parse(json)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code !== "ENOENT") {
|
if (err?.code === "ENOENT") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
console.error(
|
console.error(
|
||||||
"[server-model-config] Failed to load ai-models.json:",
|
"[server-model-config] Failed to load ai-models.json:",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: AI_MODEL with comma-separated values + AI_PROVIDER
|
|
||||||
return configFromCommaSeparatedAiModel()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
|
||||||
const envConfig = await loadEnvServerModelsConfig()
|
|
||||||
|
|
||||||
// Merge in providers managed via the admin panel (settings.json).
|
|
||||||
// Dynamic import to avoid a module-init cycle with lib/admin/providers.
|
|
||||||
let adminConfig: ServerModelsConfig | null = null
|
|
||||||
try {
|
|
||||||
const { adminProvidersToConfig, loadAdminProviders } = await import(
|
|
||||||
"./admin/providers"
|
|
||||||
)
|
|
||||||
const adminProviders = loadAdminProviders()
|
|
||||||
if (adminProviders.length > 0) {
|
|
||||||
adminConfig = adminProvidersToConfig(adminProviders)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
"[server-model-config] Failed to load admin providers:",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!adminConfig || adminConfig.providers.length === 0) return envConfig
|
|
||||||
if (!envConfig) return adminConfig
|
|
||||||
|
|
||||||
// A panel default overrides an env default
|
|
||||||
const adminHasDefault = adminConfig.providers.some((p) => p.default)
|
|
||||||
const envProviders = adminHasDefault
|
|
||||||
? envConfig.providers.map((p) =>
|
|
||||||
p.default ? { ...p, default: undefined } : p,
|
|
||||||
)
|
|
||||||
: envConfig.providers
|
|
||||||
return { providers: [...envProviders, ...adminConfig.providers] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFlattenedServerModels(): Promise<
|
export async function loadFlattenedServerModels(): Promise<
|
||||||
|
|||||||
@@ -2,37 +2,36 @@
|
|||||||
* SSRF (Server-Side Request Forgery) protection utilities
|
* SSRF (Server-Side Request Forgery) protection utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lookup } from "node:dns/promises"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
|
* Check if URL points to private/internal network
|
||||||
* Works for both user-supplied literal IPs and DNS-resolved addresses.
|
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||||
*/
|
*/
|
||||||
function isPrivateIp(ip: string): boolean {
|
export function isPrivateUrl(urlString: string): boolean {
|
||||||
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
|
try {
|
||||||
|
const url = new URL(urlString)
|
||||||
|
const hostname = url.hostname.toLowerCase()
|
||||||
|
|
||||||
// IPv6
|
// Block localhost
|
||||||
if (addr.includes(":")) {
|
|
||||||
if (addr === "::1" || addr === "::") return true
|
|
||||||
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
|
|
||||||
if (
|
if (
|
||||||
addr.startsWith("fc") ||
|
hostname === "localhost" ||
|
||||||
addr.startsWith("fd") ||
|
hostname === "127.0.0.1" ||
|
||||||
addr.startsWith("::ffff:")
|
hostname === "::1"
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// link-local (fe80::/10)
|
|
||||||
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
|
// Block AWS/cloud metadata endpoints
|
||||||
if (linkLocal) {
|
if (
|
||||||
const high = parseInt(linkLocal[1], 16)
|
hostname === "169.254.169.254" ||
|
||||||
if (high >= 0x80 && high <= 0xbf) return true
|
hostname === "metadata.google.internal"
|
||||||
}
|
) {
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv4
|
// Check for private IPv4 ranges
|
||||||
const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
const ipv4Match = hostname.match(
|
||||||
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||||
|
)
|
||||||
if (ipv4Match) {
|
if (ipv4Match) {
|
||||||
const [, a, b] = ipv4Match.map(Number)
|
const [, a, b] = ipv4Match.map(Number)
|
||||||
if (a === 10) return true // 10.0.0.0/8
|
if (a === 10) return true // 10.0.0.0/8
|
||||||
@@ -40,78 +39,25 @@ function isPrivateIp(ip: string): boolean {
|
|||||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||||
if (a === 0) return true // 0.0.0.0/8
|
}
|
||||||
if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 (CGNAT, used by some cloud internal networks)
|
|
||||||
|
// Block common internal hostnames
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* String-only check against well-known private hostnames and literal IPs.
|
|
||||||
* Fast path that avoids a DNS lookup for obvious cases.
|
|
||||||
*/
|
|
||||||
function isPrivateHostname(hostname: string): boolean {
|
|
||||||
const host = hostname
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^\[|\]$/g, "")
|
|
||||||
.replace(/\.$/, "")
|
|
||||||
|
|
||||||
if (
|
|
||||||
host === "localhost" ||
|
|
||||||
host === "127.0.0.1" ||
|
|
||||||
host === "::1" ||
|
|
||||||
host === "::"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (host === "169.254.169.254" || host === "metadata.google.internal") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
host.endsWith(".local") ||
|
|
||||||
host.endsWith(".internal") ||
|
|
||||||
host.endsWith(".localhost")
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Literal IP supplied directly in the URL
|
|
||||||
return isPrivateIp(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL points to private/internal network.
|
|
||||||
* Blocks: localhost, private IPs, link-local, AWS metadata service.
|
|
||||||
*
|
|
||||||
* Resolves the hostname via DNS and validates every returned address, so
|
|
||||||
* public-looking names that map to internal IPs (e.g. "127-0-0-1.sslip.io")
|
|
||||||
* are caught even though they pass the string-only check.
|
|
||||||
*/
|
|
||||||
export async function isPrivateUrl(urlString: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const url = new URL(urlString)
|
|
||||||
const hostname = url.hostname
|
|
||||||
|
|
||||||
// Fast path: obvious string matches and literal IPs.
|
|
||||||
if (isPrivateHostname(hostname)) return true
|
|
||||||
|
|
||||||
// Resolve DNS and reject if any address is private.
|
|
||||||
const stripped = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "")
|
|
||||||
const addresses = await lookup(stripped, { all: true })
|
|
||||||
return addresses.some(({ address }) => isPrivateIp(address))
|
|
||||||
} catch {
|
} catch {
|
||||||
return true // Invalid URL or DNS failure - block it
|
return true // Invalid URL - block it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether private URLs are allowed (defaults to true)
|
* Whether private URLs are allowed (defaults to true)
|
||||||
* Set ALLOW_PRIVATE_URLS=false to block private URLs
|
* Set ALLOW_PRIVATE_URLS=false to block private URLs
|
||||||
* Read per call so admin-panel changes apply without restart
|
|
||||||
*/
|
*/
|
||||||
export function allowPrivateUrls(): boolean {
|
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||||
return process.env.ALLOW_PRIVATE_URLS !== "false"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export type ProviderName =
|
|||||||
| "bedrock"
|
| "bedrock"
|
||||||
| "ollama"
|
| "ollama"
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "aihubmix"
|
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
| "siliconflow"
|
||||||
| "sglang"
|
| "sglang"
|
||||||
@@ -23,8 +22,6 @@ export type ProviderName =
|
|||||||
| "kimi"
|
| "kimi"
|
||||||
| "minimax"
|
| "minimax"
|
||||||
| "novita"
|
| "novita"
|
||||||
| "mimo"
|
|
||||||
| "atlascloud"
|
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -88,15 +85,6 @@ export interface FlattenedModel {
|
|||||||
baseUrlEnv?: string
|
baseUrlEnv?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers whose server credentials live in fixed env vars
|
|
||||||
// (AWS_ACCESS_KEY_ID, GOOGLE_VERTEX_API_KEY, OLLAMA_API_KEY) with no
|
|
||||||
// apiKeyEnv redirection support — their credentials are global
|
|
||||||
export const FIXED_CRED_PROVIDERS: ProviderName[] = [
|
|
||||||
"bedrock",
|
|
||||||
"vertexai",
|
|
||||||
"ollama",
|
|
||||||
]
|
|
||||||
|
|
||||||
// Map provider names to models.dev logo names
|
// Map provider names to models.dev logo names
|
||||||
export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||||
openai: "openai",
|
openai: "openai",
|
||||||
@@ -105,7 +93,6 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
azure: "azure",
|
azure: "azure",
|
||||||
bedrock: "amazon-bedrock",
|
bedrock: "amazon-bedrock",
|
||||||
openrouter: "openrouter",
|
openrouter: "openrouter",
|
||||||
aihubmix: "aihubmix",
|
|
||||||
deepseek: "deepseek",
|
deepseek: "deepseek",
|
||||||
siliconflow: "siliconflow",
|
siliconflow: "siliconflow",
|
||||||
sglang: "openai", // SGLang is OpenAI-compatible
|
sglang: "openai", // SGLang is OpenAI-compatible
|
||||||
@@ -116,8 +103,6 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
modelscope: "modelscope",
|
modelscope: "modelscope",
|
||||||
minimax: "minimax",
|
minimax: "minimax",
|
||||||
novita: "novita",
|
novita: "novita",
|
||||||
mimo: "xiaomi",
|
|
||||||
atlascloud: "openai",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider metadata
|
// Provider metadata
|
||||||
@@ -151,10 +136,6 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||||
},
|
},
|
||||||
aihubmix: {
|
|
||||||
label: "AIHubMix",
|
|
||||||
defaultBaseUrl: "https://aihubmix.com/v1",
|
|
||||||
},
|
|
||||||
deepseek: {
|
deepseek: {
|
||||||
label: "DeepSeek",
|
label: "DeepSeek",
|
||||||
defaultBaseUrl: "https://api.deepseek.com/v1",
|
defaultBaseUrl: "https://api.deepseek.com/v1",
|
||||||
@@ -204,253 +185,183 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "Novita AI",
|
label: "Novita AI",
|
||||||
defaultBaseUrl: "https://api.novita.ai/openai",
|
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||||
},
|
},
|
||||||
mimo: {
|
|
||||||
label: "MiMo (Xiaomi)",
|
|
||||||
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
|
|
||||||
},
|
|
||||||
atlascloud: {
|
|
||||||
label: "Atlas Cloud",
|
|
||||||
defaultBaseUrl: "https://api.atlascloud.ai/v1",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggested models per provider for quick add
|
// Suggested models per provider for quick add
|
||||||
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||||
openai: [
|
openai: [
|
||||||
"gpt-5.5-pro",
|
"gpt-5.2-pro",
|
||||||
"gpt-5.5",
|
"gpt-5.2-chat-latest",
|
||||||
"gpt-5.4-pro",
|
"gpt-5.2",
|
||||||
"gpt-5.4",
|
"gpt-5.1-codex-mini",
|
||||||
"gpt-5.4-mini",
|
"gpt-5.1-codex",
|
||||||
"gpt-5.4-nano",
|
"gpt-5.1-chat-latest",
|
||||||
"gpt-5-codex-mini",
|
"gpt-5.1",
|
||||||
|
"gpt-5-pro",
|
||||||
|
"gpt-5",
|
||||||
|
"gpt-5-mini",
|
||||||
|
"gpt-5-nano",
|
||||||
|
"gpt-5-codex",
|
||||||
|
"gpt-5-chat-latest",
|
||||||
"gpt-4.1",
|
"gpt-4.1",
|
||||||
"gpt-4.1-mini",
|
"gpt-4.1-mini",
|
||||||
|
"gpt-4.1-nano",
|
||||||
"gpt-4o",
|
"gpt-4o",
|
||||||
"gpt-4o-mini",
|
"gpt-4o-mini",
|
||||||
],
|
],
|
||||||
anthropic: [
|
anthropic: [
|
||||||
// Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs)
|
// Claude 4.5 series (latest)
|
||||||
"claude-opus-4-8",
|
"claude-opus-4-5-20250514",
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-5-20250514",
|
||||||
"claude-haiku-4-5",
|
// Claude 4 series
|
||||||
"claude-opus-4-7",
|
"claude-opus-4-20250514",
|
||||||
"claude-opus-4-6",
|
"claude-sonnet-4-20250514",
|
||||||
// Claude 4.5 series
|
|
||||||
"claude-sonnet-4-5-20250929",
|
|
||||||
"claude-opus-4-5-20251101",
|
|
||||||
// Claude 3.7 series
|
// Claude 3.7 series
|
||||||
"claude-3-7-sonnet-20250219",
|
"claude-3-7-sonnet-20250219",
|
||||||
// Claude 3.5 series
|
// Claude 3.5 series
|
||||||
"claude-3-5-sonnet-20241022",
|
"claude-3-5-sonnet-20241022",
|
||||||
"claude-3-5-haiku-20241022",
|
"claude-3-5-haiku-20241022",
|
||||||
|
// Claude 3 series
|
||||||
|
"claude-3-opus-20240229",
|
||||||
|
"claude-3-sonnet-20240229",
|
||||||
|
"claude-3-haiku-20240307",
|
||||||
],
|
],
|
||||||
google: [
|
google: [
|
||||||
// Gemini 3 series
|
|
||||||
"gemini-3.1-pro",
|
|
||||||
"gemini-3.5-flash",
|
|
||||||
"gemini-3-flash",
|
|
||||||
"gemini-3.1-flash-lite",
|
|
||||||
// Gemini 2.5 series
|
// Gemini 2.5 series
|
||||||
"gemini-2.5-pro",
|
"gemini-2.5-pro",
|
||||||
"gemini-2.5-flash",
|
"gemini-2.5-flash",
|
||||||
"gemini-2.5-flash-lite",
|
"gemini-2.5-flash-preview-05-20",
|
||||||
|
// Gemini 2.0 series
|
||||||
|
"gemini-2.0-flash",
|
||||||
|
"gemini-2.0-flash-exp",
|
||||||
|
"gemini-2.0-flash-lite",
|
||||||
|
// Gemini 1.5 series
|
||||||
|
"gemini-1.5-pro",
|
||||||
|
"gemini-1.5-flash",
|
||||||
|
// Legacy
|
||||||
|
"gemini-pro",
|
||||||
],
|
],
|
||||||
vertexai: [
|
vertexai: [
|
||||||
// Gemini 3 series
|
|
||||||
"gemini-3.1-pro-preview",
|
|
||||||
"gemini-3.5-flash",
|
|
||||||
"gemini-3-flash-preview",
|
|
||||||
"gemini-3.1-flash-lite",
|
|
||||||
// Gemini 2.5 series
|
// Gemini 2.5 series
|
||||||
"gemini-2.5-pro",
|
"gemini-2.5-pro",
|
||||||
"gemini-2.5-flash",
|
"gemini-2.5-flash",
|
||||||
"gemini-2.5-flash-lite",
|
// Gemini 2.0 series
|
||||||
],
|
"gemini-2.0-flash",
|
||||||
azure: [
|
"gemini-2.0-flash-exp",
|
||||||
"gpt-5.5",
|
// Gemini 1.5 series
|
||||||
"gpt-5.4",
|
"gemini-1.5-pro",
|
||||||
"gpt-5.1",
|
"gemini-1.5-flash",
|
||||||
"gpt-5",
|
|
||||||
"gpt-5-mini",
|
|
||||||
"gpt-4.1",
|
|
||||||
"gpt-4o",
|
|
||||||
"gpt-4o-mini",
|
|
||||||
"o3",
|
|
||||||
"o4-mini",
|
|
||||||
],
|
],
|
||||||
|
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
||||||
bedrock: [
|
bedrock: [
|
||||||
// Anthropic Claude
|
// Anthropic Claude
|
||||||
"anthropic.claude-opus-4-8",
|
"anthropic.claude-opus-4-5-20250514-v1:0",
|
||||||
"anthropic.claude-opus-4-7",
|
"anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||||
"anthropic.claude-sonnet-4-6",
|
|
||||||
"anthropic.claude-opus-4-6-v1",
|
|
||||||
"anthropic.claude-opus-4-5-20251101-v1:0",
|
|
||||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
|
||||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
||||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
|
||||||
"anthropic.claude-opus-4-20250514-v1:0",
|
"anthropic.claude-opus-4-20250514-v1:0",
|
||||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||||
|
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||||
|
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||||
|
"anthropic.claude-3-opus-20240229-v1:0",
|
||||||
|
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||||
|
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||||
// Amazon Nova
|
// Amazon Nova
|
||||||
"amazon.nova-2-lite-v1:0",
|
|
||||||
"amazon.nova-premier-v1:0",
|
|
||||||
"amazon.nova-pro-v1:0",
|
"amazon.nova-pro-v1:0",
|
||||||
"amazon.nova-lite-v1:0",
|
"amazon.nova-lite-v1:0",
|
||||||
"amazon.nova-micro-v1:0",
|
"amazon.nova-micro-v1:0",
|
||||||
// Meta Llama
|
// Meta Llama
|
||||||
"meta.llama4-maverick-17b-instruct-v1:0",
|
|
||||||
"meta.llama4-scout-17b-instruct-v1:0",
|
|
||||||
"meta.llama3-3-70b-instruct-v1:0",
|
"meta.llama3-3-70b-instruct-v1:0",
|
||||||
|
"meta.llama3-1-405b-instruct-v1:0",
|
||||||
|
"meta.llama3-1-70b-instruct-v1:0",
|
||||||
// Mistral
|
// Mistral
|
||||||
"mistral.mistral-large-3-675b-instruct",
|
"mistral.mistral-large-2411-v1:0",
|
||||||
"mistral.pixtral-large-2502-v1:0",
|
"mistral.mistral-small-2503-v1:0",
|
||||||
],
|
],
|
||||||
openrouter: [
|
openrouter: [
|
||||||
// Anthropic
|
// Anthropic
|
||||||
"anthropic/claude-opus-4.8",
|
"anthropic/claude-sonnet-4",
|
||||||
"anthropic/claude-sonnet-4.6",
|
"anthropic/claude-opus-4",
|
||||||
"anthropic/claude-haiku-4.5",
|
"anthropic/claude-3.5-sonnet",
|
||||||
|
"anthropic/claude-3.5-haiku",
|
||||||
// OpenAI
|
// OpenAI
|
||||||
"openai/gpt-5.5",
|
"openai/gpt-4o",
|
||||||
"openai/gpt-5.4",
|
|
||||||
"openai/gpt-5.4-mini",
|
|
||||||
"openai/gpt-4o-mini",
|
"openai/gpt-4o-mini",
|
||||||
|
"openai/o1",
|
||||||
|
"openai/o3-mini",
|
||||||
// Google
|
// Google
|
||||||
"google/gemini-3.1-pro-preview",
|
"google/gemini-2.5-pro",
|
||||||
"google/gemini-3.5-flash",
|
"google/gemini-2.5-flash",
|
||||||
"google/gemini-2.5-flash-lite",
|
"google/gemini-2.0-flash-exp:free",
|
||||||
// xAI
|
|
||||||
"x-ai/grok-4.3",
|
|
||||||
// Meta Llama
|
// Meta Llama
|
||||||
"meta-llama/llama-4-maverick",
|
|
||||||
"meta-llama/llama-4-scout",
|
|
||||||
"meta-llama/llama-3.3-70b-instruct",
|
"meta-llama/llama-3.3-70b-instruct",
|
||||||
|
"meta-llama/llama-3.1-405b-instruct",
|
||||||
|
"meta-llama/llama-3.1-70b-instruct",
|
||||||
// DeepSeek
|
// DeepSeek
|
||||||
"deepseek/deepseek-v4-pro",
|
"deepseek/deepseek-chat",
|
||||||
"deepseek/deepseek-v3.2",
|
"deepseek/deepseek-r1",
|
||||||
// Qwen
|
// Qwen
|
||||||
"qwen/qwen3.7-max",
|
"qwen/qwen-2.5-72b-instruct",
|
||||||
"qwen/qwen3-coder",
|
|
||||||
// MiniMax
|
|
||||||
"minimax/minimax-m3",
|
|
||||||
],
|
|
||||||
aihubmix: [
|
|
||||||
// Fallback list. The settings UI loads the live model list from AIHubMix when available.
|
|
||||||
// Anthropic Claude
|
|
||||||
"claude-fable-5",
|
|
||||||
"claude-opus-4-8",
|
|
||||||
"claude-sonnet-4-6",
|
|
||||||
// OpenAI
|
|
||||||
"gpt-5.5",
|
|
||||||
"gpt-5.5-pro",
|
|
||||||
"gpt-5.4",
|
|
||||||
// Google Gemini
|
|
||||||
"gemini-3.5-flash",
|
|
||||||
"gemini-3.1-pro-preview",
|
|
||||||
"gemini-3-flash-preview",
|
|
||||||
// DeepSeek
|
|
||||||
"deepseek-v4-pro",
|
|
||||||
"deepseek-v4-flash",
|
|
||||||
// Qwen
|
|
||||||
"qwen3.7-max",
|
|
||||||
"qwen3-coder-next",
|
|
||||||
// Z.ai
|
|
||||||
"glm-5.1",
|
|
||||||
// Moonshot AI
|
|
||||||
"kimi-k2.6",
|
|
||||||
// MiniMax
|
|
||||||
"minimax-m3",
|
|
||||||
// xAI
|
|
||||||
"grok-4.3",
|
|
||||||
// Baidu
|
|
||||||
"ernie-5.1",
|
|
||||||
// Mistral
|
|
||||||
"mistral-large-3",
|
|
||||||
// Meta
|
|
||||||
"llama-4-maverick",
|
|
||||||
],
|
|
||||||
deepseek: [
|
|
||||||
"deepseek-v4-pro",
|
|
||||||
"deepseek-v4-flash",
|
|
||||||
"deepseek-chat",
|
|
||||||
"deepseek-reasoner",
|
|
||||||
],
|
],
|
||||||
|
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
|
||||||
siliconflow: [
|
siliconflow: [
|
||||||
// DeepSeek
|
// DeepSeek
|
||||||
"deepseek-ai/DeepSeek-V4-Pro",
|
"deepseek-ai/DeepSeek-V3",
|
||||||
"deepseek-ai/DeepSeek-V4-Flash",
|
"deepseek-ai/DeepSeek-R1",
|
||||||
"deepseek-ai/DeepSeek-V3.2",
|
"deepseek-ai/DeepSeek-V2.5",
|
||||||
// MiniMax
|
|
||||||
"MiniMaxAI/MiniMax-M3",
|
|
||||||
// Moonshot
|
|
||||||
"moonshotai/Kimi-K2.6",
|
|
||||||
// Z.ai
|
|
||||||
"zai-org/GLM-5",
|
|
||||||
// Qwen
|
// Qwen
|
||||||
"Qwen/Qwen3.6-35B-A3B",
|
"Qwen/Qwen2.5-72B-Instruct",
|
||||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
"Qwen/Qwen2.5-32B-Instruct",
|
||||||
"Qwen/Qwen3-30B-A3B-Instruct-2507",
|
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||||
"Qwen/Qwen3-VL-32B-Instruct",
|
"Qwen/Qwen2.5-7B-Instruct",
|
||||||
// OpenAI open-weights
|
"Qwen/Qwen2-VL-72B-Instruct",
|
||||||
"openai/gpt-oss-120b",
|
"qwen3.5-plus",
|
||||||
],
|
],
|
||||||
sglang: [
|
sglang: [
|
||||||
// SGLang is OpenAI-compatible, models depend on deployment
|
// SGLang is OpenAI-compatible, models depend on deployment
|
||||||
"default",
|
"default",
|
||||||
],
|
],
|
||||||
gateway: [
|
gateway: [
|
||||||
"openai/gpt-5.5",
|
"openai/gpt-4o",
|
||||||
"anthropic/claude-opus-4.7",
|
"openai/gpt-4o-mini",
|
||||||
"google/gemini-3.1-pro-preview",
|
"anthropic/claude-sonnet-4-5",
|
||||||
"xai/grok-4.3",
|
"anthropic/claude-3-5-sonnet",
|
||||||
"anthropic/claude-sonnet-4.6",
|
"google/gemini-2.0-flash",
|
||||||
"anthropic/claude-haiku-4.5",
|
|
||||||
"openai/gpt-5.4-mini",
|
|
||||||
],
|
],
|
||||||
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
|
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
|
||||||
doubao: [
|
doubao: [
|
||||||
// ByteDance Doubao models (Volcengine Ark IDs use dash form)
|
// ByteDance Doubao models
|
||||||
"doubao-seed-2-0-pro-260215",
|
"doubao-1.5-thinking-pro-250415",
|
||||||
"doubao-seed-2-0-lite-260428",
|
"doubao-1.5-thinking-pro-m-250428",
|
||||||
"doubao-seed-2-0-mini-260428",
|
"doubao-1.5-pro-32k-250115",
|
||||||
"doubao-seed-1-8-251228",
|
"doubao-1.5-pro-256k-250115",
|
||||||
"doubao-seed-1-6-251015",
|
"doubao-pro-32k-241215",
|
||||||
"doubao-seed-1-6-flash-250828",
|
"doubao-pro-256k-241215",
|
||||||
"doubao-seed-1-6-vision-250815",
|
|
||||||
"doubao-1-5-pro-32k-250115",
|
|
||||||
"doubao-1-5-lite-32k-250115",
|
|
||||||
],
|
],
|
||||||
modelscope: [
|
modelscope: [
|
||||||
// DeepSeek
|
|
||||||
"deepseek-ai/DeepSeek-V4-Pro",
|
|
||||||
"deepseek-ai/DeepSeek-V3.2",
|
|
||||||
"deepseek-ai/DeepSeek-R1-0528",
|
|
||||||
"deepseek-ai/DeepSeek-R1",
|
|
||||||
// Qwen
|
// Qwen
|
||||||
|
"Qwen/Qwen2.5-72B-Instruct",
|
||||||
|
"Qwen/Qwen2.5-32B-Instruct",
|
||||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
||||||
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
|
|
||||||
"Qwen/Qwen3-32B",
|
"Qwen/Qwen3-32B",
|
||||||
"Qwen/Qwen2.5-72B-Instruct",
|
"qwen3.5-plus",
|
||||||
|
// DeepSeek
|
||||||
|
"deepseek-ai/DeepSeek-R1-0528",
|
||||||
|
"deepseek-ai/DeepSeek-V3.2",
|
||||||
],
|
],
|
||||||
minimax: [
|
minimax: [
|
||||||
// MiniMax models (Anthropic-compatible API)
|
// MiniMax models (Anthropic-compatible API)
|
||||||
"MiniMax-M3",
|
|
||||||
"MiniMax-M2.7",
|
"MiniMax-M2.7",
|
||||||
"MiniMax-M2.7-highspeed",
|
"MiniMax-M2.7-highspeed",
|
||||||
"MiniMax-M2.5",
|
"MiniMax-M2.5",
|
||||||
|
"MiniMax-M2.5-highspeed",
|
||||||
],
|
],
|
||||||
novita: [
|
novita: [
|
||||||
// Novita AI models (OpenAI-compatible API)
|
// Novita AI models (OpenAI-compatible API)
|
||||||
"minimax/minimax-m3",
|
"moonshotai/kimi-k2.5",
|
||||||
"deepseek/deepseek-v4-pro",
|
"zai-org/glm-5",
|
||||||
"zai-org/glm-5.1",
|
"minimax/minimax-m2.5",
|
||||||
"moonshotai/kimi-k2.6",
|
|
||||||
"deepseek/deepseek-v4-flash",
|
|
||||||
],
|
],
|
||||||
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
|
|
||||||
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to generate UUID
|
// Helper to generate UUID
|
||||||
|
|||||||
3757
package-lock.json
generated
3757
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,6 @@
|
|||||||
"@ai-sdk/google-vertex": "^4.0.16",
|
"@ai-sdk/google-vertex": "^4.0.16",
|
||||||
"@ai-sdk/openai": "^3.0.0",
|
"@ai-sdk/openai": "^3.0.0",
|
||||||
"@ai-sdk/react": "^3.0.1",
|
"@ai-sdk/react": "^3.0.1",
|
||||||
"@aihubmix/ai-sdk-provider": "^2.1.0",
|
|
||||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@aws-sdk/credential-providers": "^3.943.0",
|
||||||
"@extractus/article-extractor": "^8.0.18",
|
"@extractus/article-extractor": "^8.0.18",
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
"@opennextjs/cloudflare": "^1.17.1",
|
"@opennextjs/cloudflare": "^1.17.1",
|
||||||
"@openrouter/ai-sdk-provider": "^2.0.0",
|
"@openrouter/ai-sdk-provider": "^2.0.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.216.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
@@ -109,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.5.7",
|
"@biomejs/biome": "2.4.13",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -129,7 +128,7 @@
|
|||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "9.39.5",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
|
|||||||
@@ -116,14 +116,9 @@ Use the standard MCP configuration with:
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `start_session` | Opens browser with real-time diagram preview |
|
| `start_session` | Opens browser with real-time diagram preview |
|
||||||
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
|
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
|
||||||
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
|
|
||||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
||||||
| `get_diagram` | Get the current diagram XML |
|
| `get_diagram` | Get the current diagram XML |
|
||||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file |
|
| `export_diagram` | Save diagram to a `.drawio` file |
|
||||||
| `list_pages` | List every page (tab) with id, name, index, and cell count |
|
|
||||||
| `add_page` | Append a new page without touching existing ones |
|
|
||||||
| `rename_page` | Rename a page |
|
|
||||||
| `delete_page` | Delete a page (refuses to delete the last one) |
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|||||||
1668
packages/mcp-server/package-lock.json
generated
1668
packages/mcp-server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.2.3",
|
"version": "0.2.0",
|
||||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -11,8 +11,6 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -46,8 +44,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5",
|
"typescript": "^5"
|
||||||
"vitest": "^4.1.8"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* ID-based diagram operations
|
* ID-based diagram operations
|
||||||
*
|
* Copied from lib/utils.ts to avoid cross-package imports
|
||||||
* The xmlContent argument may be either a bare <mxGraphModel> (legacy) or a
|
|
||||||
* full <mxfile> with one or more <diagram> pages. For mxfile inputs, an
|
|
||||||
* optional pageSelector identifies which page to edit; when omitted, the
|
|
||||||
* first page is targeted (the "active page by convention" — see pages.ts).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
|
|
||||||
|
|
||||||
export interface DiagramOperation {
|
export interface DiagramOperation {
|
||||||
operation: "update" | "add" | "delete"
|
operation: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
@@ -28,18 +22,15 @@ export interface ApplyOperationsResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
||||||
|
* This replaces the text-matching approach with direct DOM manipulation.
|
||||||
*
|
*
|
||||||
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
|
* @param xmlContent - The full mxfile XML content
|
||||||
* a full <mxfile> with one or more <diagram> children.
|
* @param operations - Array of operations to apply
|
||||||
* @param operations - Array of operations to apply.
|
* @returns Object with result XML and any errors
|
||||||
* @param pageSelector - Optional page selector for multi-page docs. Defaults
|
|
||||||
* to the first page.
|
|
||||||
* @returns Object with result XML (same shape as input) and any per-op errors.
|
|
||||||
*/
|
*/
|
||||||
export function applyDiagramOperations(
|
export function applyDiagramOperations(
|
||||||
xmlContent: string,
|
xmlContent: string,
|
||||||
operations: DiagramOperation[],
|
operations: DiagramOperation[],
|
||||||
pageSelector?: PageSelector,
|
|
||||||
): ApplyOperationsResult {
|
): ApplyOperationsResult {
|
||||||
const errors: OperationError[] = []
|
const errors: OperationError[] = []
|
||||||
|
|
||||||
@@ -62,60 +53,8 @@ export function applyDiagramOperations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locate the <root> element to operate on.
|
// Find the root element (inside mxGraphModel)
|
||||||
//
|
const root = doc.querySelector("root")
|
||||||
// - For <mxfile> input: resolve the page via pageSelector, then dive into
|
|
||||||
// its <root>. This scopes querySelectorAll calls below to one page so
|
|
||||||
// cells on other pages aren't accidentally matched.
|
|
||||||
// - For bare <mxGraphModel> input: use the document's only <root>.
|
|
||||||
let root: Element | null
|
|
||||||
if (doc.documentElement?.tagName === "mxfile") {
|
|
||||||
const found = findPageElement(doc as unknown as Document, pageSelector)
|
|
||||||
if (!found) {
|
|
||||||
const selDesc = hasPageSelector(pageSelector)
|
|
||||||
? ` matching selector ${JSON.stringify(pageSelector)}`
|
|
||||||
: ""
|
|
||||||
return {
|
|
||||||
result: xmlContent,
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
cellId: "",
|
|
||||||
message: `Page${selDesc} not found in <mxfile>`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
root = found.element.querySelector("root")
|
|
||||||
if (!root) {
|
|
||||||
const pageId =
|
|
||||||
found.element.getAttribute("id") || `(index ${found.index})`
|
|
||||||
return {
|
|
||||||
result: xmlContent,
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
cellId: "",
|
|
||||||
message: `Page "${pageId}" has no <root> element`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (hasPageSelector(pageSelector)) {
|
|
||||||
return {
|
|
||||||
result: xmlContent,
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
cellId: "",
|
|
||||||
message:
|
|
||||||
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
root = doc.querySelector("root")
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return {
|
return {
|
||||||
result: xmlContent,
|
result: xmlContent,
|
||||||
@@ -128,9 +67,8 @@ export function applyDiagramOperations(
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Build a map of cell IDs to elements (scoped to the resolved page).
|
// Build a map of cell IDs to elements
|
||||||
const cellMap = new Map<string, Element>()
|
const cellMap = new Map<string, Element>()
|
||||||
root.querySelectorAll("mxCell").forEach((cell) => {
|
root.querySelectorAll("mxCell").forEach((cell) => {
|
||||||
const id = cell.getAttribute("id")
|
const id = cell.getAttribute("id")
|
||||||
@@ -270,9 +208,7 @@ export function applyDiagramOperations(
|
|||||||
cellsToDelete.add(cellId)
|
cellsToDelete.add(cellId)
|
||||||
|
|
||||||
// Find children (cells where parent === cellId)
|
// Find children (cells where parent === cellId)
|
||||||
// Scoped to `root` so other pages' cells with the same parent id
|
const children = root.querySelectorAll(
|
||||||
// (notably "1") are never touched.
|
|
||||||
const children = root!.querySelectorAll(
|
|
||||||
`mxCell[parent="${cellId}"]`,
|
`mxCell[parent="${cellId}"]`,
|
||||||
)
|
)
|
||||||
children.forEach((child) => {
|
children.forEach((child) => {
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Workflow gate for edit_diagram.
|
|
||||||
*
|
|
||||||
* Instead of a wall-clock timeout (the old 30s rule rejected slow-but-correct
|
|
||||||
* clients, see #885), we compare content: `lastSeenXml` is the state-store
|
|
||||||
* XML the model last saw (get_diagram) or wrote itself (create_new_diagram /
|
|
||||||
* edit_diagram / page CRUD). The store only changes on server writes or
|
|
||||||
* browser pushes (user autosave, sync exports), so if the live store still
|
|
||||||
* matches `lastSeenXml`, nothing happened that the model hasn't seen — the
|
|
||||||
* edit is safe no matter how much time passed.
|
|
||||||
*
|
|
||||||
* "Matches" is structural, not byte-for-byte: draw.io re-serialises the
|
|
||||||
* document when it pushes state back (different attribute order, pretty-
|
|
||||||
* printed whitespace, regenerated diagram ids, viewport attributes like
|
|
||||||
* dx/dy/pageWidth on <mxGraphModel>, a different mxfile host). None of that
|
|
||||||
* is a user edit, so the fingerprint keeps only what a user can actually
|
|
||||||
* change: the set of pages, each page's name, and each page's cell tree
|
|
||||||
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
|
|
||||||
*/
|
|
||||||
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
|
|
||||||
|
|
||||||
export type EditGateResult =
|
|
||||||
| { ok: true }
|
|
||||||
| { ok: false; reason: "no-context" | "stale" }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Canonical serialisation of an element subtree: tag + attributes sorted by
|
|
||||||
* name + child elements in order + non-whitespace text. Whitespace-only text
|
|
||||||
* nodes (pretty-printing) are dropped.
|
|
||||||
*/
|
|
||||||
function canonicalizeElement(el: Element): string {
|
|
||||||
const attrs = Array.from(el.attributes)
|
|
||||||
.map((a) => `${a.name}=${JSON.stringify(a.value)}`)
|
|
||||||
.sort()
|
|
||||||
.join(" ")
|
|
||||||
let children = ""
|
|
||||||
for (const child of Array.from(el.childNodes)) {
|
|
||||||
if (child.nodeType === 1) {
|
|
||||||
children += canonicalizeElement(child as Element)
|
|
||||||
} else if (child.nodeType === 3 || child.nodeType === 4) {
|
|
||||||
const text = (child.textContent ?? "").trim()
|
|
||||||
if (text) children += JSON.stringify(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Structural fingerprint of a diagram document: page names + each page's
|
|
||||||
* <root> subtree, ignoring everything draw.io rewrites on re-serialisation
|
|
||||||
* (mxfile/mxGraphModel attributes, diagram ids, formatting). A bare
|
|
||||||
* <mxGraphModel> fingerprints identically to its single-page mxfile wrapping.
|
|
||||||
* Unparseable input falls back to the trimmed raw string, degrading to the
|
|
||||||
* plain string comparison.
|
|
||||||
*
|
|
||||||
* `includeNames=false` drops page names from the fingerprint — used when the
|
|
||||||
* other side of a comparison is a bare <mxGraphModel>, which carries no page
|
|
||||||
* name at all (normalizeToMxfile would invent "Page-1", falsely mismatching
|
|
||||||
* any real page name).
|
|
||||||
*/
|
|
||||||
export function contentFingerprint(xml: string, includeNames = true): string {
|
|
||||||
const normalized = normalizeToMxfile(xml)
|
|
||||||
const doc = normalized ? parseMxfile(normalized) : null
|
|
||||||
if (!doc) return xml.trim()
|
|
||||||
const pages: string[] = []
|
|
||||||
doc.querySelectorAll("diagram").forEach((d) => {
|
|
||||||
const name = includeNames ? d.getAttribute("name") || "" : ""
|
|
||||||
const root = d.querySelector("root")
|
|
||||||
// No <root> means the page content is not plain XML (e.g. draw.io's
|
|
||||||
// compressed format) — fingerprint the raw text instead.
|
|
||||||
const body = root
|
|
||||||
? canonicalizeElement(root)
|
|
||||||
: (d.textContent || "").trim()
|
|
||||||
pages.push(`${name}=${body}`)
|
|
||||||
})
|
|
||||||
return pages.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkEditGate(
|
|
||||||
lastSeenXml: string,
|
|
||||||
liveXml: string,
|
|
||||||
): EditGateResult {
|
|
||||||
// Model never fetched or produced any diagram state in this session.
|
|
||||||
if (!lastSeenXml) return { ok: false, reason: "no-context" }
|
|
||||||
// Browser state moved since the model last looked (e.g. manual user
|
|
||||||
// edits): force a re-fetch so update/delete operations don't build on
|
|
||||||
// stale cell contents. An empty liveXml means the store has no entry to
|
|
||||||
// compare against, so there is nothing newer to have missed.
|
|
||||||
if (liveXml && liveXml !== lastSeenXml) {
|
|
||||||
// A bare <mxGraphModel> on either side carries no page name, so
|
|
||||||
// comparing names would mismatch against anything not called
|
|
||||||
// "Page-1". Compare cell trees only in that case.
|
|
||||||
const includeNames =
|
|
||||||
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
|
|
||||||
if (
|
|
||||||
contentFingerprint(liveXml, includeNames) !==
|
|
||||||
contentFingerprint(lastSeenXml, includeNames)
|
|
||||||
)
|
|
||||||
return { ok: false, reason: "stale" }
|
|
||||||
}
|
|
||||||
return { ok: true }
|
|
||||||
}
|
|
||||||
@@ -93,7 +93,6 @@ interface SessionState {
|
|||||||
svg?: string // Cached SVG from last browser save
|
svg?: string // Cached SVG from last browser save
|
||||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||||
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
||||||
exportXml?: string // Single-page projection to load before a page-targeted export
|
|
||||||
exportData?: string // Base64/SVG data returned by browser after export
|
exportData?: string // Base64/SVG data returned by browser after export
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,37 +117,12 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
|
|||||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||||
exportXml: existing?.exportXml, // Preserve pending projection
|
|
||||||
exportData: existing?.exportData, // Preserve export result
|
exportData: existing?.exportData, // Preserve export result
|
||||||
})
|
})
|
||||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
return newVersion
|
return newVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask the browser bridge to export the current diagram as png/svg.
|
|
||||||
*
|
|
||||||
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
|
|
||||||
* first, waits for draw.io's own load event, exports, then reloads the
|
|
||||||
* session's real document — so a page-targeted export never mutates the
|
|
||||||
* canonical session state and needs no fixed-delay guessing on the server.
|
|
||||||
*
|
|
||||||
* Returns false when the session is unknown. Callers should then poll
|
|
||||||
* `getState(sessionId)?.exportData` for the result.
|
|
||||||
*/
|
|
||||||
export function requestExport(
|
|
||||||
sessionId: string,
|
|
||||||
format: "png" | "svg",
|
|
||||||
projectionXml?: string,
|
|
||||||
): boolean {
|
|
||||||
const state = stateStore.get(sessionId)
|
|
||||||
if (!state) return false
|
|
||||||
state.exportData = undefined
|
|
||||||
state.exportXml = projectionXml
|
|
||||||
state.exportFormat = format
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requestSync(sessionId: string): boolean {
|
export function requestSync(sessionId: string): boolean {
|
||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -312,7 +286,6 @@ function handleStateApi(
|
|||||||
version: state?.version || 0,
|
version: state?.version || 0,
|
||||||
syncRequested: !!state?.syncRequested,
|
syncRequested: !!state?.syncRequested,
|
||||||
exportFormat: state?.exportFormat || null,
|
exportFormat: state?.exportFormat || null,
|
||||||
exportXml: state?.exportXml || null,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -332,7 +305,6 @@ function handleStateApi(
|
|||||||
if (state) {
|
if (state) {
|
||||||
state.exportData = data.exportData
|
state.exportData = data.exportData
|
||||||
state.exportFormat = undefined
|
state.exportFormat = undefined
|
||||||
state.exportXml = undefined
|
|
||||||
log.debug(
|
log.debug(
|
||||||
`Export data received for session=${sessionId}`,
|
`Export data received for session=${sessionId}`,
|
||||||
)
|
)
|
||||||
@@ -703,8 +675,6 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
let pendingSvgExport = null;
|
let pendingSvgExport = null;
|
||||||
let pendingAiSvg = false;
|
let pendingAiSvg = false;
|
||||||
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
||||||
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
|
|
||||||
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
|
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||||
@@ -714,10 +684,6 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
isReady = true;
|
isReady = true;
|
||||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||||
// Ignore autosave while a single-page projection is on screen
|
|
||||||
// for a page-targeted export — otherwise we'd push the
|
|
||||||
// transient projection back as the canonical session state.
|
|
||||||
if (projectionExportActive) return;
|
|
||||||
// Request SVG export, then push state with SVG
|
// Request SVG export, then push state with SVG
|
||||||
pendingSvgExport = msg.xml;
|
pendingSvgExport = msg.xml;
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
@@ -738,9 +704,6 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, exportData: d })
|
body: JSON.stringify({ sessionId, exportData: d })
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
// Page-targeted export: restore the user's real
|
|
||||||
// multi-page document now that we have the image.
|
|
||||||
restoreFromProjection();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -798,22 +761,6 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the user's real document after a page-targeted projection
|
|
||||||
// export. If we never captured one (lastXml was null at projection
|
|
||||||
// start), fall back to forcing a reload from the server on the next
|
|
||||||
// poll by rewinding currentVersion — never leave the iframe stuck on
|
|
||||||
// the transient projection.
|
|
||||||
function restoreFromProjection() {
|
|
||||||
if (!projectionExportActive) return;
|
|
||||||
projectionExportActive = false;
|
|
||||||
if (projectionRestoreXml) {
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
|
|
||||||
projectionRestoreXml = null;
|
|
||||||
} else {
|
|
||||||
currentVersion = -1; // force the next poll to reload from server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pushState(xml, svg = '') {
|
async function pushState(xml, svg = '') {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
try {
|
try {
|
||||||
@@ -839,54 +786,20 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
pendingSyncExport = true;
|
pendingSyncExport = true;
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||||
}
|
}
|
||||||
// Load new diagram from server (before export, so we export latest).
|
// Load new diagram from server (before export, so we export latest)
|
||||||
// While a page-targeted projection is on screen, skip the reload
|
if (s.version > currentVersion && s.xml) {
|
||||||
// so it doesn't fight the projection — and leave currentVersion
|
|
||||||
// unadvanced so this bump is re-detected and applied once the
|
|
||||||
// real document is restored.
|
|
||||||
if (s.version > currentVersion && s.xml && !projectionExportActive) {
|
|
||||||
currentVersion = s.version;
|
currentVersion = s.version;
|
||||||
loadDiagram(s.xml, true);
|
loadDiagram(s.xml, true);
|
||||||
}
|
}
|
||||||
// Handle export request from MCP server (png/svg).
|
// Handle export request from MCP server (png/svg) - after version update
|
||||||
//
|
|
||||||
// Plain export: capture whatever tab is currently displayed.
|
|
||||||
//
|
|
||||||
// Page-targeted export: the server sends a single-page <mxfile>
|
|
||||||
// projection in s.exportXml. We load it into the iframe, let
|
|
||||||
// draw.io render it, export, then reload the user's real
|
|
||||||
// document — all browser-side. The canonical session state is
|
|
||||||
// never mutated, so there is no server-side restore race and no
|
|
||||||
// dependence on poll timing. autosave is suppressed while the
|
|
||||||
// projection is showing (see projectionExportActive guard).
|
|
||||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||||
pendingMcpExport = s.exportFormat;
|
pendingMcpExport = s.exportFormat;
|
||||||
const fireExport = () => {
|
const exportOpts = s.exportFormat === 'png'
|
||||||
const exportOpts = pendingMcpExport === 'png'
|
|
||||||
? { action: 'export', format: 'png', scale: 2 }
|
? { action: 'export', format: 'png', scale: 2 }
|
||||||
: { action: 'export', format: 'svg' };
|
: { action: 'export', format: 'svg' };
|
||||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||||
};
|
// Timeout: reset if draw.io never responds
|
||||||
if (s.exportXml) {
|
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
||||||
// Stash the real document so we can restore after export.
|
|
||||||
projectionRestoreXml = lastXml;
|
|
||||||
projectionExportActive = true;
|
|
||||||
// Load the projection without touching lastXml/server state.
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
|
|
||||||
// Let draw.io render the loaded page before exporting
|
|
||||||
// (same proven settle delay as the AI-preview path).
|
|
||||||
setTimeout(fireExport, 600);
|
|
||||||
} else {
|
|
||||||
fireExport();
|
|
||||||
}
|
|
||||||
// Timeout: reset if draw.io never responds, and restore the
|
|
||||||
// real document if a projection was left showing.
|
|
||||||
setTimeout(() => {
|
|
||||||
if (pendingMcpExport) {
|
|
||||||
pendingMcpExport = null;
|
|
||||||
restoreFromProjection();
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -926,11 +839,7 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
saveConfirmBtn.textContent = 'Exporting...';
|
saveConfirmBtn.textContent = 'Exporting...';
|
||||||
|
|
||||||
if (format === 'drawio') {
|
if (format === 'drawio') {
|
||||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
|
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
||||||
// session.xml is canonically <mxfile> after the multi-page refactor,
|
|
||||||
// so no wrapper injection is needed. The legacy fallback below
|
|
||||||
// remains only for documents that somehow slipped past
|
|
||||||
// normalisation (e.g. an older session loaded from external state).
|
|
||||||
let xmlData = lastXml || '';
|
let xmlData = lastXml || '';
|
||||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* File-loading helpers for the load_diagram tool.
|
|
||||||
*
|
|
||||||
* A .drawio file is an <mxfile> whose <diagram> children hold each page's
|
|
||||||
* <mxGraphModel> either as plain XML or — draw.io's default save format —
|
|
||||||
* compressed: encodeURIComponent(xml) → raw deflate → base64 as the
|
|
||||||
* diagram's text content. The rest of the server assumes plain XML inside
|
|
||||||
* every <diagram>, so loading decompresses all pages up front.
|
|
||||||
*/
|
|
||||||
import { inflateRawSync } from "node:zlib"
|
|
||||||
import { DOMParser } from "linkedom"
|
|
||||||
import {
|
|
||||||
isMxFile,
|
|
||||||
isMxGraphModel,
|
|
||||||
normalizeToMxfile,
|
|
||||||
parseMxfile,
|
|
||||||
serializeMxfile,
|
|
||||||
} from "./pages.js"
|
|
||||||
|
|
||||||
export type LoadResult =
|
|
||||||
| { ok: true; xml: string }
|
|
||||||
| { ok: false; error: string }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode one compressed page body (base64 → raw deflate → URI-decode).
|
|
||||||
* Returns null if the text isn't in that format.
|
|
||||||
*/
|
|
||||||
export function decompressPageContent(compressed: string): string | null {
|
|
||||||
try {
|
|
||||||
const inflated = inflateRawSync(
|
|
||||||
Buffer.from(compressed.trim(), "base64"),
|
|
||||||
).toString("utf-8")
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(inflated)
|
|
||||||
} catch {
|
|
||||||
// Not URI-encoded (older files) — the inflated text is the XML.
|
|
||||||
return inflated
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the content of a .drawio file into the canonical session shape:
|
|
||||||
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
|
|
||||||
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
|
|
||||||
* any compressed pages.
|
|
||||||
*/
|
|
||||||
export function parseDrawioFileContent(content: string): LoadResult {
|
|
||||||
const trimmed = content.trim()
|
|
||||||
if (!trimmed) return { ok: false, error: "File is empty." }
|
|
||||||
|
|
||||||
if (isMxGraphModel(trimmed)) {
|
|
||||||
const normalized = normalizeToMxfile(trimmed)
|
|
||||||
return normalized
|
|
||||||
? { ok: true, xml: normalized }
|
|
||||||
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
|
|
||||||
}
|
|
||||||
if (!isMxFile(trimmed)) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const doc = parseMxfile(trimmed)
|
|
||||||
if (!doc) return { ok: false, error: "Failed to parse <mxfile> XML." }
|
|
||||||
|
|
||||||
let decompressedAny = false
|
|
||||||
for (const d of Array.from(doc.querySelectorAll("diagram"))) {
|
|
||||||
if (d.querySelector("mxGraphModel")) continue
|
|
||||||
const text = (d.textContent || "").trim()
|
|
||||||
if (!text) continue // an empty page is valid
|
|
||||||
const pageLabel =
|
|
||||||
d.getAttribute("name") || d.getAttribute("id") || "unnamed"
|
|
||||||
const xml = decompressPageContent(text)
|
|
||||||
if (!xml || !isMxGraphModel(xml)) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: `Page "${pageLabel}" has content that is neither plain <mxGraphModel> XML nor draw.io's compressed format.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const inner = new DOMParser().parseFromString(xml, "text/xml")
|
|
||||||
if (
|
|
||||||
inner.querySelector("parsererror") ||
|
|
||||||
inner.documentElement?.tagName !== "mxGraphModel"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: `Page "${pageLabel}" decompressed but its XML failed to parse.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.textContent = ""
|
|
||||||
d.appendChild(
|
|
||||||
doc.importNode(inner.documentElement as unknown as Node, true),
|
|
||||||
)
|
|
||||||
decompressedAny = true
|
|
||||||
}
|
|
||||||
// Nothing changed — keep the file's own serialisation.
|
|
||||||
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
|
|
||||||
}
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
/**
|
|
||||||
* Multi-page (mxfile) helpers for draw.io diagrams.
|
|
||||||
*
|
|
||||||
* The on-disk and embed-protocol shape of a draw.io document is:
|
|
||||||
*
|
|
||||||
* <mxfile host="...">
|
|
||||||
* <diagram id="..." name="...">
|
|
||||||
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
|
|
||||||
* </diagram>
|
|
||||||
* ...one or more <diagram> children...
|
|
||||||
* </mxfile>
|
|
||||||
*
|
|
||||||
* This module centralises page CRUD so that index.ts, xml-validation.ts,
|
|
||||||
* and diagram-operations.ts can all agree on:
|
|
||||||
* - what "the canonical in-memory shape" is (always mxfile),
|
|
||||||
* - how to find a page (id, name, or index),
|
|
||||||
* - how to add/rename/delete pages without re-parsing ad-hoc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DOMParser } from "linkedom"
|
|
||||||
|
|
||||||
export interface PageInfo {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
index: number
|
|
||||||
cellCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Selector used by all multi-page-aware tools. All fields optional. */
|
|
||||||
export interface PageSelector {
|
|
||||||
page_id?: string
|
|
||||||
page_name?: string
|
|
||||||
page_index?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if the selector targets a specific page (any field set). */
|
|
||||||
export function hasPageSelector(s?: PageSelector | null): boolean {
|
|
||||||
if (!s) return false
|
|
||||||
return (
|
|
||||||
Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a short page id similar in shape to drawio's auto-assigned ids.
|
|
||||||
* Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself
|
|
||||||
* uses short ids; collisions are still astronomically unlikely for one session.
|
|
||||||
*/
|
|
||||||
export function generatePageId(): string {
|
|
||||||
const a = Math.random().toString(36).substring(2, 10)
|
|
||||||
const b = Math.random().toString(36).substring(2, 6)
|
|
||||||
return `${a}-${b}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cheap regex check — does the XML start with an <mxfile> root? */
|
|
||||||
export function isMxFile(xml: string): boolean {
|
|
||||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
|
|
||||||
export function isMxGraphModel(xml: string): boolean {
|
|
||||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAttr(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip a leading <?xml ... ?> declaration from an XML string. The XML spec
|
|
||||||
* only permits the declaration at the very start of a document, so embedding
|
|
||||||
* a declaration inside another element produces invalid XML. Callers must
|
|
||||||
* strip before splicing a fragment into a wrapper.
|
|
||||||
*/
|
|
||||||
function stripXmlDeclaration(xml: string): string {
|
|
||||||
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
|
|
||||||
* If the input is already an mxfile, returns it unchanged.
|
|
||||||
* If the input is neither shape, returns null so the caller can surface a clear error.
|
|
||||||
*
|
|
||||||
* Strips any leading <?xml ?> declaration before embedding — a declaration is
|
|
||||||
* only valid at the very start of a document, never inside a <diagram>.
|
|
||||||
*/
|
|
||||||
export function normalizeToMxfile(
|
|
||||||
xml: string,
|
|
||||||
opts: { pageId?: string; pageName?: string; host?: string } = {},
|
|
||||||
): string | null {
|
|
||||||
const trimmed = xml.trim()
|
|
||||||
if (!trimmed) return null
|
|
||||||
if (isMxFile(trimmed)) return trimmed
|
|
||||||
if (!isMxGraphModel(trimmed)) return null
|
|
||||||
|
|
||||||
const pageId = opts.pageId || generatePageId()
|
|
||||||
const pageName = opts.pageName || "Page-1"
|
|
||||||
const host = opts.host || "app.diagrams.net"
|
|
||||||
const inner = stripXmlDeclaration(trimmed)
|
|
||||||
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse an mxfile XML string. Returns null on parse error or if the root
|
|
||||||
* isn't <mxfile> — callers are expected to have run normalizeToMxfile first.
|
|
||||||
*/
|
|
||||||
export function parseMxfile(xml: string): Document | null {
|
|
||||||
try {
|
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
|
||||||
if (doc.querySelector("parsererror")) return null
|
|
||||||
if (doc.documentElement?.tagName !== "mxfile") return null
|
|
||||||
return doc as unknown as Document
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */
|
|
||||||
export function serializeMxfile(doc: Document): string {
|
|
||||||
const serializer = new XMLSerializer()
|
|
||||||
return serializer.serializeToString(doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PageProjection =
|
|
||||||
| { ok: true; xml: string; index: number; name: string }
|
|
||||||
| { ok: false; reason: "parse" | "notfound" }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Project a single page out of an mxfile string into a standalone one-page
|
|
||||||
* <mxfile>. Used by get_diagram and export_diagram so the three call sites
|
|
||||||
* share one parse → find → serialise path.
|
|
||||||
*
|
|
||||||
* Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile,
|
|
||||||
* or { ok:false, reason:"notfound" } if the selector matches no page.
|
|
||||||
*/
|
|
||||||
export function projectPage(
|
|
||||||
xml: string,
|
|
||||||
selector: PageSelector,
|
|
||||||
): PageProjection {
|
|
||||||
const doc = parseMxfile(xml)
|
|
||||||
if (!doc) return { ok: false, reason: "parse" }
|
|
||||||
const found = findPageElement(doc, selector)
|
|
||||||
if (!found) return { ok: false, reason: "notfound" }
|
|
||||||
const serializer = new XMLSerializer()
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
xml: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
|
|
||||||
index: found.index,
|
|
||||||
name: found.element.getAttribute("name") || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Walk every <diagram> child of <mxfile> and return summary info. */
|
|
||||||
export function listPagesFromDoc(doc: Document): PageInfo[] {
|
|
||||||
const diagrams = doc.querySelectorAll("diagram")
|
|
||||||
const result: PageInfo[] = []
|
|
||||||
diagrams.forEach((d, idx) => {
|
|
||||||
const root = d.querySelector("root")
|
|
||||||
const cellCount = root ? root.querySelectorAll("mxCell").length : 0
|
|
||||||
result.push({
|
|
||||||
id: d.getAttribute("id") || "",
|
|
||||||
name: d.getAttribute("name") || `Page-${idx + 1}`,
|
|
||||||
index: idx,
|
|
||||||
cellCount,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a page selector to its <diagram> element.
|
|
||||||
* Resolution order: page_id → page_name → page_index → default (first page).
|
|
||||||
*
|
|
||||||
* When no selector field is set we return the first page — the "active page
|
|
||||||
* by convention" mentioned in §3.4 of the design doc.
|
|
||||||
*/
|
|
||||||
export function findPageElement(
|
|
||||||
doc: Document,
|
|
||||||
selector?: PageSelector,
|
|
||||||
): { element: Element; index: number } | null {
|
|
||||||
const diagrams = Array.from(doc.querySelectorAll("diagram"))
|
|
||||||
if (diagrams.length === 0) return null
|
|
||||||
|
|
||||||
if (!hasPageSelector(selector)) {
|
|
||||||
return { element: diagrams[0], index: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selector?.page_id) {
|
|
||||||
for (let i = 0; i < diagrams.length; i++) {
|
|
||||||
if (diagrams[i].getAttribute("id") === selector.page_id) {
|
|
||||||
return { element: diagrams[i], index: i }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (selector?.page_name) {
|
|
||||||
for (let i = 0; i < diagrams.length; i++) {
|
|
||||||
if (diagrams[i].getAttribute("name") === selector.page_name) {
|
|
||||||
return { element: diagrams[i], index: i }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (selector && selector.page_index !== undefined) {
|
|
||||||
const idx = selector.page_index
|
|
||||||
if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) {
|
|
||||||
return { element: diagrams[idx], index: idx }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a new <diagram> to the mxfile doc. The new page's model defaults to
|
|
||||||
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
|
|
||||||
*
|
|
||||||
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
|
|
||||||
* end up nested inside <diagram>, which is malformed. We reject the mxfile
|
|
||||||
* shape explicitly and strip any <?xml ?> declaration (only valid at
|
|
||||||
* document start, never inside <diagram>).
|
|
||||||
*
|
|
||||||
* Returns the new PageInfo. Throws if the requested id collides or the xml
|
|
||||||
* shape is wrong.
|
|
||||||
*/
|
|
||||||
export function addPageToDoc(
|
|
||||||
doc: Document,
|
|
||||||
opts: { id?: string; name?: string; xml?: string } = {},
|
|
||||||
): PageInfo {
|
|
||||||
const existing = listPagesFromDoc(doc)
|
|
||||||
const id = opts.id || generatePageId()
|
|
||||||
if (existing.some((p) => p.id === id)) {
|
|
||||||
throw new Error(`Page id "${id}" already exists`)
|
|
||||||
}
|
|
||||||
const name = opts.name || `Page-${existing.length + 1}`
|
|
||||||
|
|
||||||
let inner: string
|
|
||||||
if (opts.xml?.trim()) {
|
|
||||||
const trimmed = stripXmlDeclaration(opts.xml.trim())
|
|
||||||
if (isMxFile(trimmed)) {
|
|
||||||
throw new Error(
|
|
||||||
"addPageToDoc: opts.xml must be a bare <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!isMxGraphModel(trimmed)) {
|
|
||||||
throw new Error(
|
|
||||||
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
inner = trimmed
|
|
||||||
} else {
|
|
||||||
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
|
||||||
}
|
|
||||||
|
|
||||||
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
|
|
||||||
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
|
|
||||||
if (tempDoc.querySelector("parsererror")) {
|
|
||||||
throw new Error(
|
|
||||||
"Failed to parse new page xml — make sure it is a valid <mxGraphModel>",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const newDiagram = tempDoc.querySelector("diagram")
|
|
||||||
if (!newDiagram) {
|
|
||||||
throw new Error("Failed to construct <diagram> element for new page")
|
|
||||||
}
|
|
||||||
|
|
||||||
const imported = doc.importNode(newDiagram, true) as Element
|
|
||||||
doc.documentElement.appendChild(imported)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
index: existing.length,
|
|
||||||
cellCount: imported.querySelectorAll("mxCell").length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rename the page matched by selector. Returns true on success. */
|
|
||||||
export function renamePageInDoc(
|
|
||||||
doc: Document,
|
|
||||||
selector: PageSelector,
|
|
||||||
newName: string,
|
|
||||||
): boolean {
|
|
||||||
const found = findPageElement(doc, selector)
|
|
||||||
if (!found) return false
|
|
||||||
found.element.setAttribute("name", newName)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a page. Refuses to delete the last remaining page — the embed needs
|
|
||||||
* at least one diagram to render anything, and silently recreating one would
|
|
||||||
* be surprising behaviour for an MCP caller.
|
|
||||||
*/
|
|
||||||
export function deletePageFromDoc(
|
|
||||||
doc: Document,
|
|
||||||
selector: PageSelector,
|
|
||||||
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
|
|
||||||
const pages = listPagesFromDoc(doc)
|
|
||||||
if (pages.length <= 1) {
|
|
||||||
return { ok: false, reason: "Cannot delete the only remaining page" }
|
|
||||||
}
|
|
||||||
const found = findPageElement(doc, selector)
|
|
||||||
if (!found) {
|
|
||||||
return { ok: false, reason: "Page not found" }
|
|
||||||
}
|
|
||||||
const id = found.element.getAttribute("id") || ""
|
|
||||||
const index = found.index
|
|
||||||
found.element.parentNode?.removeChild(found.element)
|
|
||||||
return { ok: true, deletedId: id, deletedIndex: index }
|
|
||||||
}
|
|
||||||
@@ -119,74 +119,8 @@ function checkDuplicateAttributes(xml: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Check for duplicate IDs in XML */
|
||||||
* Check for duplicate IDs in XML.
|
|
||||||
*
|
|
||||||
* For multi-page documents (<mxfile> with multiple <diagram> children), cell
|
|
||||||
* IDs are unique **within a page**, not across the whole document — drawio
|
|
||||||
* legitimately reuses "0" and "1" for the root cells of every page. So we
|
|
||||||
* scope the cell-ID uniqueness check per <diagram>, and additionally check
|
|
||||||
* that the <diagram> ids themselves are unique.
|
|
||||||
*
|
|
||||||
* The legacy regex-based check is kept as a fallback for non-mxfile inputs
|
|
||||||
* and for XML that won't DOM-parse.
|
|
||||||
*/
|
|
||||||
function checkDuplicateIds(xml: string): string | null {
|
function checkDuplicateIds(xml: string): string | null {
|
||||||
// The DOM-aware path only matters for <mxfile> wrappers; for legacy
|
|
||||||
// bare <mxGraphModel> inputs (the overwhelming majority of historic
|
|
||||||
// traffic), the cheap regex fallback at the bottom is enough. A quick
|
|
||||||
// string check avoids paying the DOMParser cost on every call.
|
|
||||||
const mightBeMxFile = /<mxfile[\s>]/i.test(xml)
|
|
||||||
|
|
||||||
// Try DOM-aware, page-scoped check first when the input looks mxfile-ish.
|
|
||||||
if (mightBeMxFile)
|
|
||||||
try {
|
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
|
||||||
if (!doc.querySelector("parsererror")) {
|
|
||||||
const rootEl = doc.documentElement
|
|
||||||
if (rootEl && rootEl.tagName === "mxfile") {
|
|
||||||
const diagrams = doc.querySelectorAll("diagram")
|
|
||||||
|
|
||||||
// 1) <diagram> ids must be unique across the file.
|
|
||||||
const diagramIds = new Map<string, number>()
|
|
||||||
diagrams.forEach((d) => {
|
|
||||||
const id = d.getAttribute("id")
|
|
||||||
if (id)
|
|
||||||
diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
|
|
||||||
})
|
|
||||||
const dupDiagrams = Array.from(diagramIds.entries())
|
|
||||||
.filter(([, c]) => c > 1)
|
|
||||||
.map(([id]) => `'${id}'`)
|
|
||||||
if (dupDiagrams.length > 0) {
|
|
||||||
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Within each page, mxCell ids must be unique.
|
|
||||||
for (let i = 0; i < diagrams.length; i++) {
|
|
||||||
const diagram = diagrams[i]
|
|
||||||
const pageId =
|
|
||||||
diagram.getAttribute("id") || `(index ${i})`
|
|
||||||
const cells = diagram.querySelectorAll("mxCell")
|
|
||||||
const cellIds = new Map<string, number>()
|
|
||||||
cells.forEach((c) => {
|
|
||||||
const id = c.getAttribute("id")
|
|
||||||
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
|
|
||||||
})
|
|
||||||
const dups = Array.from(cellIds.entries())
|
|
||||||
.filter(([, c]) => c > 1)
|
|
||||||
.map(([id, count]) => `'${id}' (${count}x)`)
|
|
||||||
if (dups.length > 0) {
|
|
||||||
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to regex
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy regex-based check for bare <mxGraphModel> and parse-error cases.
|
|
||||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||||
const ids = new Map<string, number>()
|
const ids = new Map<string, number>()
|
||||||
let idMatch
|
let idMatch
|
||||||
@@ -836,14 +770,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 22. Fix duplicate IDs by appending suffix.
|
// 22. Fix duplicate IDs by appending suffix
|
||||||
// Skipped for multi-page <mxfile> documents — cell ids "0" and "1" repeat
|
|
||||||
// across pages legitimately (every page has its own <root> with id="0"/"1"
|
|
||||||
// sentinel cells). Renaming them would break drawio's parent references.
|
|
||||||
// For mxfile inputs, duplicate-id validation is page-scoped in
|
|
||||||
// checkDuplicateIds() and a true duplicate produces a hard error rather
|
|
||||||
// than a silent rename.
|
|
||||||
if (!/<mxfile[\s>]/i.test(fixed)) {
|
|
||||||
const seenIds = new Map<string, number>()
|
const seenIds = new Map<string, number>()
|
||||||
const duplicateIds: string[] = []
|
const duplicateIds: string[] = []
|
||||||
|
|
||||||
@@ -860,9 +787,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
|
|
||||||
if (duplicateIds.length > 0) {
|
if (duplicateIds.length > 0) {
|
||||||
const idCounters = new Map<string, number>()
|
const idCounters = new Map<string, number>()
|
||||||
fixed = fixed.replace(
|
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
|
||||||
/\bid\s*=\s*["']([^"']+)["']/gi,
|
|
||||||
(match, id) => {
|
|
||||||
if (!duplicateIds.includes(id)) return match
|
if (!duplicateIds.includes(id)) return match
|
||||||
|
|
||||||
const count = idCounters.get(id) || 0
|
const count = idCounters.get(id) || 0
|
||||||
@@ -872,11 +797,9 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
|
|
||||||
const newId = `${id}_dup${count}`
|
const newId = `${id}_dup${count}`
|
||||||
return match.replace(id, newId)
|
return match.replace(id, newId)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 23. Fix empty id attributes
|
// 23. Fix empty id attributes
|
||||||
let emptyIdCount = 0
|
let emptyIdCount = 0
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for the edit_diagram workflow gate (edit-gate.ts).
|
|
||||||
*
|
|
||||||
* The gate replaced the old 30-second wall-clock rule (#885): an edit is
|
|
||||||
* allowed when the model has seen the current browser state, no matter how
|
|
||||||
* long ago — and rejected when the browser state moved since. "Seen" is
|
|
||||||
* judged structurally, so draw.io's re-serialisation of the same content
|
|
||||||
* (attribute order, whitespace, viewport attributes, wrapper shape) never
|
|
||||||
* reads as a user edit.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DOMParser } from "linkedom"
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
;(globalThis as any).DOMParser = DOMParser
|
|
||||||
})
|
|
||||||
|
|
||||||
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
|
|
||||||
|
|
||||||
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
|
|
||||||
// The same document as draw.io re-serialises it on autosave: different host,
|
|
||||||
// regenerated diagram id, viewport attributes on mxGraphModel, re-ordered
|
|
||||||
// cell attributes, pretty-printed whitespace.
|
|
||||||
const XML_A_RESERIALIZED = `<mxfile host="embed.diagrams.net">
|
|
||||||
<diagram id="regenerated-id" name="Page-1">
|
|
||||||
<mxGraphModel dx="1596" dy="743" grid="1" pageWidth="827" pageHeight="1169">
|
|
||||||
<root>
|
|
||||||
<mxCell id="0" />
|
|
||||||
<mxCell id="1" parent="0" />
|
|
||||||
<mxCell id="box1" parent="1" style="rounded=0;" value="Hello" vertex="1">
|
|
||||||
<mxGeometry height="60" width="120" x="40" y="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
</root>
|
|
||||||
</mxGraphModel>
|
|
||||||
</diagram>
|
|
||||||
</mxfile>`
|
|
||||||
|
|
||||||
// A real user edit: box1 moved to a different position.
|
|
||||||
const XML_B = XML_A.replace('x="40" y="40"', 'x="300" y="200"')
|
|
||||||
|
|
||||||
// Bare mxGraphModel with identical page content to XML_A.
|
|
||||||
const XML_A_BARE = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
|
||||||
|
|
||||||
describe("checkEditGate", () => {
|
|
||||||
it("rejects when no diagram context was ever established", () => {
|
|
||||||
expect(checkEditGate("", XML_A)).toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: "no-context",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows when the browser state is exactly what the model saw", () => {
|
|
||||||
expect(checkEditGate(XML_A, XML_A)).toEqual({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows when the browser state is a re-serialisation of the same content", () => {
|
|
||||||
expect(checkEditGate(XML_A, XML_A_RESERIALIZED)).toEqual({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects when a cell actually changed", () => {
|
|
||||||
expect(checkEditGate(XML_A, XML_B)).toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: "stale",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a real edit even when wrapped in re-serialisation noise", () => {
|
|
||||||
const movedAndReserialized = XML_A_RESERIALIZED.replace(
|
|
||||||
'x="40" y="40"',
|
|
||||||
'x="300" y="200"',
|
|
||||||
)
|
|
||||||
expect(checkEditGate(XML_A, movedAndReserialized)).toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: "stale",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows when the store has no live entry to compare against", () => {
|
|
||||||
expect(checkEditGate(XML_A, "")).toEqual({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
// A bare <mxGraphModel> push carries no page name, so the gate must not
|
|
||||||
// compare the invented "Page-1" wrapper name against the real one.
|
|
||||||
it("allows a bare mxGraphModel push when the page has a custom name", () => {
|
|
||||||
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
|
||||||
expect(checkEditGate(seenRenamed, XML_A_BARE)).toEqual({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("still rejects a bare mxGraphModel push whose cells changed", () => {
|
|
||||||
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
|
||||||
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
|
|
||||||
expect(checkEditGate(seenRenamed, bareMoved)).toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: "stale",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("contentFingerprint", () => {
|
|
||||||
it("is invariant under draw.io re-serialisation", () => {
|
|
||||||
expect(contentFingerprint(XML_A)).toBe(
|
|
||||||
contentFingerprint(XML_A_RESERIALIZED),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("treats a bare mxGraphModel like its one-page mxfile wrapping", () => {
|
|
||||||
expect(contentFingerprint(XML_A_BARE)).toBe(contentFingerprint(XML_A))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("changes when a cell attribute changes", () => {
|
|
||||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(XML_B))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("changes when a page is renamed", () => {
|
|
||||||
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
|
|
||||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(renamed))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("changes when a page is added", () => {
|
|
||||||
const twoPages = XML_A.replace(
|
|
||||||
"</mxfile>",
|
|
||||||
`<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`,
|
|
||||||
)
|
|
||||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(twoPages))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("falls back to the raw string for unparseable input", () => {
|
|
||||||
expect(contentFingerprint("not xml at all")).toBe("not xml at all")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for load_diagram's file parsing (load-diagram.ts).
|
|
||||||
*
|
|
||||||
* A .drawio file stores each page's <mxGraphModel> either as plain XML or
|
|
||||||
* as draw.io's compressed default (encodeURIComponent → raw deflate →
|
|
||||||
* base64 text content). The loader must produce the canonical session
|
|
||||||
* shape: an <mxfile> whose every page is plain XML.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { deflateRawSync } from "node:zlib"
|
|
||||||
import { DOMParser } from "linkedom"
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
// Install the DOM polyfills exactly as index.ts does at runtime.
|
|
||||||
beforeAll(() => {
|
|
||||||
;(globalThis as any).DOMParser = DOMParser
|
|
||||||
class XMLSerializerPolyfill {
|
|
||||||
serializeToString(node: any): string {
|
|
||||||
if (node.outerHTML !== undefined) return node.outerHTML
|
|
||||||
if (node.documentElement) return node.documentElement.outerHTML
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
|
||||||
})
|
|
||||||
|
|
||||||
import {
|
|
||||||
decompressPageContent,
|
|
||||||
parseDrawioFileContent,
|
|
||||||
} from "../src/load-diagram.js"
|
|
||||||
|
|
||||||
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
|
||||||
|
|
||||||
/** Compress a page body exactly the way draw.io does when saving. */
|
|
||||||
function drawioCompress(xml: string): string {
|
|
||||||
return deflateRawSync(
|
|
||||||
Buffer.from(encodeURIComponent(xml), "utf-8"),
|
|
||||||
).toString("base64")
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAIN_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1">${MODEL_XML}</diagram></mxfile>`
|
|
||||||
const COMPRESSED_MXFILE = `<mxfile host="app.diagrams.net" compressed="true"><diagram id="p1" name="Page-1">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
|
||||||
|
|
||||||
describe("decompressPageContent", () => {
|
|
||||||
it("round-trips draw.io's compressed format", () => {
|
|
||||||
expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles non-URI-encoded legacy payloads", () => {
|
|
||||||
const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString(
|
|
||||||
"base64",
|
|
||||||
)
|
|
||||||
expect(decompressPageContent(legacy)).toBe(MODEL_XML)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns null for garbage", () => {
|
|
||||||
expect(decompressPageContent("not base64 deflate")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("parseDrawioFileContent", () => {
|
|
||||||
it("passes a plain-XML mxfile through unchanged", () => {
|
|
||||||
const r = parseDrawioFileContent(PLAIN_MXFILE)
|
|
||||||
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
|
|
||||||
const r = parseDrawioFileContent(MODEL_XML)
|
|
||||||
expect(r.ok).toBe(true)
|
|
||||||
if (r.ok) {
|
|
||||||
expect(r.xml).toContain("<mxfile")
|
|
||||||
expect(r.xml).toContain('value="Hello"')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("decompresses a compressed mxfile into plain XML pages", () => {
|
|
||||||
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
|
|
||||||
expect(r.ok).toBe(true)
|
|
||||||
if (r.ok) {
|
|
||||||
expect(r.xml).toContain("<mxGraphModel")
|
|
||||||
expect(r.xml).toContain('value="Hello"')
|
|
||||||
// The compressed blob must be gone.
|
|
||||||
expect(r.xml).not.toContain(drawioCompress(MODEL_XML))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("decompresses only the compressed pages of a mixed file", () => {
|
|
||||||
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
|
||||||
const r = parseDrawioFileContent(mixed)
|
|
||||||
expect(r.ok).toBe(true)
|
|
||||||
if (r.ok) {
|
|
||||||
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
|
|
||||||
const diagrams = Array.from(
|
|
||||||
doc.querySelectorAll("diagram"),
|
|
||||||
) as Element[]
|
|
||||||
expect(diagrams).toHaveLength(2)
|
|
||||||
for (const d of diagrams) {
|
|
||||||
expect(d.querySelector("mxGraphModel")).not.toBeNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps empty pages as-is", () => {
|
|
||||||
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
|
|
||||||
const r = parseDrawioFileContent(withEmpty)
|
|
||||||
expect(r).toEqual({ ok: true, xml: withEmpty })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects empty files", () => {
|
|
||||||
const r = parseDrawioFileContent(" ")
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects non-drawio content", () => {
|
|
||||||
const r = parseDrawioFileContent("<svg><rect/></svg>")
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a page whose content is neither XML nor compressed", () => {
|
|
||||||
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
|
|
||||||
const r = parseDrawioFileContent(bad)
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
if (!r.ok) expect(r.error).toContain('"Broken"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for multi-page (mxfile) support.
|
|
||||||
*
|
|
||||||
* Pinned to the user-visible contract described in
|
|
||||||
* multi-page-mcp-support-plan.md §5 (acceptance criteria):
|
|
||||||
*
|
|
||||||
* AC1. create_new_diagram accepts both bare <mxGraphModel> and full <mxfile>.
|
|
||||||
* AC2. get_diagram returns the full <mxfile> regardless of page count.
|
|
||||||
* AC3. edit_diagram accepts an optional page selector.
|
|
||||||
* AC6. Two tool calls reproduce the Transformer/CNN scenario.
|
|
||||||
* AC9. The wrapper-injection hack at http-server.ts:845 is unnecessary.
|
|
||||||
*
|
|
||||||
* These tests pin the helpers (pages.ts), the validator update
|
|
||||||
* (xml-validation.ts), and the page-targeted edit logic
|
|
||||||
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DOMParser } from "linkedom"
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
// Install the DOM polyfill exactly as index.ts does at runtime — the
|
|
||||||
// helpers under test rely on it.
|
|
||||||
beforeAll(() => {
|
|
||||||
;(globalThis as any).DOMParser = DOMParser
|
|
||||||
class XMLSerializerPolyfill {
|
|
||||||
serializeToString(node: any): string {
|
|
||||||
if (node.outerHTML !== undefined) return node.outerHTML
|
|
||||||
if (node.documentElement) return node.documentElement.outerHTML
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
|
||||||
})
|
|
||||||
|
|
||||||
import { applyDiagramOperations } from "../src/diagram-operations.js"
|
|
||||||
import {
|
|
||||||
addPageToDoc,
|
|
||||||
deletePageFromDoc,
|
|
||||||
findPageElement,
|
|
||||||
generatePageId,
|
|
||||||
hasPageSelector,
|
|
||||||
isMxFile,
|
|
||||||
isMxGraphModel,
|
|
||||||
listPagesFromDoc,
|
|
||||||
normalizeToMxfile,
|
|
||||||
parseMxfile,
|
|
||||||
projectPage,
|
|
||||||
renamePageInDoc,
|
|
||||||
serializeMxfile,
|
|
||||||
} from "../src/pages.js"
|
|
||||||
import { validateAndFixXml } from "../src/xml-validation.js"
|
|
||||||
|
|
||||||
const BARE_MODEL_ONE_CELL = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Hello"><mxGeometry x="40" y="40" width="100" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
|
|
||||||
|
|
||||||
const TWO_PAGE_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="page-transformer" name="Transformer"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Encoder"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="page-cnn" name="CNN"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
|
|
||||||
describe("pages.ts — shape detection", () => {
|
|
||||||
it("isMxFile detects a multi-page mxfile", () => {
|
|
||||||
expect(isMxFile(TWO_PAGE_MXFILE)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("isMxFile rejects a bare mxGraphModel", () => {
|
|
||||||
expect(isMxFile(BARE_MODEL_ONE_CELL)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("isMxGraphModel detects a bare model", () => {
|
|
||||||
expect(isMxGraphModel(BARE_MODEL_ONE_CELL)).toBe(true)
|
|
||||||
expect(isMxGraphModel(TWO_PAGE_MXFILE)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("isMxFile tolerates an XML declaration prefix", () => {
|
|
||||||
expect(
|
|
||||||
isMxFile(
|
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>${TWO_PAGE_MXFILE}`,
|
|
||||||
),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pages.ts — normalizeToMxfile (backward compatibility, AC1)", () => {
|
|
||||||
it("wraps a bare mxGraphModel into a single-page mxfile", () => {
|
|
||||||
const out = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
|
|
||||||
pageId: "p1",
|
|
||||||
pageName: "Page-1",
|
|
||||||
})
|
|
||||||
expect(out).not.toBeNull()
|
|
||||||
expect(out).toMatch(/^<mxfile/)
|
|
||||||
expect(out).toContain(`<diagram id="p1" name="Page-1">`)
|
|
||||||
expect(out).toContain("<mxGraphModel>")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns mxfile inputs unchanged", () => {
|
|
||||||
const out = normalizeToMxfile(TWO_PAGE_MXFILE)
|
|
||||||
expect(out).toBe(TWO_PAGE_MXFILE)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns null for neither shape", () => {
|
|
||||||
expect(normalizeToMxfile("<random/>")).toBeNull()
|
|
||||||
expect(normalizeToMxfile("")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("generated page ids look reasonable", () => {
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
const id = generatePageId()
|
|
||||||
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("strips a leading <?xml ?> declaration when wrapping a bare model", () => {
|
|
||||||
// Regression for the bug Copilot caught: isMxGraphModel tolerates a
|
|
||||||
// declaration prefix, but the wrapper used to embed it inside
|
|
||||||
// <diagram>, producing invalid XML (<?xml ?> is only valid at the
|
|
||||||
// document start). The result must round-trip through parseMxfile
|
|
||||||
// and the declaration must be gone from inside <diagram>.
|
|
||||||
const withDecl = `<?xml version="1.0" encoding="UTF-8"?>${BARE_MODEL_ONE_CELL}`
|
|
||||||
const out = normalizeToMxfile(withDecl, {
|
|
||||||
pageId: "p1",
|
|
||||||
pageName: "Page-1",
|
|
||||||
})
|
|
||||||
expect(out).not.toBeNull()
|
|
||||||
expect(out).toMatch(/^<mxfile/)
|
|
||||||
// No <?xml inside the body of the wrapped document.
|
|
||||||
expect(out!.indexOf("<?xml")).toBe(-1)
|
|
||||||
// And it must still parse cleanly.
|
|
||||||
const doc = parseMxfile(out!)
|
|
||||||
expect(doc).not.toBeNull()
|
|
||||||
expect(listPagesFromDoc(doc!)).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pages.ts — addPageToDoc input validation", () => {
|
|
||||||
it("rejects opts.xml shaped as a full <mxfile>", () => {
|
|
||||||
// Regression for the Copilot-flagged bug: an mxfile passed as
|
|
||||||
// starting page xml would end up nested inside <diagram>, corrupting
|
|
||||||
// the document. Must throw with a clear message.
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
expect(() =>
|
|
||||||
addPageToDoc(doc, { name: "Bad", xml: TWO_PAGE_MXFILE }),
|
|
||||||
).toThrowError(/bare <mxGraphModel>/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects opts.xml that is neither mxGraphModel nor mxfile", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
expect(() =>
|
|
||||||
addPageToDoc(doc, { name: "Junk", xml: "<root><x/></root>" }),
|
|
||||||
).toThrowError(/bare <mxGraphModel>/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("strips a <?xml ?> declaration prefix on opts.xml", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
const withDecl = `<?xml version="1.0"?>${BARE_MODEL_ONE_CELL}`
|
|
||||||
const info = addPageToDoc(doc, { name: "Sequence", xml: withDecl })
|
|
||||||
expect(info.cellCount).toBeGreaterThanOrEqual(3)
|
|
||||||
// Serialised document must not have <?xml ?> inside <diagram>.
|
|
||||||
const out = serializeMxfile(doc)
|
|
||||||
// The mxfile may have one <?xml ?> at the very start (the doc decl),
|
|
||||||
// but no further occurrence inside <diagram>.
|
|
||||||
const matches = out.match(/<\?xml/g) || []
|
|
||||||
expect(matches.length).toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pages.ts — listPagesFromDoc / findPageElement", () => {
|
|
||||||
it("lists both pages in a two-page mxfile", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
const pages = listPagesFromDoc(doc)
|
|
||||||
expect(pages).toHaveLength(2)
|
|
||||||
expect(pages[0]).toMatchObject({
|
|
||||||
id: "page-transformer",
|
|
||||||
name: "Transformer",
|
|
||||||
index: 0,
|
|
||||||
})
|
|
||||||
expect(pages[1]).toMatchObject({
|
|
||||||
id: "page-cnn",
|
|
||||||
name: "CNN",
|
|
||||||
index: 1,
|
|
||||||
})
|
|
||||||
// Cell count is per-page (3 cells per page including the two root sentinels).
|
|
||||||
expect(pages[0].cellCount).toBe(3)
|
|
||||||
expect(pages[1].cellCount).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("findPageElement defaults to the first page when selector is empty", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
const found = findPageElement(doc)
|
|
||||||
expect(found?.index).toBe(0)
|
|
||||||
expect(found?.element.getAttribute("id")).toBe("page-transformer")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("findPageElement matches by id, name, and index — id wins when several are set", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
expect(findPageElement(doc, { page_id: "page-cnn" })?.index).toBe(1)
|
|
||||||
expect(findPageElement(doc, { page_name: "CNN" })?.index).toBe(1)
|
|
||||||
expect(findPageElement(doc, { page_index: 1 })?.index).toBe(1)
|
|
||||||
// id beats name beats index
|
|
||||||
const winner = findPageElement(doc, {
|
|
||||||
page_id: "page-cnn",
|
|
||||||
page_name: "Transformer",
|
|
||||||
page_index: 0,
|
|
||||||
})
|
|
||||||
expect(winner?.index).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("findPageElement returns null for an unknown selector", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
expect(findPageElement(doc, { page_id: "ghost" })).toBeNull()
|
|
||||||
expect(findPageElement(doc, { page_name: "ghost" })).toBeNull()
|
|
||||||
expect(findPageElement(doc, { page_index: 99 })).toBeNull()
|
|
||||||
expect(findPageElement(doc, { page_index: -1 })).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("hasPageSelector correctly detects empty vs populated selectors", () => {
|
|
||||||
expect(hasPageSelector()).toBe(false)
|
|
||||||
expect(hasPageSelector({})).toBe(false)
|
|
||||||
expect(hasPageSelector({ page_id: "x" })).toBe(true)
|
|
||||||
expect(hasPageSelector({ page_index: 0 })).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pages.ts — addPageToDoc", () => {
|
|
||||||
it("appends a third page and returns its info", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
const info = addPageToDoc(doc, { name: "Sequence" })
|
|
||||||
expect(info.name).toBe("Sequence")
|
|
||||||
expect(info.index).toBe(2)
|
|
||||||
expect(info.id).toMatch(/.+/)
|
|
||||||
const pages = listPagesFromDoc(doc)
|
|
||||||
expect(pages).toHaveLength(3)
|
|
||||||
expect(pages[2].name).toBe("Sequence")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a duplicate explicit id", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
expect(() =>
|
|
||||||
addPageToDoc(doc, { id: "page-transformer", name: "X" }),
|
|
||||||
).toThrowError(/already exists/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses a sensible default name when none is supplied", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
const info = addPageToDoc(doc, {})
|
|
||||||
expect(info.name).toBe("Page-3")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts an inline starting mxGraphModel", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
const inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="A"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell></root></mxGraphModel>`
|
|
||||||
const info = addPageToDoc(doc, { name: "Custom", xml: inner })
|
|
||||||
expect(info.cellCount).toBeGreaterThanOrEqual(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pages.ts — renamePageInDoc / deletePageFromDoc", () => {
|
|
||||||
it("renames an existing page by name", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
const ok = renamePageInDoc(doc, { page_name: "CNN" }, "CNN-v2")
|
|
||||||
expect(ok).toBe(true)
|
|
||||||
const pages = listPagesFromDoc(doc)
|
|
||||||
expect(pages[1].name).toBe("CNN-v2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rename returns false when target page is missing", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
expect(renamePageInDoc(doc, { page_id: "ghost" }, "Z")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("deletes a page and removes the <diagram> element from the doc", () => {
|
|
||||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
|
||||||
const outcome = deletePageFromDoc(doc, { page_id: "page-cnn" })
|
|
||||||
expect(outcome.ok).toBe(true)
|
|
||||||
expect(outcome.deletedId).toBe("page-cnn")
|
|
||||||
expect(listPagesFromDoc(doc)).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses to delete the only remaining page", () => {
|
|
||||||
// Build a single-page doc to test the guard.
|
|
||||||
const single = normalizeToMxfile(BARE_MODEL_ONE_CELL)!
|
|
||||||
const doc = parseMxfile(single)!
|
|
||||||
const outcome = deletePageFromDoc(doc, { page_index: 0 })
|
|
||||||
expect(outcome.ok).toBe(false)
|
|
||||||
expect(outcome.reason).toMatch(/only remaining page/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("xml-validation.ts — multi-page support", () => {
|
|
||||||
it("accepts a valid two-page mxfile (the exact payload that used to fail)", () => {
|
|
||||||
const result = validateAndFixXml(TWO_PAGE_MXFILE)
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.error).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does NOT flag root sentinel ids 0 and 1 repeating across pages", () => {
|
|
||||||
// This is the regression the planning doc explicitly called out:
|
|
||||||
// before this work, the legacy regex-based duplicate-id check rejected
|
|
||||||
// any multi-page document because cells "0" and "1" appear in every page.
|
|
||||||
const result = validateAndFixXml(TWO_PAGE_MXFILE)
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects duplicate cell ids WITHIN a single page", () => {
|
|
||||||
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="P1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="dup" vertex="1" parent="1"/><mxCell id="dup" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
const result = validateAndFixXml(bad)
|
|
||||||
expect(result.valid).toBe(false)
|
|
||||||
expect(result.error).toMatch(/duplicate cell ID/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects duplicate <diagram> ids across the file", () => {
|
|
||||||
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram id="p1" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
const result = validateAndFixXml(bad)
|
|
||||||
expect(result.valid).toBe(false)
|
|
||||||
expect(result.error).toMatch(/duplicate <diagram> id/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("still validates a bare <mxGraphModel> (legacy callers)", () => {
|
|
||||||
const result = validateAndFixXml(BARE_MODEL_ONE_CELL)
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("auto-fix does NOT rename mxfile root cells 0/1 (would break drawio refs)", () => {
|
|
||||||
// Build a doc that triggers some other auto-fix (so autoFixXml runs)
|
|
||||||
// but contains valid multi-page 0/1 cells that must NOT be renamed.
|
|
||||||
const malformedButMultiPage = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Q & A"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="p2" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
const result = validateAndFixXml(malformedButMultiPage)
|
|
||||||
// The doc has an unescaped & — autoFix will repair that. After repair
|
|
||||||
// it should be valid AND must not have renamed the 0/1 cells.
|
|
||||||
const finalXml = result.fixed || malformedButMultiPage
|
|
||||||
expect(finalXml).not.toMatch(/id="0_dup/)
|
|
||||||
expect(finalXml).not.toMatch(/id="1_dup/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("diagram-operations.ts — page-targeted edits (AC3)", () => {
|
|
||||||
it("adds a cell to the targeted page by id, leaving the other page untouched", () => {
|
|
||||||
const { result, errors } = applyDiagramOperations(
|
|
||||||
TWO_PAGE_MXFILE,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
operation: "add",
|
|
||||||
cell_id: "conv-2",
|
|
||||||
new_xml: `<mxCell id="conv-2" vertex="1" parent="1" value="Conv2"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ page_id: "page-cnn" },
|
|
||||||
)
|
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
const doc = parseMxfile(result)!
|
|
||||||
const pages = listPagesFromDoc(doc)
|
|
||||||
// Transformer untouched (still 3 cells), CNN gained one cell.
|
|
||||||
expect(pages[0].cellCount).toBe(3)
|
|
||||||
expect(pages[1].cellCount).toBe(4)
|
|
||||||
expect(result).toContain(`id="conv-2"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("defaults to the first page when no selector is given", () => {
|
|
||||||
const { result, errors } = applyDiagramOperations(TWO_PAGE_MXFILE, [
|
|
||||||
{
|
|
||||||
operation: "add",
|
|
||||||
cell_id: "shape-x",
|
|
||||||
new_xml: `<mxCell id="shape-x" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>`,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
const doc = parseMxfile(result)!
|
|
||||||
const pages = listPagesFromDoc(doc)
|
|
||||||
expect(pages[0].cellCount).toBe(4) // Transformer (first page) grew
|
|
||||||
expect(pages[1].cellCount).toBe(3) // CNN untouched
|
|
||||||
})
|
|
||||||
|
|
||||||
it("errors clearly when the page is not found", () => {
|
|
||||||
const { errors } = applyDiagramOperations(
|
|
||||||
TWO_PAGE_MXFILE,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
operation: "delete",
|
|
||||||
cell_id: "2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ page_id: "does-not-exist" },
|
|
||||||
)
|
|
||||||
expect(errors).toHaveLength(1)
|
|
||||||
expect(errors[0].message).toMatch(/Page.*not found/i)
|
|
||||||
// Page-level errors carry an empty cellId — edit_diagram relies on
|
|
||||||
// this to distinguish "nothing applied" from per-cell warnings and
|
|
||||||
// return a hard error instead of a false success.
|
|
||||||
expect(errors[0].cellId).toBe("")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("delete on page 2 does NOT touch page 1's mxCell with the same id", () => {
|
|
||||||
// Both pages have a cell with id="2". A delete on CNN's "2" must not
|
|
||||||
// remove Transformer's "2".
|
|
||||||
const { result, errors } = applyDiagramOperations(
|
|
||||||
TWO_PAGE_MXFILE,
|
|
||||||
[{ operation: "delete", cell_id: "2" }],
|
|
||||||
{ page_id: "page-cnn" },
|
|
||||||
)
|
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
const doc = parseMxfile(result)!
|
|
||||||
const pages = listPagesFromDoc(doc)
|
|
||||||
// CNN lost its only non-sentinel cell, Transformer keeps its three.
|
|
||||||
expect(pages[1].cellCount).toBe(2)
|
|
||||||
expect(pages[0].cellCount).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("legacy bare-mxGraphModel input still works when no selector is given", () => {
|
|
||||||
const { result, errors } = applyDiagramOperations(BARE_MODEL_ONE_CELL, [
|
|
||||||
{
|
|
||||||
operation: "add",
|
|
||||||
cell_id: "new",
|
|
||||||
new_xml: `<mxCell id="new" vertex="1" parent="1"><mxGeometry x="100" y="100" width="50" height="50" as="geometry"/></mxCell>`,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
expect(result).toContain(`id="new"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("page selector on a bare mxGraphModel returns a clear error", () => {
|
|
||||||
const { errors } = applyDiagramOperations(
|
|
||||||
BARE_MODEL_ONE_CELL,
|
|
||||||
[{ operation: "delete", cell_id: "2" }],
|
|
||||||
{ page_id: "page-1" },
|
|
||||||
)
|
|
||||||
expect(errors).toHaveLength(1)
|
|
||||||
expect(errors[0].message).toMatch(/not multi-page/i)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("export_diagram — single-page projection (regression for selectPage bug)", () => {
|
|
||||||
// The previous implementation tried to drive drawio's iframe with an
|
|
||||||
// `action: 'selectPage'` postMessage, which the embed protocol silently
|
|
||||||
// ignores. The result was that PNG/SVG exports targeted the currently
|
|
||||||
// active tab regardless of the page selector — two visually different
|
|
||||||
// pages would yield byte-identical PNGs.
|
|
||||||
//
|
|
||||||
// The current implementation builds a single-page <mxfile> projection via
|
|
||||||
// the shared pages.ts:projectPage helper and hands it to the browser
|
|
||||||
// bridge to load BEFORE triggering export. These tests pin that helper so
|
|
||||||
// a future refactor can't silently re-introduce the multi-page drift.
|
|
||||||
function projectSinglePage(fullMxfile: string, sel: any): string {
|
|
||||||
const result = projectPage(fullMxfile, sel)
|
|
||||||
if (!result.ok) throw new Error(`projection failed: ${result.reason}`)
|
|
||||||
return result.xml
|
|
||||||
}
|
|
||||||
|
|
||||||
it("returns a parse error for a non-mxfile source", () => {
|
|
||||||
const result = projectPage(BARE_MODEL_ONE_CELL, { page_id: "x" })
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe("parse")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns a notfound error for an unknown page", () => {
|
|
||||||
const result = projectPage(TWO_PAGE_MXFILE, { page_id: "ghost" })
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe("notfound")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("projects only the requested page when targeted by id", () => {
|
|
||||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
|
||||||
page_id: "page-cnn",
|
|
||||||
})
|
|
||||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
|
||||||
expect(pages).toHaveLength(1)
|
|
||||||
expect(pages[0].id).toBe("page-cnn")
|
|
||||||
expect(pages[0].name).toBe("CNN")
|
|
||||||
// The projection must NOT contain the Transformer page anywhere.
|
|
||||||
expect(projected).not.toContain('id="page-transformer"')
|
|
||||||
expect(projected).not.toContain('name="Transformer"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("projects only the requested page when targeted by name", () => {
|
|
||||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
|
||||||
page_name: "Transformer",
|
|
||||||
})
|
|
||||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
|
||||||
expect(pages).toHaveLength(1)
|
|
||||||
expect(pages[0].name).toBe("Transformer")
|
|
||||||
expect(projected).not.toContain('id="page-cnn"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("projects only the requested page when targeted by index", () => {
|
|
||||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
|
||||||
page_index: 1,
|
|
||||||
})
|
|
||||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
|
||||||
expect(pages).toHaveLength(1)
|
|
||||||
expect(pages[0].index).toBe(0) // re-indexed: it's the only page in the projection
|
|
||||||
expect(pages[0].id).toBe("page-cnn")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("two different page selectors produce visually distinct projections", () => {
|
|
||||||
// The regression: under the old selectPage bug, two exports would
|
|
||||||
// return the same active tab. With the projection approach, the
|
|
||||||
// payload that drawio renders is provably different.
|
|
||||||
const a = projectSinglePage(TWO_PAGE_MXFILE, {
|
|
||||||
page_id: "page-transformer",
|
|
||||||
})
|
|
||||||
const b = projectSinglePage(TWO_PAGE_MXFILE, { page_id: "page-cnn" })
|
|
||||||
expect(a).not.toBe(b)
|
|
||||||
expect(a).toContain('"Encoder"')
|
|
||||||
expect(a).not.toContain('"Conv1"')
|
|
||||||
expect(b).toContain('"Conv1"')
|
|
||||||
expect(b).not.toContain('"Encoder"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("the projection parses to a valid one-page mxfile", () => {
|
|
||||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
|
||||||
page_id: "page-cnn",
|
|
||||||
})
|
|
||||||
// Validator accepts it.
|
|
||||||
expect(validateAndFixXml(projected).valid).toBe(true)
|
|
||||||
// And it has a real <root> with the cells from the source page.
|
|
||||||
const doc = parseMxfile(projected)!
|
|
||||||
const root = doc.querySelector("root")
|
|
||||||
expect(root).not.toBeNull()
|
|
||||||
const conv1 = doc.querySelector('mxCell[value="Conv1"]')
|
|
||||||
expect(conv1).not.toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("end-to-end — Transformer + CNN scenario (AC6)", () => {
|
|
||||||
it("two tool-equivalent steps reproduce the motivating user scenario", () => {
|
|
||||||
// Step 1 — caller passes a single-page mxfile.
|
|
||||||
const step1 = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
|
|
||||||
pageId: "page-transformer",
|
|
||||||
pageName: "Transformer",
|
|
||||||
})
|
|
||||||
expect(step1).not.toBeNull()
|
|
||||||
let xml = step1 as string
|
|
||||||
const validate1 = validateAndFixXml(xml)
|
|
||||||
expect(validate1.valid).toBe(true)
|
|
||||||
|
|
||||||
// Step 2 — equivalent of add_page("CNN") with a starting model.
|
|
||||||
const doc = parseMxfile(xml)!
|
|
||||||
addPageToDoc(doc, {
|
|
||||||
id: "page-cnn",
|
|
||||||
name: "CNN",
|
|
||||||
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
|
||||||
})
|
|
||||||
xml = serializeMxfile(doc)
|
|
||||||
|
|
||||||
// Now: two pages, both valid, with the right names.
|
|
||||||
const pages = listPagesFromDoc(parseMxfile(xml)!)
|
|
||||||
expect(pages.map((p) => p.name)).toEqual(["Transformer", "CNN"])
|
|
||||||
expect(validateAndFixXml(xml).valid).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
|
|
||||||
* and drive it the way a real MCP client does — initialize handshake,
|
|
||||||
* tools/list — to catch registration/schema regressions that the unit tests
|
|
||||||
* (which import helpers directly) can't see.
|
|
||||||
*
|
|
||||||
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
|
|
||||||
* dist/index.js and was therefore never run in CI (CI doesn't build this
|
|
||||||
* package before testing). Running from source via tsx means it executes as
|
|
||||||
* part of the normal `vitest run`.
|
|
||||||
*
|
|
||||||
* We deliberately do NOT call start_session — it would open a real browser
|
|
||||||
* window via open(). The browser bridge is covered by the Playwright e2e suite.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
|
|
||||||
import path from "node:path"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
||||||
const entry = path.resolve(__dirname, "..", "src", "index.ts")
|
|
||||||
const tsxBin = path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"node_modules",
|
|
||||||
".bin",
|
|
||||||
process.platform === "win32" ? "tsx.cmd" : "tsx",
|
|
||||||
)
|
|
||||||
|
|
||||||
const EXPECTED_TOOLS = [
|
|
||||||
"start_session",
|
|
||||||
"create_new_diagram",
|
|
||||||
"load_diagram",
|
|
||||||
"edit_diagram",
|
|
||||||
"get_diagram",
|
|
||||||
"export_diagram",
|
|
||||||
"list_pages",
|
|
||||||
"add_page",
|
|
||||||
"rename_page",
|
|
||||||
"delete_page",
|
|
||||||
]
|
|
||||||
|
|
||||||
let proc: ChildProcessWithoutNullStreams
|
|
||||||
let stdoutBuf = ""
|
|
||||||
const pending = new Map<
|
|
||||||
number,
|
|
||||||
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
|
|
||||||
>()
|
|
||||||
let nextId = 1
|
|
||||||
|
|
||||||
function send(method: string, params: unknown, isNotification = false) {
|
|
||||||
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
|
|
||||||
if (!isNotification) msg.id = nextId++
|
|
||||||
proc.stdin.write(`${JSON.stringify(msg)}\n`)
|
|
||||||
if (isNotification) return Promise.resolve(undefined)
|
|
||||||
return new Promise<any>((resolve, reject) => {
|
|
||||||
const id = msg.id as number
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
reject(new Error(`Timed out waiting for response to ${method}`))
|
|
||||||
}, 15000)
|
|
||||||
pending.set(id, { resolve, reject, timeout })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
proc = spawn(tsxBin, [entry], {
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
}) as ChildProcessWithoutNullStreams
|
|
||||||
|
|
||||||
proc.stdout.on("data", (chunk: Buffer) => {
|
|
||||||
stdoutBuf += chunk.toString()
|
|
||||||
const lines = stdoutBuf.split("\n")
|
|
||||||
stdoutBuf = lines.pop() || ""
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
if (!trimmed) continue
|
|
||||||
let msg: any
|
|
||||||
try {
|
|
||||||
msg = JSON.parse(trimmed)
|
|
||||||
} catch {
|
|
||||||
// Non-JSON-RPC log line — ignore.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
|
|
||||||
if (p) {
|
|
||||||
clearTimeout(p.timeout)
|
|
||||||
pending.delete(msg.id)
|
|
||||||
p.resolve(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const initResp = await send("initialize", {
|
|
||||||
protocolVersion: "2024-11-05",
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: "wiring-test", version: "0.0.0" },
|
|
||||||
})
|
|
||||||
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
|
|
||||||
expect(initResp.result?.serverInfo?.name).toBeTruthy()
|
|
||||||
await send("notifications/initialized", {}, true)
|
|
||||||
}, 30000)
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
proc?.kill("SIGTERM")
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("MCP server wiring", () => {
|
|
||||||
it("registers all nine multi-page tools", async () => {
|
|
||||||
const resp = await send("tools/list", {})
|
|
||||||
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
|
|
||||||
const names: string[] = (resp.result?.tools ?? []).map(
|
|
||||||
(t: { name: string }) => t.name,
|
|
||||||
)
|
|
||||||
for (const expected of EXPECTED_TOOLS) {
|
|
||||||
expect(names, `missing tool: ${expected}`).toContain(expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("advertises page-selector params on edit_diagram", async () => {
|
|
||||||
const resp = await send("tools/list", {})
|
|
||||||
const edit = resp.result.tools.find(
|
|
||||||
(t: { name: string }) => t.name === "edit_diagram",
|
|
||||||
)
|
|
||||||
const props = edit?.inputSchema?.properties ?? {}
|
|
||||||
expect(props.page_id).toBeTruthy()
|
|
||||||
expect(props.page_name).toBeTruthy()
|
|
||||||
expect(props.page_index).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("advertises name/id/xml on add_page", async () => {
|
|
||||||
const resp = await send("tools/list", {})
|
|
||||||
const addPage = resp.result.tools.find(
|
|
||||||
(t: { name: string }) => t.name === "add_page",
|
|
||||||
)
|
|
||||||
const props = addPage?.inputSchema?.properties ?? {}
|
|
||||||
expect(props.name).toBeTruthy()
|
|
||||||
expect(props.id).toBeTruthy()
|
|
||||||
expect(props.xml).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config"
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
include: ["tests/**/*.test.ts"],
|
|
||||||
environment: "node",
|
|
||||||
// The package source uses Node16 module resolution with explicit .js
|
|
||||||
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
|
|
||||||
// transparently, so no extra alias config is needed.
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
ADMIN_PROVIDERS_KEY,
|
|
||||||
adminProvidersToConfig,
|
|
||||||
deriveEnvUpdates,
|
|
||||||
loadAdminProviders,
|
|
||||||
maskAdminProviders,
|
|
||||||
mergeSecrets,
|
|
||||||
type StoredAdminProvider,
|
|
||||||
validateAdminProviders,
|
|
||||||
} from "@/lib/admin/providers"
|
|
||||||
import { _resetForTests, saveSettings } from "@/lib/admin/settings"
|
|
||||||
import { loadRawServerModelsConfig } from "@/lib/server-model-config"
|
|
||||||
|
|
||||||
let tmpDir: string
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-providers-"))
|
|
||||||
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
|
|
||||||
_resetForTests()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
_resetForTests()
|
|
||||||
delete process.env.SETTINGS_FILE
|
|
||||||
delete process.env.AI_MODELS_CONFIG
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
function provider(
|
|
||||||
overrides: Partial<StoredAdminProvider> = {},
|
|
||||||
): StoredAdminProvider {
|
|
||||||
return {
|
|
||||||
id: "p1",
|
|
||||||
provider: "openai",
|
|
||||||
apiKey: "sk-test",
|
|
||||||
models: ["gpt-5.2"],
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("deriveEnvUpdates", () => {
|
|
||||||
it("writes credentials to ADMIN_-prefixed env vars (never shadows standard vars)", () => {
|
|
||||||
const updates = deriveEnvUpdates([provider()], [])
|
|
||||||
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
|
|
||||||
expect(updates.OPENAI_API_KEY).toBeUndefined()
|
|
||||||
expect(JSON.parse(updates.ADMIN_PROVIDERS as string)).toHaveLength(1)
|
|
||||||
// AI_MODELS_CONFIG is no longer written (merged at read time)
|
|
||||||
expect(updates.AI_MODELS_CONFIG).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("suffixes env vars for a second instance of the same provider", () => {
|
|
||||||
const updates = deriveEnvUpdates(
|
|
||||||
[
|
|
||||||
provider({ id: "p1", name: "First" }),
|
|
||||||
provider({
|
|
||||||
id: "p2",
|
|
||||||
name: "Second",
|
|
||||||
apiKey: "sk-second",
|
|
||||||
models: ["gpt-5-mini"],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
|
|
||||||
expect(updates.ADMIN_OPENAI_API_KEY_2).toBe("sk-second")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("maps bedrock credentials to AWS env vars", () => {
|
|
||||||
const updates = deriveEnvUpdates(
|
|
||||||
[
|
|
||||||
provider({
|
|
||||||
provider: "bedrock",
|
|
||||||
apiKey: undefined,
|
|
||||||
awsAccessKeyId: "AKIA123",
|
|
||||||
awsSecretAccessKey: "secret",
|
|
||||||
awsRegion: "us-west-2",
|
|
||||||
models: ["claude-x"],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
expect(updates.AWS_ACCESS_KEY_ID).toBe("AKIA123")
|
|
||||||
expect(updates.AWS_SECRET_ACCESS_KEY).toBe("secret")
|
|
||||||
expect(updates.AWS_REGION).toBe("us-west-2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("clears keys owned by the previous list when providers are removed", () => {
|
|
||||||
const prev = [provider()]
|
|
||||||
const updates = deriveEnvUpdates([], prev)
|
|
||||||
expect(updates.ADMIN_OPENAI_API_KEY).toBeNull()
|
|
||||||
expect(updates.AI_MODELS_CONFIG).toBeNull()
|
|
||||||
expect(updates.ADMIN_PROVIDERS).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("sets AI_PROVIDER/AI_MODEL only when a default is flagged", () => {
|
|
||||||
const noDefault = deriveEnvUpdates([provider()], [])
|
|
||||||
expect(noDefault.AI_PROVIDER).toBeNull()
|
|
||||||
expect(noDefault.AI_MODEL).toBeNull()
|
|
||||||
|
|
||||||
const updates = deriveEnvUpdates(
|
|
||||||
[
|
|
||||||
provider({ id: "p1" }),
|
|
||||||
provider({
|
|
||||||
id: "p2",
|
|
||||||
provider: "deepseek",
|
|
||||||
models: ["deepseek-chat"],
|
|
||||||
isDefault: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
expect(updates.AI_PROVIDER).toBe("deepseek")
|
|
||||||
expect(updates.AI_MODEL).toBe("deepseek-chat")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("adminProvidersToConfig", () => {
|
|
||||||
it("builds a config with ADMIN_-prefixed apiKeyEnv wiring", () => {
|
|
||||||
const config = adminProvidersToConfig([provider()])
|
|
||||||
expect(config.providers).toHaveLength(1)
|
|
||||||
expect(config.providers[0].models).toEqual(["gpt-5.2"])
|
|
||||||
expect(config.providers[0].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("wires suffixed env vars for a second instance", () => {
|
|
||||||
const config = adminProvidersToConfig([
|
|
||||||
provider({ id: "p1", name: "First" }),
|
|
||||||
provider({
|
|
||||||
id: "p2",
|
|
||||||
name: "Second",
|
|
||||||
apiKey: "sk-second",
|
|
||||||
models: ["gpt-5-mini"],
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
expect(config.providers[1].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY_2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("skips providers without models and carries the default flag", () => {
|
|
||||||
const config = adminProvidersToConfig([
|
|
||||||
provider({ id: "p1", models: [] }),
|
|
||||||
provider({ id: "p2", name: "D", isDefault: true }),
|
|
||||||
])
|
|
||||||
expect(config.providers).toHaveLength(1)
|
|
||||||
expect(config.providers[0].default).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("mergeSecrets", () => {
|
|
||||||
it("keeps stored secret when client sends an isSet marker", () => {
|
|
||||||
const stored = [provider({ apiKey: "sk-original" })]
|
|
||||||
const merged = mergeSecrets(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
...provider(),
|
|
||||||
apiKey: { isSet: true, hint: "…test" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stored,
|
|
||||||
)
|
|
||||||
expect(merged[0].apiKey).toBe("sk-original")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("replaces secret when client sends a plaintext string", () => {
|
|
||||||
const stored = [provider({ apiKey: "sk-original" })]
|
|
||||||
const merged = mergeSecrets(
|
|
||||||
[{ ...provider(), apiKey: "sk-new" }],
|
|
||||||
stored,
|
|
||||||
)
|
|
||||||
expect(merged[0].apiKey).toBe("sk-new")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("clears secret when client sends undefined", () => {
|
|
||||||
const stored = [provider({ apiKey: "sk-original" })]
|
|
||||||
const merged = mergeSecrets(
|
|
||||||
[{ ...provider(), apiKey: undefined }],
|
|
||||||
stored,
|
|
||||||
)
|
|
||||||
expect(merged[0].apiKey).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("loadRawServerModelsConfig merge", () => {
|
|
||||||
it("combines env AI_MODELS_CONFIG with panel providers", async () => {
|
|
||||||
process.env.AI_MODELS_CONFIG = JSON.stringify({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "Env OpenAI",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-from-env"],
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
saveSettings(deriveEnvUpdates([provider({ name: "Panel" })], []))
|
|
||||||
|
|
||||||
const merged = await loadRawServerModelsConfig()
|
|
||||||
expect(merged?.providers.map((p) => p.name)).toEqual([
|
|
||||||
"Env OpenAI",
|
|
||||||
"Panel",
|
|
||||||
])
|
|
||||||
// Env default kept because panel set none
|
|
||||||
expect(merged?.providers[0].default).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("panel default overrides the env default", async () => {
|
|
||||||
process.env.AI_MODELS_CONFIG = JSON.stringify({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "Env OpenAI",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-from-env"],
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
saveSettings(
|
|
||||||
deriveEnvUpdates(
|
|
||||||
[provider({ name: "Panel", isDefault: true })],
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const merged = await loadRawServerModelsConfig()
|
|
||||||
expect(merged?.providers[0].default).toBeFalsy()
|
|
||||||
expect(merged?.providers[1].default).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns only env config when the panel has no providers", async () => {
|
|
||||||
process.env.AI_MODELS_CONFIG = JSON.stringify({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "Env Only",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-from-env"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const merged = await loadRawServerModelsConfig()
|
|
||||||
expect(merged?.providers.map((p) => p.name)).toEqual(["Env Only"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("validateAdminProviders", () => {
|
|
||||||
it("rejects names clashing with env-configured providers", () => {
|
|
||||||
expect(
|
|
||||||
validateAdminProviders([provider({ name: "Env OpenAI" })], {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "Env OpenAI",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-x"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toMatch(/already defined/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a global-credential provider already in the env config", () => {
|
|
||||||
expect(
|
|
||||||
validateAdminProviders(
|
|
||||||
[
|
|
||||||
provider({
|
|
||||||
provider: "bedrock",
|
|
||||||
apiKey: undefined,
|
|
||||||
awsAccessKeyId: "AKIA-panel",
|
|
||||||
awsSecretAccessKey: "panel-secret",
|
|
||||||
awsRegion: "us-east-1",
|
|
||||||
models: ["claude-x"],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "Env Bedrock",
|
|
||||||
provider: "bedrock",
|
|
||||||
models: ["claude-env"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).toMatch(/shares global credentials/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows a normal provider type alongside the same env type", () => {
|
|
||||||
expect(
|
|
||||||
validateAdminProviders([provider({ name: "Panel OpenAI" })], {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "Env OpenAI",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-x"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects two bedrock instances", () => {
|
|
||||||
const list = [
|
|
||||||
provider({ id: "p1", provider: "bedrock" }),
|
|
||||||
provider({ id: "p2", provider: "bedrock" }),
|
|
||||||
]
|
|
||||||
expect(validateAdminProviders(list)).toMatch(/Only one/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects duplicate display names", () => {
|
|
||||||
const list = [
|
|
||||||
provider({ id: "p1", name: "Same" }),
|
|
||||||
provider({ id: "p2", name: "Same" }),
|
|
||||||
]
|
|
||||||
expect(validateAdminProviders(list)).toMatch(/unique/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects multiple defaults", () => {
|
|
||||||
const list = [
|
|
||||||
provider({ id: "p1", isDefault: true }),
|
|
||||||
provider({ id: "p2", name: "Other", isDefault: true }),
|
|
||||||
]
|
|
||||||
expect(validateAdminProviders(list)).toMatch(/default/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts a valid list", () => {
|
|
||||||
const list = [
|
|
||||||
provider({ id: "p1", isDefault: true }),
|
|
||||||
provider({ id: "p2", name: "Backup" }),
|
|
||||||
]
|
|
||||||
expect(validateAdminProviders(list)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("loadAdminProviders", () => {
|
|
||||||
it("returns [] when nothing is stored", () => {
|
|
||||||
expect(loadAdminProviders()).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("loads valid stored providers", () => {
|
|
||||||
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([provider()]) })
|
|
||||||
expect(loadAdminProviders()).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("round-trips a bedrock provider with multiple string secrets", () => {
|
|
||||||
const bedrock = provider({
|
|
||||||
provider: "bedrock",
|
|
||||||
apiKey: undefined,
|
|
||||||
awsAccessKeyId: "AKIA123",
|
|
||||||
awsSecretAccessKey: "secret",
|
|
||||||
awsRegion: "us-west-2",
|
|
||||||
models: ["claude-x"],
|
|
||||||
})
|
|
||||||
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([bedrock]) })
|
|
||||||
const loaded = loadAdminProviders()
|
|
||||||
expect(loaded).toHaveLength(1)
|
|
||||||
expect(loaded[0].awsAccessKeyId).toBe("AKIA123")
|
|
||||||
expect(loaded[0].awsSecretAccessKey).toBe("secret")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("drops malformed entries and keeps valid ones", () => {
|
|
||||||
saveSettings({
|
|
||||||
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
|
|
||||||
provider({ id: "good" }),
|
|
||||||
{ id: "missing-fields" }, // no provider/models
|
|
||||||
{ provider: "openai", models: ["x"] }, // no id
|
|
||||||
"not-an-object",
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
const loaded = loadAdminProviders()
|
|
||||||
expect(loaded).toHaveLength(1)
|
|
||||||
expect(loaded[0].id).toBe("good")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns [] when the stored value is not an array", () => {
|
|
||||||
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify({ nope: true }) })
|
|
||||||
expect(loadAdminProviders()).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns [] on invalid JSON", () => {
|
|
||||||
saveSettings({ [ADMIN_PROVIDERS_KEY]: "{ broken" })
|
|
||||||
expect(loadAdminProviders()).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("drops entries whose secret is an {isSet} marker, not a string", () => {
|
|
||||||
// A hand-edited file could hold a transit-only marker object; if it
|
|
||||||
// slipped through, maskSecret() would throw on a non-string value.
|
|
||||||
saveSettings({
|
|
||||||
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
|
|
||||||
{ ...provider(), apiKey: { isSet: true, hint: "…1234" } },
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
const loaded = loadAdminProviders()
|
|
||||||
expect(loaded).toEqual([])
|
|
||||||
// Masking the loaded list must not throw
|
|
||||||
expect(() => maskAdminProviders(loaded)).not.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
_resetForTests,
|
|
||||||
applyToEnv,
|
|
||||||
getEnvFallback,
|
|
||||||
getValueSource,
|
|
||||||
isSettingsWritable,
|
|
||||||
loadSettings,
|
|
||||||
saveSettings,
|
|
||||||
} from "@/lib/admin/settings"
|
|
||||||
|
|
||||||
let tmpDir: string
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-settings-"))
|
|
||||||
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
|
|
||||||
_resetForTests()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
_resetForTests()
|
|
||||||
delete process.env.SETTINGS_FILE
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
||||||
delete process.env.TEST_ADMIN_VAR
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("loadSettings", () => {
|
|
||||||
it("returns empty object when file does not exist", () => {
|
|
||||||
expect(loadSettings()).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reads values from the settings file", () => {
|
|
||||||
fs.writeFileSync(
|
|
||||||
process.env.SETTINGS_FILE!,
|
|
||||||
JSON.stringify({ version: 1, values: { TEST_ADMIN_VAR: "abc" } }),
|
|
||||||
)
|
|
||||||
expect(loadSettings()).toEqual({ TEST_ADMIN_VAR: "abc" })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("drops non-string values from a corrupted file", () => {
|
|
||||||
fs.writeFileSync(
|
|
||||||
process.env.SETTINGS_FILE!,
|
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
values: {
|
|
||||||
GOOD: "ok",
|
|
||||||
NUM: 5,
|
|
||||||
OBJ: { nested: true },
|
|
||||||
ARR: [1, 2],
|
|
||||||
NULL: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(loadSettings()).toEqual({ GOOD: "ok" })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns empty object when values is null", () => {
|
|
||||||
fs.writeFileSync(
|
|
||||||
process.env.SETTINGS_FILE!,
|
|
||||||
JSON.stringify({ version: 1, values: null }),
|
|
||||||
)
|
|
||||||
expect(loadSettings()).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns empty object when values is an array (no numeric keys)", () => {
|
|
||||||
fs.writeFileSync(
|
|
||||||
process.env.SETTINGS_FILE!,
|
|
||||||
JSON.stringify({ version: 1, values: ["a", "b"] }),
|
|
||||||
)
|
|
||||||
// Without the Array.isArray guard this would yield { "0": "a", ... }
|
|
||||||
expect(loadSettings()).toEqual({})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("applyToEnv / saveSettings", () => {
|
|
||||||
it("overlays file values onto process.env", () => {
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: "from-file" })
|
|
||||||
expect(process.env.TEST_ADMIN_VAR).toBe("from-file")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("file value wins over pre-existing env value", () => {
|
|
||||||
process.env.TEST_ADMIN_VAR = "from-env"
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: "from-file" })
|
|
||||||
expect(process.env.TEST_ADMIN_VAR).toBe("from-file")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("deleting a key restores the original env value", () => {
|
|
||||||
process.env.TEST_ADMIN_VAR = "from-env"
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: "from-file" })
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: null })
|
|
||||||
expect(process.env.TEST_ADMIN_VAR).toBe("from-env")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("deleting a key unsets env when there was no original value", () => {
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: "from-file" })
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: null })
|
|
||||||
expect(process.env.TEST_ADMIN_VAR).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("persists across cache reset (file round-trip)", () => {
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: "persisted" })
|
|
||||||
_resetForTests()
|
|
||||||
applyToEnv()
|
|
||||||
expect(process.env.TEST_ADMIN_VAR).toBe("persisted")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("getValueSource / getEnvFallback", () => {
|
|
||||||
it("reports file source when key is in settings", () => {
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: "x" })
|
|
||||||
expect(getValueSource("TEST_ADMIN_VAR")).toBe("file")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports env source when only env is set", () => {
|
|
||||||
process.env.TEST_ADMIN_VAR = "from-env"
|
|
||||||
applyToEnv()
|
|
||||||
expect(getValueSource("TEST_ADMIN_VAR")).toBe("env")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports default when neither is set", () => {
|
|
||||||
expect(getValueSource("TEST_ADMIN_VAR")).toBe("default")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns the shadowed env value as fallback", () => {
|
|
||||||
process.env.TEST_ADMIN_VAR = "from-env"
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: "from-file" })
|
|
||||||
expect(getEnvFallback("TEST_ADMIN_VAR")).toBe("from-env")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("isSettingsWritable", () => {
|
|
||||||
it("returns true for a writable temp dir", () => {
|
|
||||||
expect(isSettingsWritable()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for an unwritable path", () => {
|
|
||||||
_resetForTests()
|
|
||||||
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
|
|
||||||
expect(isSettingsWritable()).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("settings file on disk", () => {
|
|
||||||
it("writes valid JSON with restrictive permissions", () => {
|
|
||||||
saveSettings({ TEST_ADMIN_VAR: "secret" })
|
|
||||||
const filePath = process.env.SETTINGS_FILE!
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"))
|
|
||||||
expect(parsed).toEqual({
|
|
||||||
version: 1,
|
|
||||||
values: { TEST_ADMIN_VAR: "secret" },
|
|
||||||
})
|
|
||||||
const mode = fs.statSync(filePath).mode & 0o777
|
|
||||||
expect(mode).toBe(0o600)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
import {
|
import {
|
||||||
getAIModel,
|
getAIModel,
|
||||||
isAihubmixStandardBaseURL,
|
|
||||||
resolveBaseURL,
|
resolveBaseURL,
|
||||||
|
supportsImageInput,
|
||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { extractAihubmixModelIds } from "@/lib/aihubmix-models"
|
|
||||||
|
|
||||||
describe("extractAihubmixModelIds", () => {
|
|
||||||
it("extracts unique chat model IDs from the AIHubMix model list payload", () => {
|
|
||||||
const models = extractAihubmixModelIds({
|
|
||||||
data: [
|
|
||||||
{ model_id: "claude-sonnet-4-5-20250929", types: "llm" },
|
|
||||||
{ model_id: "gpt-5.1", types: "llm" },
|
|
||||||
{ model_id: "gpt-5.1", types: "llm" },
|
|
||||||
{ model_id: "gpt-image-2", types: "image_generation,llm" },
|
|
||||||
{ model_id: "cohere-rerank-v4.0", types: "rerank" },
|
|
||||||
{ model_id: "", types: "llm" },
|
|
||||||
{ types: "llm" },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(models).toEqual(["claude-sonnet-4-5-20250929", "gpt-5.1"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns an empty list for malformed payloads", () => {
|
|
||||||
expect(extractAihubmixModelIds({ data: null })).toEqual([])
|
|
||||||
expect(extractAihubmixModelIds({})).toEqual([])
|
|
||||||
expect(extractAihubmixModelIds(null)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("resolveBaseURL", () => {
|
describe("resolveBaseURL", () => {
|
||||||
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
||||||
@@ -182,6 +157,86 @@ describe("supportsPromptCaching", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("supportsImageInput", () => {
|
||||||
|
it("returns true for models with vision capability", () => {
|
||||||
|
expect(supportsImageInput("gpt-4-vision")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen-vl")).toBe(true)
|
||||||
|
expect(supportsImageInput("deepseek-vl")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for Kimi K2 models without vision", () => {
|
||||||
|
expect(supportsImageInput("kimi-k2")).toBe(false)
|
||||||
|
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for Kimi K2.5 models (supports vision)", () => {
|
||||||
|
expect(supportsImageInput("kimi-k2.5")).toBe(true)
|
||||||
|
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for Moonshot v1 text models", () => {
|
||||||
|
expect(supportsImageInput("moonshot-v1-8k")).toBe(false)
|
||||||
|
expect(supportsImageInput("moonshot-v1-32k")).toBe(false)
|
||||||
|
expect(supportsImageInput("moonshot-v1-128k")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for MiniMax text models", () => {
|
||||||
|
expect(supportsImageInput("MiniMax-M2.7")).toBe(false)
|
||||||
|
expect(supportsImageInput("MiniMax-M2.5")).toBe(false)
|
||||||
|
expect(supportsImageInput("MiniMax-M2")).toBe(false)
|
||||||
|
expect(supportsImageInput("MiniMax-M2.5-highspeed")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for DeepSeek text models", () => {
|
||||||
|
expect(supportsImageInput("deepseek-chat")).toBe(false)
|
||||||
|
expect(supportsImageInput("deepseek-coder")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for Qwen text models", () => {
|
||||||
|
expect(supportsImageInput("qwen-turbo")).toBe(false)
|
||||||
|
expect(supportsImageInput("qwen-plus")).toBe(false)
|
||||||
|
expect(supportsImageInput("qwen3-max")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for Qwen vision models", () => {
|
||||||
|
expect(supportsImageInput("qwen-vl")).toBe(true)
|
||||||
|
expect(supportsImageInput("Qwen3.5")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen3.5")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen3.5-plus")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen3.5-flash")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen3-vl-plus")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen3-vl-flash")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for QvQ (Qwen Visual QA) models including OpenRouter-prefixed names", () => {
|
||||||
|
expect(supportsImageInput("qvq-72b-preview")).toBe(true)
|
||||||
|
expect(supportsImageInput("qvq-max")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen/qvq-72b-preview")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen/qvq-max")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for GLM text models", () => {
|
||||||
|
expect(supportsImageInput("glm-4")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-4-plus")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-4-flash")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-4-long")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-4.7")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-5")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for GLM vision models", () => {
|
||||||
|
expect(supportsImageInput("glm-4v")).toBe(true)
|
||||||
|
expect(supportsImageInput("glm-4v-9b")).toBe(true)
|
||||||
|
expect(supportsImageInput("glm-4.1v-9b-thinking")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for Claude and GPT models by default", () => {
|
||||||
|
expect(supportsImageInput("claude-sonnet-4-5")).toBe(true)
|
||||||
|
expect(supportsImageInput("gpt-4o")).toBe(true)
|
||||||
|
expect(supportsImageInput("gemini-pro")).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock("ollama-ai-provider-v2", () => {
|
vi.mock("ollama-ai-provider-v2", () => {
|
||||||
const mockModel = { modelId: "test-model" }
|
const mockModel = { modelId: "test-model" }
|
||||||
const mockProviderFn = vi.fn(() => mockModel)
|
const mockProviderFn = vi.fn(() => mockModel)
|
||||||
@@ -198,128 +253,6 @@ vi.mock("@ai-sdk/deepseek", () => {
|
|||||||
return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek }
|
return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek }
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock("@aihubmix/ai-sdk-provider", () => {
|
|
||||||
const mockModel = { modelId: "test-model" }
|
|
||||||
const mockProviderFn = vi.fn(() => mockModel)
|
|
||||||
const mockCreateAihubmix = vi.fn(() => mockProviderFn)
|
|
||||||
const mockAihubmix = vi.fn(() => mockModel)
|
|
||||||
return { aihubmix: mockAihubmix, createAihubmix: mockCreateAihubmix }
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock("@ai-sdk/openai", () => {
|
|
||||||
const mockModel = { modelId: "test-model" }
|
|
||||||
const mockChat = vi.fn(() => mockModel)
|
|
||||||
const mockProviderFn = vi.fn(() => mockModel) as any
|
|
||||||
mockProviderFn.chat = mockChat
|
|
||||||
const mockCreateOpenAI = vi.fn(() => mockProviderFn)
|
|
||||||
const mockOpenai = vi.fn(() => mockModel)
|
|
||||||
return { createOpenAI: mockCreateOpenAI, openai: mockOpenai }
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("AIHubMix provider", () => {
|
|
||||||
let createAihubmixMock: ReturnType<typeof vi.fn>
|
|
||||||
const savedEnv: Record<string, string | undefined> = {}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
savedEnv.AIHUBMIX_API_KEY = process.env.AIHUBMIX_API_KEY
|
|
||||||
savedEnv.AIHUBMIX_BASE_URL = process.env.AIHUBMIX_BASE_URL
|
|
||||||
delete process.env.AIHUBMIX_BASE_URL
|
|
||||||
|
|
||||||
const mod = await import("@aihubmix/ai-sdk-provider")
|
|
||||||
createAihubmixMock = mod.createAihubmix as ReturnType<typeof vi.fn>
|
|
||||||
createAihubmixMock.mockClear()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.AIHUBMIX_API_KEY = savedEnv.AIHUBMIX_API_KEY
|
|
||||||
process.env.AIHUBMIX_BASE_URL = savedEnv.AIHUBMIX_BASE_URL
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses AIHUBMIX_API_KEY for server configured AIHubMix", () => {
|
|
||||||
process.env.AIHUBMIX_API_KEY = "server-aihubmix-key"
|
|
||||||
|
|
||||||
getAIModel({
|
|
||||||
provider: "aihubmix",
|
|
||||||
modelId: "claude-sonnet-4-5-20250929",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(createAihubmixMock).toHaveBeenCalledWith({
|
|
||||||
apiKey: "server-aihubmix-key",
|
|
||||||
appCode: "MSBS9675",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses client BYOK API key for AIHubMix", () => {
|
|
||||||
getAIModel({
|
|
||||||
provider: "aihubmix",
|
|
||||||
apiKey: "client-aihubmix-key",
|
|
||||||
modelId: "gpt-5.1",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(createAihubmixMock).toHaveBeenCalledWith({
|
|
||||||
apiKey: "client-aihubmix-key",
|
|
||||||
appCode: "MSBS9675",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recognizes AIHubMix standard endpoints", () => {
|
|
||||||
expect(isAihubmixStandardBaseURL(undefined)).toBe(true)
|
|
||||||
expect(isAihubmixStandardBaseURL("https://aihubmix.com")).toBe(true)
|
|
||||||
expect(isAihubmixStandardBaseURL("https://aihubmix.com/v1/")).toBe(true)
|
|
||||||
expect(isAihubmixStandardBaseURL("https://proxy.example.com/v1")).toBe(
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Atlas Cloud provider", () => {
|
|
||||||
let createOpenAIMock: ReturnType<typeof vi.fn>
|
|
||||||
const savedEnv: Record<string, string | undefined> = {}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
savedEnv.ATLASCLOUD_API_KEY = process.env.ATLASCLOUD_API_KEY
|
|
||||||
savedEnv.ATLASCLOUD_BASE_URL = process.env.ATLASCLOUD_BASE_URL
|
|
||||||
delete process.env.ATLASCLOUD_BASE_URL
|
|
||||||
|
|
||||||
const mod = await import("@ai-sdk/openai")
|
|
||||||
createOpenAIMock = mod.createOpenAI as ReturnType<typeof vi.fn>
|
|
||||||
createOpenAIMock.mockClear()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.ATLASCLOUD_API_KEY = savedEnv.ATLASCLOUD_API_KEY
|
|
||||||
process.env.ATLASCLOUD_BASE_URL = savedEnv.ATLASCLOUD_BASE_URL
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses Atlas Cloud default endpoint with ATLASCLOUD_API_KEY", () => {
|
|
||||||
process.env.ATLASCLOUD_API_KEY = "server-atlas-key"
|
|
||||||
|
|
||||||
getAIModel({
|
|
||||||
provider: "atlascloud",
|
|
||||||
modelId: "qwen/qwen3.5-flash",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(createOpenAIMock).toHaveBeenCalledWith({
|
|
||||||
apiKey: "server-atlas-key",
|
|
||||||
baseURL: "https://api.atlascloud.ai/v1",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses custom Atlas Cloud base URL when provided", () => {
|
|
||||||
getAIModel({
|
|
||||||
provider: "atlascloud",
|
|
||||||
apiKey: "client-atlas-key",
|
|
||||||
baseUrl: "https://proxy.example.com/v1",
|
|
||||||
modelId: "deepseek-ai/deepseek-v4-pro",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(createOpenAIMock).toHaveBeenCalledWith({
|
|
||||||
apiKey: "client-atlas-key",
|
|
||||||
baseURL: "https://proxy.example.com/v1",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
|
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
|
||||||
let createDeepSeekMock: ReturnType<typeof vi.fn>
|
let createDeepSeekMock: ReturnType<typeof vi.fn>
|
||||||
const savedEnv: Record<string, string | undefined> = {}
|
const savedEnv: Record<string, string | undefined> = {}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
import { afterEach, describe, expect, it } from "vitest"
|
||||||
import { _resetForTests } from "@/lib/admin/settings"
|
|
||||||
import {
|
import {
|
||||||
loadFlattenedServerModels,
|
loadFlattenedServerModels,
|
||||||
type ServerModelsConfig,
|
type ServerModelsConfig,
|
||||||
@@ -8,20 +7,11 @@ import {
|
|||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env }
|
const ORIGINAL_ENV = { ...process.env }
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Isolate from any local data/settings.json (admin panel providers
|
|
||||||
// are merged into the server models config)
|
|
||||||
process.env.SETTINGS_FILE = "/nonexistent/settings.json"
|
|
||||||
_resetForTests()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
_resetForTests()
|
|
||||||
process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER
|
process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER
|
||||||
process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL
|
process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL
|
||||||
process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH
|
process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH
|
||||||
process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG
|
process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG
|
||||||
delete process.env.SETTINGS_FILE
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("ServerModelsConfigSchema", () => {
|
describe("ServerModelsConfigSchema", () => {
|
||||||
@@ -39,22 +29,6 @@ describe("ServerModelsConfigSchema", () => {
|
|||||||
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("accepts Atlas Cloud provider names", () => {
|
|
||||||
const config: ServerModelsConfig = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "Atlas Cloud Server",
|
|
||||||
provider: "atlascloud",
|
|
||||||
models: ["qwen/qwen3.5-flash"],
|
|
||||||
apiKeyEnv: "ATLASCLOUD_API_KEY",
|
|
||||||
baseUrlEnv: "ATLASCLOUD_BASE_URL",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects invalid provider names", () => {
|
it("rejects invalid provider names", () => {
|
||||||
const invalidConfig = {
|
const invalidConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -175,44 +149,6 @@ describe("loadFlattenedServerModels", () => {
|
|||||||
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
||||||
})
|
})
|
||||||
|
|
||||||
it("falls back to comma-separated AI_MODEL when no other config is set", async () => {
|
|
||||||
process.env.AI_MODELS_CONFIG = ""
|
|
||||||
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
|
|
||||||
process.env.AI_PROVIDER = "openai"
|
|
||||||
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini, gpt-4o"
|
|
||||||
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
|
|
||||||
// Trims, deduplicates, and preserves order
|
|
||||||
expect(models.map((m) => m.modelId)).toEqual(["gpt-4o", "gpt-4o-mini"])
|
|
||||||
expect(models.every((m) => m.provider === "openai")).toBe(true)
|
|
||||||
|
|
||||||
// First model is marked default (provider has default: true)
|
|
||||||
const defaults = models.filter((m) => m.isDefault)
|
|
||||||
expect(defaults.length).toBe(1)
|
|
||||||
expect(defaults[0].modelId).toBe("gpt-4o")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not synthesize when AI_MODEL has no comma", async () => {
|
|
||||||
process.env.AI_MODELS_CONFIG = ""
|
|
||||||
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
|
|
||||||
process.env.AI_PROVIDER = "openai"
|
|
||||||
process.env.AI_MODEL = "gpt-4o"
|
|
||||||
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
expect(models).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not synthesize when AI_PROVIDER is unset", async () => {
|
|
||||||
process.env.AI_MODELS_CONFIG = ""
|
|
||||||
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
|
|
||||||
delete process.env.AI_PROVIDER
|
|
||||||
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini"
|
|
||||||
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
expect(models).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
|
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
|
||||||
const config: ServerModelsConfig = {
|
const config: ServerModelsConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
||||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
|
|
||||||
// Mock DNS so tests are deterministic and never hit the network.
|
|
||||||
const lookupMock = vi.hoisted(() => vi.fn())
|
|
||||||
vi.mock("node:dns/promises", () => ({
|
|
||||||
default: { lookup: lookupMock },
|
|
||||||
lookup: lookupMock,
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe("isPrivateUrl", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
lookupMock.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("blocks private IPv6 URLs (string-only fast path, no DNS)", async () => {
|
|
||||||
expect(await isPrivateUrl("http://[::1]/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://[::]/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://[fc00::1]/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://[fe80::1]/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://[fe9f::1]/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://[febf::1]/")).toBe(true)
|
|
||||||
expect(lookupMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("blocks literal private IPv4 without DNS", async () => {
|
|
||||||
expect(await isPrivateUrl("http://127.0.0.1/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://10.0.0.5/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://192.168.1.1/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://169.254.169.254/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://0.0.0.0/")).toBe(true)
|
|
||||||
// 100.64.0.0/10 CGNAT (RFC 6598), routable in some cloud internal nets
|
|
||||||
expect(await isPrivateUrl("http://100.64.0.1/")).toBe(true)
|
|
||||||
expect(await isPrivateUrl("http://100.127.255.255/")).toBe(true)
|
|
||||||
expect(lookupMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("treats CGNAT boundaries correctly", async () => {
|
|
||||||
// 100.63.x and 100.128.x are outside 100.64.0.0/10 → public
|
|
||||||
lookupMock.mockResolvedValue([{ address: "100.63.255.255", family: 4 }])
|
|
||||||
expect(await isPrivateUrl("http://just-below.example/")).toBe(false)
|
|
||||||
lookupMock.mockResolvedValue([{ address: "100.128.0.1", family: 4 }])
|
|
||||||
expect(await isPrivateUrl("http://just-above.example/")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("blocks a hostname that resolves to a private IPv6 address", async () => {
|
|
||||||
lookupMock.mockResolvedValue([{ address: "fd00::1", family: 6 }])
|
|
||||||
expect(await isPrivateUrl("http://v6.example.com/")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows public URLs that resolve to public IPs", async () => {
|
|
||||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }])
|
|
||||||
expect(await isPrivateUrl("https://example.com/article")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("blocks public-looking hostnames that resolve to a private IP (DNS-rebinding-style bypass)", async () => {
|
|
||||||
// e.g. 127-0-0-1.sslip.io resolves to 127.0.0.1
|
|
||||||
lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }])
|
|
||||||
expect(await isPrivateUrl("http://127-0-0-1.sslip.io/")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("blocks when any resolved address is private", async () => {
|
|
||||||
lookupMock.mockResolvedValue([
|
|
||||||
{ address: "93.184.216.34", family: 4 },
|
|
||||||
{ address: "10.1.2.3", family: 4 },
|
|
||||||
])
|
|
||||||
expect(await isPrivateUrl("http://mixed.example.com/")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("blocks when DNS resolution fails", async () => {
|
|
||||||
lookupMock.mockRejectedValue(new Error("ENOTFOUND"))
|
|
||||||
expect(await isPrivateUrl("http://does-not-resolve.example/")).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user