fix(*): 客户端标识 - 兼容 HTTP 内网访问并修复创造页卡死

Fixes #86
This commit is contained in:
ddCat
2026-08-03 15:06:09 +08:00
parent 72420895b2
commit d3b044e009
9 changed files with 35 additions and 9 deletions

View File

@@ -2,6 +2,7 @@
## Unreleased
- 修复 HTTP 内网 IP 访问时 `crypto.randomUUID` 不可用导致创造页卡死,并统一画布、Agent 与生成请求的客户端 ID 生成方式。
- 画布绘图节点支持 tldraw 与 Excalidraw 双引擎,管理员可配置新建默认工具,并保持历史绘图和导入导出兼容。
## v1.0.32

View File

@@ -6,6 +6,7 @@ import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, MessageSquare
import { motion } from "motion/react";
import { canvasThemes } from "@/lib/canvas-theme";
import { createClientId } from "@/lib/client-id";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/canvas/use-canvas-agent-store";
@@ -57,7 +58,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
const connectedRef = useRef(false);
const errorLoggedRef = useRef(false);
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID());
const clientIdRef = useRef(createClientId());
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
const loadThreads = useCallback(async () => {
@@ -1017,7 +1018,7 @@ function formatThreadTime(value?: number) {
}
function createId() {
return typeof crypto === "undefined" ? `${Date.now()}-${Math.random()}` : crypto.randomUUID();
return createClientId();
}
function clamp(value: number, min: number, max: number) {

View File

@@ -2,6 +2,8 @@ import { convertToExcalidrawElements } from "@excalidraw/excalidraw";
import type { FileId } from "@excalidraw/excalidraw/element/types";
import type { BinaryFileData, BinaryFiles, DataURL } from "@excalidraw/excalidraw/types";
import { createClientId } from "@/lib/client-id";
const INITIAL_DRAWING_SHAPE_MAX_DIMENSION = 1200;
type DrawingImageSource = {
@@ -14,7 +16,7 @@ type DrawingImageSource = {
export function createExcalidrawDrawingFromImage(source: DrawingImageSource) {
const now = Date.now();
const fileId = crypto.randomUUID() as FileId;
const fileId = createClientId() as FileId;
const scale = Math.min(1, INITIAL_DRAWING_SHAPE_MAX_DIMENSION / Math.max(source.width, source.height));
const width = Math.max(1, Math.round(source.width * scale));
const height = Math.max(1, Math.round(source.height * scale));

6
web/src/lib/client-id.ts Normal file
View File

@@ -0,0 +1,6 @@
import { nanoid } from "nanoid";
export function createClientId() {
// randomUUID 在 HTTP 内网地址下不可用;nanoid 依赖仍可用的 getRandomValues。
return nanoid();
}

View File

@@ -8,6 +8,7 @@ import { Link } from "react-router";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { ModelPicker } from "@/components/model-picker";
import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
import { createClientId } from "@/lib/client-id";
import { generationErrorMessage } from "@/lib/generation-error";
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { parseBackendGenerationResult, runBackendGenerationTask, runBackendGenerationTaskBatch } from "@/services/api/generation-task";
@@ -60,11 +61,11 @@ const countOptions = ["1", "2", "3", "4"];
const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
function newConversation(): CreationConversation {
return { id: crypto.randomUUID(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
}
function newMessage(role: CreationMessage["role"], content: string, extra: Partial<CreationMessage> = {}): CreationMessage {
return { id: crypto.randomUUID(), role, content, createdAt: new Date().toISOString(), ...extra };
return { id: createClientId(), role, content, createdAt: new Date().toISOString(), ...extra };
}
export default function CreatePage() {
@@ -195,7 +196,7 @@ export default function CreatePage() {
if (!next.length) return;
void Promise.all(next.slice(0, 6).map(async (file) => {
const dataUrl = await readFileAsDataUrl(file);
return { id: crypto.randomUUID(), name: file.name, type: file.type, dataUrl, previewUrl: dataUrl } satisfies CreationAttachment;
return { id: createClientId(), name: file.name, type: file.type, dataUrl, previewUrl: dataUrl } satisfies CreationAttachment;
})).then((items) => setAttachments((current) => [...current, ...items].slice(0, 6))).catch(() => toast.error("参考图读取失败,请重试"));
};

View File

@@ -1,6 +1,7 @@
import axios from "axios";
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { createClientId } from "@/lib/client-id";
import { channelRequest } from "@/services/api/custom-channel-relay";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { buildApiUrl, isSystemProxyBaseUrl, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
@@ -15,7 +16,7 @@ function aiHeaders(config: AiConfig) {
return {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
...(isSystemProxyBaseUrl(config.baseUrl) ? { "X-Canvas-Scene": "audio", "X-Idempotency-Key": crypto.randomUUID() } : {}),
...(isSystemProxyBaseUrl(config.baseUrl) ? { "X-Canvas-Scene": "audio", "X-Idempotency-Key": createClientId() } : {}),
};
}

View File

@@ -4,6 +4,7 @@ import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRe
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
import { createClientId } from "@/lib/client-id";
import { channelRequest } from "@/services/api/custom-channel-relay";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
@@ -262,7 +263,7 @@ function aiHeaders(config: AiConfig, contentType?: string) {
return {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
...(isSystemProxyBaseUrl(config.baseUrl) ? { "X-Canvas-Scene": "image", "X-Idempotency-Key": crypto.randomUUID() } : {}),
...(isSystemProxyBaseUrl(config.baseUrl) ? { "X-Canvas-Scene": "image", "X-Idempotency-Key": createClientId() } : {}),
};
}

View File

@@ -1,5 +1,6 @@
import axios from "axios";
import { createClientId } from "@/lib/client-id";
import { dataUrlToFile } from "@/lib/image-utils";
import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { getResourceOSSUrl } from "@/services/api/resources";
@@ -37,7 +38,7 @@ function aiHeaders(config: AiConfig, contentType?: string) {
return {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
...(isSystemProxyBaseUrl(config.baseUrl) ? { "X-Canvas-Scene": "video", "X-Idempotency-Key": crypto.randomUUID() } : {}),
...(isSystemProxyBaseUrl(config.baseUrl) ? { "X-Canvas-Scene": "video", "X-Idempotency-Key": createClientId() } : {}),
};
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test";
import { createClientId } from "../src/lib/client-id";
describe("createClientId", () => {
test("生成不依赖 randomUUID 的唯一客户端标识", () => {
const ids = Array.from({ length: 100 }, () => createClientId());
expect(new Set(ids).size).toBe(ids.length);
ids.forEach((id) => expect(id).toMatch(/^[A-Za-z0-9_-]{21}$/));
});
});