Files
ttstd-infinite-canvas/web/src/stores/use-config-store.ts
爱笑的毛毛虫 f8cb3d0201 fix(canvas): 分镜脚本节点文字溢出与视频模型下拉异常 #84 (#85)
* fix(canvas): 分镜脚本节点文字溢出与视频模型下拉异常 #84

- CompactInput 补齐 overflow-y-auto/whitespace-pre-wrap/break-words,长文本不再横向溢出撑开列宽
- filterModelsByCapability 增加 modelCosts[].protocol 协议层回退,未显式配置 capability 时通过协议识别能力
- isVideoModelName 扩展 pika/runway/gen-3/hunyuan-video 等主流视频模型名关键字
- ModelPicker.options 保留当前选中 value,避免过滤后 options 为空触发跳转设置页

* fix(canvas): 模型过滤协议反向排除 + 分镜脚本节点模型选择提示 #84

- filterModelsByCapability 协议层提升至最高优先级,协议明确属于其他能力时直接排除,
  防止 video/image/audio 协议的模型被误标为 text 后混入文本下拉
- isVideoModelName 补充 cogvideo/mochi/latte/stable-video/svd/animatediff/ltx-video/minimax-video/abab-video 等关键字
- isImageModelName 补充 nano-banana/ideogram/recraft/playground/leonardo 等关键字
- 分镜脚本节点 ModelPicker 包裹 Tooltip,提示"脚本生成仅支持文本模型"

* fix(canvas): 节点标题截断修复 — flex min-width 约束 + 宽度边界 + hover tooltip

- NodeExternalHeader: 容器增加 max-w-full 双重约束,窄节点时不越界,宽节点时上限 240px
- NodeExternalHeader: 标题 span 增加 min-w-0,解决 flex min-width:auto 导致 truncate 失效
- 所有节点标题(绘图/技能/角色参考/音频/分镜脚本)增加 title 属性,hover 显示完整标题
- AudioNodeContent: 标题 span 增加 min-w-0,修复 flex 子项截断

* fix(canvas): 节点顶部标题真正截断 — JS 计算 maxWidth + overflow-hidden + flex-1

- NodeExternalHeader 容器: 移除 Tailwind 级联冲突的 max-w-[240px]/max-w-full,
  改用 JS 计算 Math.min(240, node.width) 作为 inline style maxWidth,
  从根本上解决两个 max-width 声明的优先级不确定问题
- 容器增加 overflow-hidden 作为第二道防线
- 内部 text/input/button 元素改用 flex-1 + min-w-0 替代固定 max-w-[Npx],
  让 flex 子项自动填充可用空间,文本在剩余空间内截断

* fix(canvas): 节点标题缩放溢出 — 考虑 canvas zoom scale

- maxHeaderWidth 改为 Math.min(240, node.width * scale),
  因为 header 的视觉宽度 = maxHeaderWidth(inverseScale 与 canvas scale 抵消),
  而节点的视觉宽度 = node.width * scale,两者必须比较同一维度
- 修复 zoom 缩小时(scale < 1)header 视觉宽度超过节点视觉宽度的溢出问题

---------

Co-authored-by: sikongyue <3155157671@qq.com>
2026-08-03 08:40:04 +08:00

