mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 12:09:53 +08:00
fix: older defects (batch C) and the second batch's review
Chats: - New Chat right after an answer saves that chat once. Saves run one at a time and read the chat on screen when their turn comes; a save scheduled for a chat that is no longer on screen is dropped. A chat whose id was still on its way to the URL no longer comes back after New Chat (the next answer went into it). - Crossing the 768 px breakpoint keeps the chat panel: a streaming answer, unsaved messages and attachments stay. The panel gets the sizes of each side, and a panel collapsed on desktop opens on mobile. - The chat's export waits for its own reply: an edit's history export still on its way no longer answers it with the older diagram, and two file saves at once no longer swap results. - A second edit in one answer is previewed on the first edit's result. - Stop also ends a running screenshot check; a chat that cannot be saved (storage full) can be left with "Continue without saving". - Small diagrams with shapes count as diagrams; the tool card no longer crashes on malformed operations. Quota and providers: - Requests that reach the server's own endpoints count toward the quota: EdgeOne (always its own endpoint now), a private base URL whatever key header is sent, keyless Ollama without a URL. With the quota on, a redirect is followed only to a public address. The output cap applies to these requests too. - Stop records the tokens of the steps that finished; the screenshot check counts its tokens without counting a request. - EdgeOne configured only by AI_PROVIDER works, also in the admin Test, which forwards the access code. Azure set up only in the admin panel works in chat. The Test sends a Bedrock session token. - The admin panel's Test of an entry without a URL uses the server's URL as the server does (no private address check for it); the admin panel no longer writes an Ollama URL. MCP server: - Write tools and start_session run one at a time, so two at once never drop each other's change; a cancelled call waiting its turn is skipped. get_diagram and export_diagram keep the session they started with. - Export to .drawio first gets the user's latest edits from the browser. - History thumbnails: one that arrives after the next AI write is dropped; a sync reply keeps the image; a version that changed only page settings is its own entry. - A diagram over the 10 MB limit is saved without its image, or the user is told to download it (the server now answers 413 instead of cutting the connection). - Labels holding text like id='1' or parent='1' are no longer read as attributes (a layer or a parent was deleted). A broken bare <mxGraphModel> file is refused. - After a sync reply the tab no longer sends its autosave copy again. Desktop and files: - A newer switch of the same preset is not rolled back by an older one that failed. .env values with escaped quotes are read whole. - MCP saved files: a file that could not be read stays protected while a folder without permission hides it, and is saved again once deleted. - The desktop app reports "no chats" only when the count was read and no model settings are stored.
This commit is contained in:
@@ -257,12 +257,7 @@ export function deriveEnvUpdates(
|
||||
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
|
||||
} else if (p.provider === "ollama") {
|
||||
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
|
||||
// A key without a URL is an Ollama Cloud key, as its Test sends
|
||||
// it; chat sends a server key to OLLAMA_BASE_URL or local Ollama
|
||||
if (p.baseUrl || p.apiKey) {
|
||||
updates.OLLAMA_BASE_URL =
|
||||
p.baseUrl || PROVIDER_INFO.ollama.defaultBaseUrl || null
|
||||
}
|
||||
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
|
||||
|
||||
+37
-12
@@ -21,6 +21,7 @@ import {
|
||||
adminProvidersToConfig,
|
||||
loadAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { redirectGuardedFetch } from "@/lib/ssrf-protection"
|
||||
import {
|
||||
normalizeBaseUrl,
|
||||
@@ -100,6 +101,9 @@ export interface ClientOverrides {
|
||||
awsSessionToken?: string | null
|
||||
// Vertex AI config
|
||||
vertexApiKey?: string | null // Express Mode API key
|
||||
// baseUrl is the server's own <P>_BASE_URL (the admin panel's Test),
|
||||
// not one a user chose: no redirect guard
|
||||
trustedBaseUrl?: boolean
|
||||
// Custom headers (e.g., for EdgeOne cookie auth)
|
||||
headers?: Record<string, string>
|
||||
// Custom env var name(s) for server models
|
||||
@@ -569,6 +573,7 @@ function detectProvider(): ProviderName | null {
|
||||
function validateProviderCredentials(
|
||||
provider: ProviderName,
|
||||
customApiKeyEnv?: string | string[],
|
||||
customBaseUrlEnv?: string,
|
||||
): void {
|
||||
// Handle array of env var names - at least one must be set
|
||||
if (Array.isArray(customApiKeyEnv)) {
|
||||
@@ -604,9 +609,12 @@ function validateProviderCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
// 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, or a server model's own URL variable (an admin panel entry)
|
||||
if (provider === "azure") {
|
||||
const hasBaseUrl = !!process.env.AZURE_BASE_URL
|
||||
const hasBaseUrl =
|
||||
!!process.env.AZURE_BASE_URL ||
|
||||
!!(customBaseUrlEnv && process.env[customBaseUrlEnv])
|
||||
const hasResourceName = !!process.env.AZURE_RESOURCE_NAME
|
||||
if (!hasBaseUrl && !hasResourceName) {
|
||||
throw new Error(
|
||||
@@ -879,13 +887,20 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
// Only validate server credentials if client isn't providing their own API key
|
||||
if (!isClientOverride) {
|
||||
validateProviderCredentials(provider, overrides?.apiKeyEnv)
|
||||
validateProviderCredentials(
|
||||
provider,
|
||||
overrides?.apiKeyEnv,
|
||||
overrides?.baseUrlEnv,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
||||
|
||||
// Requests to a base URL the client chose must not follow redirects
|
||||
const guardedFetch = overrides?.baseUrl ? redirectGuardedFetch() : undefined
|
||||
const guardedFetch =
|
||||
overrides?.baseUrl && !overrides.trustedBaseUrl
|
||||
? redirectGuardedFetch()
|
||||
: undefined
|
||||
// Build provider-specific options from environment variables
|
||||
let providerOptions = buildProviderOptions(provider, modelId)
|
||||
let model: LanguageModel
|
||||
@@ -1091,20 +1106,30 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
||||
return { model, providerOptions, modelId, provider }
|
||||
}
|
||||
|
||||
/**
|
||||
* The deployment's EdgeOne Pages function, as an absolute URL (the SDK
|
||||
* needs one), under the deployment's base path
|
||||
*/
|
||||
export function edgeOneEndpoint(req: Request): string {
|
||||
const origin = req.headers.get("origin") || new URL(req.url).origin
|
||||
return `${origin}${getApiEndpoint("/api/edgeai")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's <P>_BASE_URL for a provider, which getAIModel uses for a
|
||||
* server model without a URL variable of its own (an admin panel entry
|
||||
* without a URL). Bedrock, EdgeOne and Ollama (the panel writes
|
||||
* OLLAMA_BASE_URL itself) have none.
|
||||
* without a URL). None for Bedrock and EdgeOne, and none for Ollama and
|
||||
* Vertex AI, whose variables the panel writes itself (before a save they
|
||||
* still hold the entry's previous URL).
|
||||
*/
|
||||
export function globalBaseUrl(provider: ProviderName): string | undefined {
|
||||
if (["bedrock", "edgeone", "ollama"].includes(provider)) return undefined
|
||||
if (["bedrock", "edgeone", "ollama", "vertexai"].includes(provider)) {
|
||||
return undefined
|
||||
}
|
||||
const name =
|
||||
provider === "vertexai"
|
||||
? "GOOGLE_VERTEX_BASE_URL"
|
||||
: provider === "gateway"
|
||||
? "AI_GATEWAY_BASE_URL"
|
||||
: `${provider.toUpperCase()}_BASE_URL`
|
||||
provider === "gateway"
|
||||
? "AI_GATEWAY_BASE_URL"
|
||||
: `${provider.toUpperCase()}_BASE_URL`
|
||||
return process.env[name] || undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -64,10 +64,13 @@ interface QuotaCheckResult {
|
||||
* Check all quotas and increment request count atomically.
|
||||
* Uses composite key (PK=user, SK=date) for per-day tracking.
|
||||
* Each day automatically gets a new item - no explicit reset needed.
|
||||
* A request limit of 0 means none; increment 0 checks the limits without
|
||||
* counting a request (the screenshot check).
|
||||
*/
|
||||
export async function checkAndIncrementRequest(
|
||||
ip: string,
|
||||
limits: QuotaLimits,
|
||||
increment = 1,
|
||||
): Promise<QuotaCheckResult> {
|
||||
// Skip if quota tracking not enabled
|
||||
if (!client || !TABLE) {
|
||||
@@ -99,7 +102,7 @@ export async function checkAndIncrementRequest(
|
||||
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
||||
`,
|
||||
ExpressionAttributeValues: {
|
||||
":one": { N: "1" },
|
||||
":one": { N: String(increment) },
|
||||
":minute": { S: currentMinute },
|
||||
":reqLimit": { N: String(limits.requests || 999999) },
|
||||
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
|
||||
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
|
||||
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
|
||||
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
|
||||
"continueWithoutSaving": "Continue without saving",
|
||||
"llm": {
|
||||
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
|
||||
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
|
||||
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
|
||||
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
|
||||
"continueWithoutSaving": "保存せずに続ける",
|
||||
"llm": {
|
||||
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
|
||||
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
|
||||
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
|
||||
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
|
||||
"continueWithoutSaving": "不儲存,繼續",
|
||||
"llm": {
|
||||
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
|
||||
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
|
||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
|
||||
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
|
||||
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
|
||||
"continueWithoutSaving": "不保存,继续",
|
||||
"llm": {
|
||||
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
|
||||
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
|
||||
|
||||
@@ -199,13 +199,18 @@ export async function deleteSession(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getSessionCount(): Promise<number> {
|
||||
if (!isIndexedDBAvailable()) return 0
|
||||
return (await readSessionCount()) ?? 0
|
||||
}
|
||||
|
||||
/** The number of saved chats, or null when it could not be read */
|
||||
export async function readSessionCount(): Promise<number | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
const db = await getDB()
|
||||
return await db.count(STORE_NAME)
|
||||
} catch (error) {
|
||||
console.error("Failed to get session count:", error)
|
||||
return 0
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-8
@@ -118,24 +118,52 @@ export function allowPrivateUrls(): boolean {
|
||||
|
||||
/** A redirect the guard below refused; its text is safe to show */
|
||||
export class RedirectRefusedError extends Error {
|
||||
constructor() {
|
||||
super("Redirects are not allowed for custom base URLs")
|
||||
constructor(message = "Redirects are not allowed for custom base URLs") {
|
||||
super(message)
|
||||
this.name = "RedirectRefusedError"
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_REDIRECTS = 5
|
||||
|
||||
/**
|
||||
* A fetch for requests to a base URL the client chose. With private URLs
|
||||
* blocked, a public URL could still redirect the request to an internal
|
||||
* host, so redirects are refused. Undefined when private URLs are allowed.
|
||||
* host, so redirects are refused. With private URLs allowed but the quota
|
||||
* on (DYNAMODB_QUOTA_TABLE), a request to a private address counts as the
|
||||
* server's: redirects are followed only to public addresses, or a public
|
||||
* URL could reach the server's own network uncounted. Undefined otherwise.
|
||||
*/
|
||||
export function redirectGuardedFetch(): typeof fetch | undefined {
|
||||
if (allowPrivateUrls()) return undefined
|
||||
const blockAll = !allowPrivateUrls()
|
||||
if (!blockAll && !process.env.DYNAMODB_QUOTA_TABLE) return undefined
|
||||
return async (input, init) => {
|
||||
const response = await fetch(input, { ...init, redirect: "manual" })
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new RedirectRefusedError()
|
||||
let url = input instanceof Request ? input.url : String(input)
|
||||
let next = init
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const response = await fetch(url, { ...next, redirect: "manual" })
|
||||
const location = response.headers.get("location")
|
||||
if (response.status < 300 || response.status >= 400 || !location) {
|
||||
return response
|
||||
}
|
||||
if (blockAll) throw new RedirectRefusedError()
|
||||
url = new URL(location, url).toString()
|
||||
if (await isPrivateUrl(url)) {
|
||||
throw new RedirectRefusedError(
|
||||
"Redirects to private addresses are not allowed",
|
||||
)
|
||||
}
|
||||
// As fetch itself does: 303, and 301 or 302 after a POST, go on
|
||||
// as a GET without the body
|
||||
const method = (next?.method ?? "GET").toUpperCase()
|
||||
if (
|
||||
response.status === 303 ||
|
||||
((response.status === 301 || response.status === 302) &&
|
||||
method === "POST")
|
||||
) {
|
||||
next = { ...next, method: "GET", body: undefined }
|
||||
}
|
||||
}
|
||||
return response
|
||||
throw new RedirectRefusedError("Too many redirects")
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import * as pako from "pako"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -17,12 +18,14 @@ export function cn(...inputs: ClassValue[]) {
|
||||
export const MIN_REAL_DIAGRAM_LENGTH = 300
|
||||
|
||||
/**
|
||||
* Check if diagram XML represents a real diagram (not just empty template).
|
||||
* Check if diagram XML represents a real diagram (not just empty template):
|
||||
* it has a shape (however short), or is long enough to hold pages worth
|
||||
* keeping.
|
||||
* @param xml - The diagram XML string to check
|
||||
* @returns true if the XML is a real diagram with content
|
||||
*/
|
||||
export function isRealDiagram(xml: string | undefined | null): boolean {
|
||||
return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH
|
||||
return !!xml && (hasCells(xml) || xml.length > MIN_REAL_DIAGRAM_LENGTH)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user