mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
11 Commits
chore/add-
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5de8bbae | ||
|
|
38a247c55d | ||
|
|
a0e3130ac2 | ||
|
|
6bd26c8bbd | ||
|
|
d2e51f159f | ||
|
|
9677737745 | ||
|
|
d5774b336c | ||
|
|
0d79487b6c | ||
|
|
fbce1baf16 | ||
|
|
c3d3afc202 | ||
|
|
651238529a |
@@ -4,7 +4,6 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
|||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import ChatPanel from "@/components/chat-panel"
|
import ChatPanel from "@/components/chat-panel"
|
||||||
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
@@ -17,8 +16,13 @@ const drawioBaseUrl =
|
|||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
const {
|
||||||
useDiagram()
|
drawioRef,
|
||||||
|
handleDiagramExport,
|
||||||
|
handleAutoSave,
|
||||||
|
onDrawioLoad,
|
||||||
|
resetDrawioReady,
|
||||||
|
} = useDiagram()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||||
@@ -29,7 +33,6 @@ export default function Home() {
|
|||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const [closeProtection, setCloseProtection] = useState(false)
|
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
@@ -66,13 +69,6 @@ export default function Home() {
|
|||||||
document.documentElement.classList.toggle("dark", prefersDark)
|
document.documentElement.classList.toggle("dark", prefersDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedCloseProtection = localStorage.getItem(
|
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
|
||||||
)
|
|
||||||
if (savedCloseProtection === "true") {
|
|
||||||
setCloseProtection(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
@@ -146,20 +142,6 @@ export default function Home() {
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Show confirmation dialog when user tries to leave the page
|
|
||||||
useEffect(() => {
|
|
||||||
if (!closeProtection) return
|
|
||||||
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
|
||||||
}, [closeProtection])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-background relative overflow-hidden">
|
<div className="h-screen bg-background relative overflow-hidden">
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@@ -187,6 +169,8 @@ export default function Home() {
|
|||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={handleDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
|
onAutoSave={handleAutoSave}
|
||||||
|
autosave={true}
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
ui: drawioUi,
|
||||||
@@ -243,7 +227,6 @@ export default function Home() {
|
|||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
onToggleDarkMode={handleDarkModeChange}
|
onToggleDarkMode={handleDarkModeChange}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onCloseProtectionChange={setCloseProtection}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ interface ChatPanelProps {
|
|||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
onToggleDarkMode: () => void
|
onToggleDarkMode: () => void
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
onCloseProtectionChange?: (enabled: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constants for tool states
|
// Constants for tool states
|
||||||
@@ -111,7 +110,6 @@ export default function ChatPanel({
|
|||||||
darkMode,
|
darkMode,
|
||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
onCloseProtectionChange,
|
|
||||||
}: ChatPanelProps) {
|
}: ChatPanelProps) {
|
||||||
const {
|
const {
|
||||||
loadDiagram: onDisplayChart,
|
loadDiagram: onDisplayChart,
|
||||||
@@ -1296,7 +1294,6 @@ export default function ChatPanel({
|
|||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
open={showSettingsDialog}
|
open={showSettingsDialog}
|
||||||
onOpenChange={setShowSettingsDialog}
|
onOpenChange={setShowSettingsDialog}
|
||||||
onCloseProtectionChange={onCloseProtectionChange}
|
|
||||||
drawioUi={drawioUi}
|
drawioUi={drawioUi}
|
||||||
onToggleDrawioUi={onToggleDrawioUi}
|
onToggleDrawioUi={onToggleDrawioUi}
|
||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
|||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onCloseProtectionChange?: (enabled: boolean) => void
|
|
||||||
drawioUi: "min" | "sketch"
|
drawioUi: "min" | "sketch"
|
||||||
onToggleDrawioUi: () => void
|
onToggleDrawioUi: () => void
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
@@ -71,7 +70,6 @@ interface SettingsDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
|
||||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||||
|
|
||||||
function getStoredAccessCodeRequired(): boolean | null {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
@@ -84,7 +82,6 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
function SettingsContent({
|
function SettingsContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCloseProtectionChange,
|
|
||||||
drawioUi,
|
drawioUi,
|
||||||
onToggleDrawioUi,
|
onToggleDrawioUi,
|
||||||
darkMode,
|
darkMode,
|
||||||
@@ -97,7 +94,6 @@ function SettingsContent({
|
|||||||
const pathname = usePathname() || "/"
|
const pathname = usePathname() || "/"
|
||||||
const search = useSearchParams()
|
const search = useSearchParams()
|
||||||
const [accessCode, setAccessCode] = useState("")
|
const [accessCode, setAccessCode] = useState("")
|
||||||
const [closeProtection, setCloseProtection] = useState(true)
|
|
||||||
const [isVerifying, setIsVerifying] = useState(false)
|
const [isVerifying, setIsVerifying] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
@@ -151,12 +147,6 @@ function SettingsContent({
|
|||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||||
setAccessCode(storedCode)
|
setAccessCode(storedCode)
|
||||||
|
|
||||||
const storedCloseProtection = localStorage.getItem(
|
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
|
||||||
)
|
|
||||||
// Default to true if not set
|
|
||||||
setCloseProtection(storedCloseProtection !== "false")
|
|
||||||
|
|
||||||
const storedSendShortcut = localStorage.getItem(
|
const storedSendShortcut = localStorage.getItem(
|
||||||
STORAGE_KEYS.sendShortcut,
|
STORAGE_KEYS.sendShortcut,
|
||||||
)
|
)
|
||||||
@@ -394,25 +384,6 @@ function SettingsContent({
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* Close Protection */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.closeProtection}
|
|
||||||
description={dict.settings.closeProtectionDescription}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
id="close-protection"
|
|
||||||
checked={closeProtection}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setCloseProtection(checked)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_CLOSE_PROTECTION_KEY,
|
|
||||||
checked.toString(),
|
|
||||||
)
|
|
||||||
onCloseProtectionChange?.(checked)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Diagram Style */}
|
{/* Diagram Style */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.diagramStyle}
|
label={dict.settings.diagramStyle}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef, EventAutoSave } from "react-drawio"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
@@ -23,6 +23,7 @@ interface DiagramContextType {
|
|||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
resolverRef: React.Ref<((value: string) => void) | null>
|
||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
||||||
handleDiagramExport: (data: any) => void
|
handleDiagramExport: (data: any) => void
|
||||||
|
handleAutoSave: (data: EventAutoSave) => void
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -226,6 +227,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle autosave events from draw.io - keeps chartXML in sync with user modifications
|
||||||
|
const handleAutoSave = (data: EventAutoSave) => {
|
||||||
|
if (data.xml) {
|
||||||
|
setChartXML(data.xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
@@ -350,6 +358,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resolverRef,
|
resolverRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
|
handleAutoSave,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
|
|||||||
@@ -62,6 +62,32 @@ const ANTHROPIC_BETA_HEADERS = {
|
|||||||
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve baseURL based on whether user is providing their own API key.
|
||||||
|
* When user provides their own API key, we should NOT fall back to server's
|
||||||
|
* baseURL environment variable - user credentials should only be sent to
|
||||||
|
* user-specified endpoints or official provider endpoints.
|
||||||
|
*
|
||||||
|
* @param userApiKey - User-provided API key (if any)
|
||||||
|
* @param userBaseUrl - User-provided base URL (if any)
|
||||||
|
* @param serverBaseUrl - Server's base URL from environment variable
|
||||||
|
* @param defaultBaseUrl - Provider's official/default base URL (optional)
|
||||||
|
* @returns The resolved base URL to use
|
||||||
|
*/
|
||||||
|
export function resolveBaseURL(
|
||||||
|
userApiKey: string | null | undefined,
|
||||||
|
userBaseUrl: string | null | undefined,
|
||||||
|
serverBaseUrl: string | undefined,
|
||||||
|
defaultBaseUrl?: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (userApiKey) {
|
||||||
|
// User provides their own API key - only use user's baseUrl or default
|
||||||
|
return userBaseUrl || defaultBaseUrl || undefined
|
||||||
|
}
|
||||||
|
// No user API key - fall back to server config
|
||||||
|
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse integer from environment variable with validation
|
* Safely parse integer from environment variable with validation
|
||||||
*/
|
*/
|
||||||
@@ -595,7 +621,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "openai": {
|
case "openai": {
|
||||||
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
||||||
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
process.env.OPENAI_BASE_URL,
|
||||||
|
)
|
||||||
if (baseURL) {
|
if (baseURL) {
|
||||||
// Custom base URL = third-party proxy, use Chat Completions API
|
// Custom base URL = third-party proxy, use Chat Completions API
|
||||||
// for compatibility (most proxies don't support /responses endpoint)
|
// for compatibility (most proxies don't support /responses endpoint)
|
||||||
@@ -614,10 +644,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "anthropic": {
|
case "anthropic": {
|
||||||
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
|
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
|
||||||
const baseURL =
|
const baseURL = resolveBaseURL(
|
||||||
overrides?.baseUrl ||
|
overrides?.apiKey,
|
||||||
process.env.ANTHROPIC_BASE_URL ||
|
overrides?.baseUrl,
|
||||||
"https://api.anthropic.com/v1"
|
process.env.ANTHROPIC_BASE_URL,
|
||||||
|
"https://api.anthropic.com/v1",
|
||||||
|
)
|
||||||
const customProvider = createAnthropic({
|
const customProvider = createAnthropic({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -632,7 +664,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
case "google": {
|
case "google": {
|
||||||
const apiKey =
|
const apiKey =
|
||||||
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
||||||
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
process.env.GOOGLE_BASE_URL,
|
||||||
|
)
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
const customGoogle = createGoogleGenerativeAI({
|
const customGoogle = createGoogleGenerativeAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -647,8 +683,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
|
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
|
||||||
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
|
const baseURL = resolveBaseURL(
|
||||||
const resourceName = process.env.AZURE_RESOURCE_NAME
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
process.env.AZURE_BASE_URL,
|
||||||
|
)
|
||||||
|
// Only use server's resourceName if user is NOT providing their own API key
|
||||||
|
const resourceName = overrides?.apiKey
|
||||||
|
? undefined
|
||||||
|
: process.env.AZURE_RESOURCE_NAME
|
||||||
// Azure requires either baseURL or resourceName to construct the endpoint
|
// Azure requires either baseURL or resourceName to construct the endpoint
|
||||||
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
||||||
if (baseURL || resourceName || overrides?.apiKey) {
|
if (baseURL || resourceName || overrides?.apiKey) {
|
||||||
@@ -678,8 +721,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "openrouter": {
|
case "openrouter": {
|
||||||
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
|
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
|
||||||
const baseURL =
|
const baseURL = resolveBaseURL(
|
||||||
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
process.env.OPENROUTER_BASE_URL,
|
||||||
|
)
|
||||||
const openrouter = createOpenRouter({
|
const openrouter = createOpenRouter({
|
||||||
apiKey,
|
apiKey,
|
||||||
...(baseURL && { baseURL }),
|
...(baseURL && { baseURL }),
|
||||||
@@ -690,7 +736,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "deepseek": {
|
case "deepseek": {
|
||||||
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
|
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
|
||||||
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
process.env.DEEPSEEK_BASE_URL,
|
||||||
|
)
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
const customDeepSeek = createDeepSeek({
|
const customDeepSeek = createDeepSeek({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -705,10 +755,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "siliconflow": {
|
case "siliconflow": {
|
||||||
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
|
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
|
||||||
const baseURL =
|
const baseURL = resolveBaseURL(
|
||||||
overrides?.baseUrl ||
|
overrides?.apiKey,
|
||||||
process.env.SILICONFLOW_BASE_URL ||
|
overrides?.baseUrl,
|
||||||
"https://api.siliconflow.cn/v1"
|
process.env.SILICONFLOW_BASE_URL,
|
||||||
|
"https://api.siliconflow.cn/v1",
|
||||||
|
)
|
||||||
const siliconflowProvider = createOpenAI({
|
const siliconflowProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -719,11 +771,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "sglang": {
|
case "sglang": {
|
||||||
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
|
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
|
||||||
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
process.env.SGLANG_BASE_URL,
|
||||||
|
)
|
||||||
|
|
||||||
const sglangProvider = createOpenAI({
|
const sglangProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
...(baseURL && { baseURL }),
|
||||||
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
||||||
fetch: async (url, options) => {
|
fetch: async (url, options) => {
|
||||||
const response = await fetch(url, options)
|
const response = await fetch(url, options)
|
||||||
@@ -828,8 +884,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
// See: https://vercel.com/ai-gateway
|
// See: https://vercel.com/ai-gateway
|
||||||
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
|
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
|
||||||
const baseURL =
|
const baseURL = resolveBaseURL(
|
||||||
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
process.env.AI_GATEWAY_BASE_URL,
|
||||||
|
)
|
||||||
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
||||||
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
@@ -861,10 +920,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
|
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
|
||||||
const baseURL =
|
const baseURL = resolveBaseURL(
|
||||||
overrides?.baseUrl ||
|
overrides?.apiKey,
|
||||||
process.env.DOUBAO_BASE_URL ||
|
overrides?.baseUrl,
|
||||||
"https://ark.cn-beijing.volces.com/api/v3"
|
process.env.DOUBAO_BASE_URL,
|
||||||
|
"https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
)
|
||||||
const lowerModelId = modelId.toLowerCase()
|
const lowerModelId = modelId.toLowerCase()
|
||||||
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
|
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
|
||||||
if (
|
if (
|
||||||
@@ -888,10 +949,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "modelscope": {
|
case "modelscope": {
|
||||||
const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY
|
const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY
|
||||||
const baseURL =
|
const baseURL = resolveBaseURL(
|
||||||
overrides?.baseUrl ||
|
overrides?.apiKey,
|
||||||
process.env.MODELSCOPE_BASE_URL ||
|
overrides?.baseUrl,
|
||||||
"https://api-inference.modelscope.cn/v1"
|
process.env.MODELSCOPE_BASE_URL,
|
||||||
|
"https://api-inference.modelscope.cn/v1",
|
||||||
|
)
|
||||||
const modelscopeProvider = createOpenAI({
|
const modelscopeProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
|
|||||||
@@ -100,8 +100,6 @@
|
|||||||
"switchTo": "Switch to",
|
"switchTo": "Switch to",
|
||||||
"minimal": "Minimal",
|
"minimal": "Minimal",
|
||||||
"sketch": "Sketch",
|
"sketch": "Sketch",
|
||||||
"closeProtection": "Close Protection",
|
|
||||||
"closeProtectionDescription": "Show confirmation when leaving the page.",
|
|
||||||
"diagramStyle": "Diagram Style",
|
"diagramStyle": "Diagram Style",
|
||||||
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
||||||
"sendShortcut": "Send Shortcut",
|
"sendShortcut": "Send Shortcut",
|
||||||
|
|||||||
@@ -100,8 +100,6 @@
|
|||||||
"switchTo": "切り替え",
|
"switchTo": "切り替え",
|
||||||
"minimal": "ミニマル",
|
"minimal": "ミニマル",
|
||||||
"sketch": "スケッチ",
|
"sketch": "スケッチ",
|
||||||
"closeProtection": "ページ離脱確認",
|
|
||||||
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
|
|
||||||
"diagramStyle": "ダイアグラムスタイル",
|
"diagramStyle": "ダイアグラムスタイル",
|
||||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||||
"sendShortcut": "送信ショートカット",
|
"sendShortcut": "送信ショートカット",
|
||||||
|
|||||||
@@ -100,8 +100,6 @@
|
|||||||
"switchTo": "切换到",
|
"switchTo": "切换到",
|
||||||
"minimal": "简约",
|
"minimal": "简约",
|
||||||
"sketch": "草图",
|
"sketch": "草图",
|
||||||
"closeProtection": "关闭确认",
|
|
||||||
"closeProtectionDescription": "离开页面时显示确认。",
|
|
||||||
"diagramStyle": "图表样式",
|
"diagramStyle": "图表样式",
|
||||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||||
"sendShortcut": "发送快捷键",
|
"sendShortcut": "发送快捷键",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export const STORAGE_KEYS = {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
accessCode: "next-ai-draw-io-access-code",
|
accessCode: "next-ai-draw-io-access-code",
|
||||||
closeProtection: "next-ai-draw-io-close-protection",
|
|
||||||
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
||||||
aiProvider: "next-ai-draw-io-ai-provider",
|
aiProvider: "next-ai-draw-io-ai-provider",
|
||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
|
|||||||
@@ -1,5 +1,141 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers"
|
import {
|
||||||
|
resolveBaseURL,
|
||||||
|
supportsImageInput,
|
||||||
|
supportsPromptCaching,
|
||||||
|
} from "@/lib/ai-providers"
|
||||||
|
|
||||||
|
describe("resolveBaseURL", () => {
|
||||||
|
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
||||||
|
const USER_BASE_URL = "https://user-proxy.example.com"
|
||||||
|
const DEFAULT_BASE_URL = "https://api.provider.com/v1"
|
||||||
|
const USER_API_KEY = "user-api-key-123"
|
||||||
|
|
||||||
|
describe("when user provides their own API key", () => {
|
||||||
|
it("uses user's baseUrl when provided", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
USER_API_KEY,
|
||||||
|
USER_BASE_URL,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
expect(result).toBe(USER_BASE_URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses default baseUrl when user provides no baseUrl", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
USER_API_KEY,
|
||||||
|
null,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
expect(result).toBe(DEFAULT_BASE_URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined when user provides no baseUrl and no default exists", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
USER_API_KEY,
|
||||||
|
null,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does NOT use server's baseUrl even when available", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
USER_API_KEY,
|
||||||
|
undefined,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
// Should NOT return SERVER_BASE_URL
|
||||||
|
expect(result).not.toBe(SERVER_BASE_URL)
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers user's baseUrl over default", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
USER_API_KEY,
|
||||||
|
USER_BASE_URL,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
expect(result).toBe(USER_BASE_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when using server credentials (no user API key)", () => {
|
||||||
|
it("uses user's baseUrl when provided (overrides server)", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
null,
|
||||||
|
USER_BASE_URL,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
expect(result).toBe(USER_BASE_URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to server's baseUrl when no user baseUrl", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
expect(result).toBe(SERVER_BASE_URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to default when no user or server baseUrl", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
expect(result).toBe(DEFAULT_BASE_URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined when no baseUrl available anywhere", () => {
|
||||||
|
const result = resolveBaseURL(null, null, undefined, undefined)
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles undefined apiKey same as null", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
expect(result).toBe(SERVER_BASE_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("handles empty string apiKey as falsy (uses server config)", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
// Empty string is falsy, so should use server config
|
||||||
|
expect(result).toBe(SERVER_BASE_URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles empty string baseUrl as falsy", () => {
|
||||||
|
const result = resolveBaseURL(
|
||||||
|
USER_API_KEY,
|
||||||
|
"",
|
||||||
|
SERVER_BASE_URL,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
)
|
||||||
|
// Empty string baseUrl is falsy, should fall back to default
|
||||||
|
expect(result).toBe(DEFAULT_BASE_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("supportsPromptCaching", () => {
|
describe("supportsPromptCaching", () => {
|
||||||
it("returns true for Claude models", () => {
|
it("returns true for Claude models", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user