556 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from "react";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { nanoid } from "nanoid";
import { scopedLocalStorage } from "@/lib/user-scope";
import { modelProtocolCapability, normalizeModelProtocol, type ModelProtocol } from "@/lib/model-protocols";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
export type ApiCallFormat = "openai" | "gemini";
export type ChannelInterfaceType = ModelProtocol;
export type ChannelHeader = { name: string; value: string };
export type ModelChannel = {
id: string;
name: string;
baseUrl: string;
apiKey: string;
secretKey?: string;
headers?: ChannelHeader[];
apiFormat: ApiCallFormat;
interfaceType?: ChannelInterfaceType;
models: string[];
scope?: "system" | "user";
enabled?: boolean;
hasApiKey?: boolean;
hasSecretKey?: boolean;
concurrencyLimit?: number;
modelCosts?: Array<{
model: string;
displayName?: string;
capability: ModelCapability;
protocol?: ModelProtocol;
billingMode: "fixed_request" | "per_second";
unitPriceMicrocredits: number;
}>;
};
export type AiConfig = {
channelMode: "remote" | "local";
baseUrl: string;
apiKey: string;
apiFormat: ApiCallFormat;
channels: ModelChannel[];
model: string;
imageModel: string;
videoModel: string;
textModel: string;
audioModel: string;
audioVoice: string;
audioFormat: string;
audioSpeed: string;
audioInstructions: string;
videoSeconds: string;
vquality: string;
videoGenerateAudio: string;
videoWatermark: string;
systemPrompt: string;
models: string[];
imageModels: string[];
videoModels: string[];
textModels: string[];
audioModels: string[];
quality: string;
size: string;
transparentBackground: string;
count: string;
canvasImageCount: string;
};
export const CONFIG_STORE_KEY = "open_ai_canvas:ai_config_store";
export type ModelCapability = "image" | "video" | "text" | "audio";
const CHANNEL_MODEL_SEPARATOR = "::";
const OPENAI_BASE_URL = "https://api.openai.com";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
export const defaultConfig: AiConfig = {
channelMode: "local",
baseUrl: OPENAI_BASE_URL,
apiKey: "",
apiFormat: "openai",
channels: [
{
id: "default",
name: "默认渠道",
baseUrl: OPENAI_BASE_URL,
apiKey: "",
apiFormat: "openai",
models: ["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"],
},
],
model: "default::gpt-image-2",
imageModel: "default::gpt-image-2",
videoModel: "default::grok-imagine-video",
textModel: "default::gpt-5.5",
audioModel: "default::gpt-4o-mini-tts",
audioVoice: "alloy",
audioFormat: "mp3",
audioSpeed: "1",
audioInstructions: "",
videoSeconds: "6",
vquality: "720",
videoGenerateAudio: "true",
videoWatermark: "false",
systemPrompt: "",
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
imageModels: ["default::gpt-image-2"],
videoModels: ["default::grok-imagine-video"],
textModels: ["default::gpt-5.5"],
audioModels: ["default::gpt-4o-mini-tts"],
quality: "auto",
size: "1:1",
transparentBackground: "false",
count: "1",
canvasImageCount: "1",
};
type ConfigStore = {
config: AiConfig;
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
replaceConfig: (config: AiConfig) => void;
mergeSystemChannels: (channels: ModelChannel[]) => void;
isAiConfigReady: (config: AiConfig, model: string) => boolean;
};
export type ConfigStoreSnapshot = {
config?: Partial<AiConfig>;
};
function isVideoModelName(model: string) {
const value = modelOptionName(model).toLowerCase();
return value.includes("seedance")
|| value.includes("video")
|| value.includes("sora")
|| value.includes("veo")
|| value.includes("kling")
|| value.includes("wan")
|| value.includes("hailuo")
|| value.includes("pika")
|| value.includes("runway")
|| value.includes("gen-3")
|| value.includes("gen3")
|| value.includes("hunyuan-video")
|| value.includes("hunyuanvideo")
|| value.includes("cogvideo")
|| value.includes("mochi")
|| value.includes("latte")
|| value.includes("stable-video")
|| value.includes("svd")
|| value.includes("animatediff")
|| value.includes("ltx-video")
|| value.includes("ltxvideo")
|| value.includes("minimax-video")
|| value.includes("abab-video");
}
function isImageModelName(model: string) {
const value = modelOptionName(model).toLowerCase();
return (
!isVideoModelName(model) &&
!isAudioModelName(model) &&
(value.includes("seedream") ||
value.includes("gpt-image") ||
value.includes("image") ||
value.includes("dall-e") ||
value.includes("dalle") ||
value.includes("imagen") ||
value.includes("flux") ||
value.includes("sdxl") ||
value.includes("stable-diffusion") ||
value.includes("midjourney") ||
value.includes("nano-banana") ||
value.includes("nanobanana") ||
value.includes("ideogram") ||
value.includes("recraft") ||
value.includes("playground") ||
value.includes("leonardo"))
);
}
function isAudioModelName(model: string) {
const value = modelOptionName(model).toLowerCase();
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound");
}
function isTextModelName(model: string) {
return !isImageModelName(model) && !isVideoModelName(model) && !isAudioModelName(model);
}
export function modelMatchesCapability(model: string, capability?: ModelCapability) {
if (!capability) return true;
if (capability === "image") return isImageModelName(model);
if (capability === "video") return isVideoModelName(model);
if (capability === "audio") return isAudioModelName(model);
return isTextModelName(model);
}
export function filterModelsByCapability(models: string[], capability?: ModelCapability, channels?: ModelChannel[]) {
if (!capability) return models;
return models.filter((model) => {
const decoded = decodeChannelModel(model);
const channel = decoded ? channels?.find((item) => item.id === decoded.channelId) : undefined;
const modelName = decoded?.model || modelOptionName(model);
const costEntry = channel?.modelCosts?.find((item) => item.model === modelName);
// 协议层优先级最高:协议决定 API 端点,明确属于其他能力时直接排除,
// 防止用户将 video/image/audio 协议的模型误标为 text 后混入文本下拉。
const protocolCapability = modelProtocolCapability(costEntry?.protocol);
if (protocolCapability) return protocolCapability === capability;
// 渠道接口层:渠道级协议推断能力
const channelCapability = capabilityForChannelInterface(channel?.interfaceType);
if (channelCapability) return channelCapability === capability;
// 配置能力层:用户显式标记的 capability
const configuredCapability = costEntry?.capability;
if (configuredCapability) return configuredCapability === capability;
// 模型名启发式:最后回退
return modelMatchesCapability(model, capability);
});
}
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
if (!capability) return config.models;
return filterModelsByCapability(config.models, capability, config.channels);
}
export function configuredModelMatchesCapability(config: AiConfig, model: string, capability?: ModelCapability) {
const normalized = normalizeModelOptionValue(model, config.channels);
if (!normalized || !config.models.includes(normalized)) return false;
return capability ? selectableModelsByCapability(config, capability).includes(normalized) : true;
}
function isAiConfigReady(config: AiConfig, model: string) {
const channel = resolveModelChannel(config, model);
return Boolean(model.trim() && channel.baseUrl.trim() && channel.apiKey.trim());
}
export const useConfigStore = create<ConfigStore>()(
persist(
(set) => ({
config: defaultConfig,
updateConfig: (key, value) =>
set((state) => ({
config: {
...state.config,
[key]: value,
},
})),
replaceConfig: (config) => set({ config }),
mergeSystemChannels: (channels) =>
set((state) => {
const systemChannels = channels.map((channel, index) =>
createModelChannel({
...channel,
id: channel.id || `system-${index + 1}`,
name: channel.name || `系统渠道 ${index + 1}`,
scope: "system",
apiKey: channel.apiKey || "system",
}),
);
const userChannels = state.config.channels.filter((channel) => channel.scope !== "system");
return normalizeConfigSnapshot({ config: { ...state.config, channels: [...systemChannels, ...userChannels] } });
}),
isAiConfigReady: (config, model) => isAiConfigReady(config, model),
}),
{
name: CONFIG_STORE_KEY,
storage: createJSONStorage(() => scopedLocalStorage),
partialize: (state) => ({ config: state.config }),
merge: (persisted, current) => {
const persistedState = (persisted || {}) as Partial<ConfigStore>;
return {
...current,
...normalizeConfigSnapshot({ config: persistedState.config }),
};
},
},
),
);
export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot) {
const persistedConfig = (snapshot.config || {}) as Partial<AiConfig>;
const config = { ...defaultConfig, ...persistedConfig };
const hasPersistedChannels = Array.isArray(persistedConfig.channels);
if (!hasPersistedChannels) config.channels = [];
const channels = normalizeChannels(config, !hasPersistedChannels);
const models = modelOptionsFromChannels(channels);
const imageModels = filterModelsByCapability(models, "image", channels);
const videoModels = filterModelsByCapability(models, "video", channels);
const textModels = filterModelsByCapability(models, "text", channels);
const audioModels = filterModelsByCapability(models, "audio", channels);
const model = normalizeSelectedModel(config.model || config.imageModel || config.textModel, channels, models);
return {
config: {
...config,
channelMode: "local" as const,
apiFormat: normalizeApiFormat(config.apiFormat),
channels,
models,
model,
imageModel: normalizeSelectedModel(config.imageModel || model, channels, imageModels),
videoModel: normalizeSelectedModel(config.videoModel || "grok-imagine-video", channels, videoModels),
textModel: normalizeSelectedModel(config.textModel || model, channels, textModels),
audioModel: normalizeSelectedModel(config.audioModel || defaultConfig.audioModel, channels, audioModels),
audioVoice: config.audioVoice || defaultConfig.audioVoice,
audioFormat: config.audioFormat || defaultConfig.audioFormat,
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: config.audioInstructions || "",
videoSeconds: normalizeVideoDuration(config.videoSeconds),
vquality: normalizeVideoResolution(config.vquality),
videoGenerateAudio: config.videoGenerateAudio || "true",
videoWatermark: config.videoWatermark || "false",
transparentBackground: config.transparentBackground === "true" ? "true" : "false",
canvasImageCount: config.canvasImageCount || defaultConfig.canvasImageCount,
imageModels,
videoModels,
textModels,
audioModels,
},
};
}
function normalizeSelectedModel(value: string, channels: ModelChannel[], options: string[]) {
const model = normalizeModelOptionValue(value, channels);
return model && options.includes(model) ? model : options[0] || "";
}
export function useEffectiveConfig() {
const config = useConfigStore((state) => state.config);
return useMemo(() => ({ ...config, channelMode: "local" as const }), [config]);
}
export function createModelChannel(channel?: Partial<ModelChannel>): ModelChannel {
const apiFormat = normalizeApiFormat(channel?.apiFormat);
const interfaceType = normalizeChannelInterfaceType(channel?.interfaceType);
const providedBaseUrl = channel?.baseUrl?.trim();
return {
id: channel?.id?.trim() || nanoid(),
name: channel?.name?.trim() || "新渠道",
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
apiKey: channel?.apiKey || "",
secretKey: channel?.secretKey || "",
headers: Array.isArray(channel?.headers) ? channel.headers.map((header) => ({ name: String(header.name || ""), value: String(header.value || "") })) : [],
apiFormat,
interfaceType,
models: uniqueRawModels(channel?.models || []),
scope: channel?.scope === "system" ? "system" : "user",
enabled: channel?.enabled !== false,
hasApiKey: channel?.hasApiKey,
hasSecretKey: channel?.hasSecretKey,
modelCosts: channel?.modelCosts?.map((item) => ({ ...item, protocol: normalizeModelProtocol(item.protocol) })),
};
}
export function encodeChannelModel(channelId: string, model: string) {
return `${channelId}${CHANNEL_MODEL_SEPARATOR}${model.trim()}`;
}
export function isChannelModelValue(value: string) {
return value.includes(CHANNEL_MODEL_SEPARATOR);
}
export function decodeChannelModel(value: string) {
const index = value.indexOf(CHANNEL_MODEL_SEPARATOR);
if (index < 0) return null;
return { channelId: value.slice(0, index), model: value.slice(index + CHANNEL_MODEL_SEPARATOR.length) };
}
export function modelOptionName(value: string) {
return decodeChannelModel(value)?.model || value;
}
export function modelDisplayName(config: AiConfig, value: string) {
const model = modelOptionName(value);
const channel = resolveModelChannel(config, value);
const displayName = channel.modelCosts?.find((item) => item.model === model)?.displayName?.trim();
if (displayName) return displayName;
return channel.scope === "system" ? "系统模型" : model;
}
export function modelOptionLabel(config: AiConfig, value: string) {
const decoded = decodeChannelModel(value);
if (!decoded) return modelDisplayName(config, value);
const channel = config.channels.find((item) => item.id === decoded.channelId);
const displayName = modelDisplayName(config, value);
return channel ? `${displayName}(${channel.name})` : displayName;
}
export function modelOptionsFromChannels(channels: ModelChannel[]) {
return uniqueModelOptions(
channels.flatMap((channel) =>
channel.models
.map(normalizeRawModelName)
.filter(Boolean)
.filter((model) => channel.scope !== "system" || hasSystemModelPrice(channel, model))
.map((model) => encodeChannelModel(channel.id, model)),
),
);
}
export function hasSystemModelPrice(channel: ModelChannel, model: string) {
if (channel.scope !== "system") return true;
return channel.modelCosts?.some((item) => item.model === model && Number.isFinite(item.unitPriceMicrocredits) && item.unitPriceMicrocredits >= 0) === true;
}
export function normalizeModelOptionValue(value: unknown, channels: ModelChannel[]) {
const model = typeof value === "string" ? value.trim() : "";
if (!normalizeRawModelName(model)) return "";
const decoded = decodeChannelModel(model);
if (decoded) {
const channel = channels.find((item) => item.id === decoded.channelId);
return channel && channel.models.includes(decoded.model) ? model : "";
}
const channel = channels.find((item) => item.models.includes(model)) || channels[0];
return channel && channel.models.includes(model) ? encodeChannelModel(channel.id, model) : "";
}
export function resolveModelChannel(config: AiConfig, value: string) {
const decoded = decodeChannelModel(value);
const model = decoded?.model || value;
const matched = decoded ? config.channels.find((channel) => channel.id === decoded.channelId) : config.channels.find((channel) => channel.models.includes(model));
return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName) });
}
export function resolveModelRequestConfig(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value);
const model = modelOptionName(value || config.model);
const modelProtocol = channel.modelCosts?.find((item) => item.model === model)?.protocol;
const interfaceType = modelProtocol || channel.interfaceType;
return {
...config,
model,
baseUrl: channel.baseUrl,
apiKey: channel.apiKey,
secretKey: channel.secretKey,
headers: channel.headers,
apiFormat: interfaceType ? (interfaceType === "gemini-veo" ? "gemini" as const : "openai" as const) : channel.apiFormat,
interfaceType,
channelId: channel.scope === "system" ? channel.id : "",
};
}
function normalizeChannels(config: AiConfig, ensureDefault = true) {
const persistedChannels = Array.isArray(config.channels) ? config.channels : [];
const channels = persistedChannels
.map((channel, index) =>
createModelChannel({
...channel,
id: channel.id || (index === 0 ? "default" : `channel-${index + 1}`),
name: channel.name || (index === 0 ? "默认渠道" : `渠道 ${index + 1}`),
models: uniqueRawModels(channel.models || []),
}),
)
.filter((channel) => !isEmptyDefaultChannel(channel));
if (!channels.length && ensureDefault && config.apiKey.trim()) {
channels.push(
createModelChannel({
id: "default",
name: "默认渠道",
baseUrl: config.baseUrl || defaultConfig.baseUrl,
apiKey: config.apiKey || "",
apiFormat: config.apiFormat || defaultConfig.apiFormat,
models: uniqueRawModels([...(config.models || []), config.model, config.imageModel, config.videoModel, config.textModel, config.audioModel]),
}),
);
}
return channels.map((channel) => ({ ...channel, models: uniqueRawModels(channel.models) }));
}
function isEmptyDefaultChannel(channel: ModelChannel) {
if (channel.scope === "system") return false;
if (channel.id !== "default" || channel.name.trim() !== "默认渠道" || channel.apiKey.trim()) return false;
const baseUrl = channel.baseUrl.trim().replace(/\/+$/, "");
const defaultBaseUrl = defaultConfig.baseUrl.trim().replace(/\/+$/, "");
if (baseUrl && baseUrl !== defaultBaseUrl) return false;
const defaultModels = new Set((defaultConfig.channels[0]?.models || []).map(modelOptionName));
return !channel.models.length || channel.models.every((model) => defaultModels.has(modelOptionName(model)));
}
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? GEMINI_BASE_URL : OPENAI_BASE_URL;
}
export function defaultBaseUrlForChannelInterface(interfaceType?: ChannelInterfaceType) {
if (interfaceType === "gemini-veo") return GEMINI_BASE_URL;
if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-video") return "https://ark.cn-beijing.volces.com/api/v3";
if (interfaceType === "volcengine-jimeng-image" || interfaceType === "volcengine-jimeng-video") return "https://visual.volcengineapi.com";
if (interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || interfaceType === "xai-video") return "";
return OPENAI_BASE_URL;
}
function capabilityForChannelInterface(interfaceType?: ChannelInterfaceType): ModelCapability | undefined {
return modelProtocolCapability(interfaceType);
}
function normalizeApiFormat(apiFormat: unknown): ApiCallFormat {
return apiFormat === "gemini" ? "gemini" : "openai";
}
function normalizeChannelInterfaceType(value: unknown): ChannelInterfaceType | undefined {
return normalizeModelProtocol(value);
}
function uniqueRawModels(models: string[]) {
return Array.from(new Set((models || []).map(normalizeRawModelName).filter(Boolean)));
}
function uniqueModelOptions(models: string[]) {
return Array.from(new Set((models || []).filter((model): model is string => typeof model === "string").map((model) => model.trim()).filter(Boolean)));
}
function normalizeRawModelName(value: unknown) {
if (typeof value !== "string") return "";
const model = modelOptionName(value).trim();
return model && model !== "undefined" && model !== "null" ? model : "";
}
export function buildApiUrl(baseUrl: string, path: string) {
let normalizedBaseUrl = resolveBackendApiUrl(baseUrl).replace(/\/+$/, "");
normalizedBaseUrl = normalizeArkPlanBaseUrl(normalizedBaseUrl);
const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
const apiBaseUrl = isSystemProxyBaseUrl(normalizedBaseUrl) || lowerBaseUrl.endsWith("/v1") || lowerBaseUrl.endsWith("/api/v3") || lowerBaseUrl.endsWith("/api/plan/v3") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
return `${apiBaseUrl}${path}`;
}
export function resolveBackendApiUrl(value: string) {
const url = value.trim();
if (!url.startsWith("/api/")) return url;
const backendBaseUrl = String(import.meta.env.VITE_CANVAS_BACKEND_URL || "/api").trim().replace(/\/+$/, "");
return backendBaseUrl === "/api" ? url : `${backendBaseUrl}${url.slice("/api".length)}`;
}
export function isSystemProxyBaseUrl(baseUrl: string) {
const marker = "/api/ai/system/";
const index = baseUrl.toLowerCase().indexOf(marker);
if (index < 0) return false;
const channelId = baseUrl.slice(index + marker.length);
return Boolean(channelId && !channelId.includes("/") && !channelId.includes("?") && !channelId.includes("#"));
}
function normalizeArkPlanBaseUrl(baseUrl: string) {
try {
const url = new URL(baseUrl);
const path = url.pathname.replace(/\/+$/, "");
const lowerPath = path.toLowerCase();
const arkPlanIndex = lowerPath.indexOf("/api/plan/v3");
if (arkPlanIndex < 0) return baseUrl;
const end = arkPlanIndex + "/api/plan/v3".length;
if (lowerPath.length !== end && lowerPath[end] !== "/") return baseUrl;
url.pathname = path.slice(0, end);
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/, "");
} catch {
return baseUrl;
}
}