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:
dayuan.jiang
2026-10-05 18:57:02 +09:00
parent c75f74a5a0
commit c0fa997186
67 changed files with 3152 additions and 531 deletions
+1 -6
View File
@@ -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
View File
@@ -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
}
+4 -1
View File
@@ -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) },
+2
View File
@@ -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.",
+2
View File
@@ -187,6 +187,8 @@
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける",
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
+2
View File
@@ -187,6 +187,8 @@
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續",
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
+2
View File
@@ -187,6 +187,8 @@
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
"continueWithoutSaving": "不保存,继续",
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
// ============================================================================