lx0xl commited on
Commit
86de0aa
·
verified ·
1 Parent(s): d8bb7ab

Upload 2 files

Browse files
Files changed (2) hide show
  1. app/store/config.ts +325 -0
  2. app/utils.ts +474 -0
app/store/config.ts ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LLMModel } from "../client/api";
2
+ import { DalleSize, DalleQuality, DalleStyle } from "../typing";
3
+ import { getClientConfig } from "../config/client";
4
+ import {
5
+ DEFAULT_INPUT_TEMPLATE,
6
+ DEFAULT_MODELS,
7
+ DEFAULT_SIDEBAR_WIDTH,
8
+ DEFAULT_TTS_ENGINE,
9
+ DEFAULT_TTS_ENGINES,
10
+ DEFAULT_TTS_MODEL,
11
+ DEFAULT_TTS_MODELS,
12
+ DEFAULT_TTS_VOICE,
13
+ DEFAULT_TTS_VOICES,
14
+ StoreKey,
15
+ ServiceProvider,
16
+ } from "../constant";
17
+ import { createPersistStore } from "../utils/store";
18
+ import type { Voice } from "rt-client";
19
+
20
+ export type ModelType = (typeof DEFAULT_MODELS)[number]["name"];
21
+ export type TTSModelType = (typeof DEFAULT_TTS_MODELS)[number];
22
+ export type TTSVoiceType = (typeof DEFAULT_TTS_VOICES)[number];
23
+ export type TTSEngineType = (typeof DEFAULT_TTS_ENGINES)[number];
24
+
25
+ export enum SubmitKey {
26
+ Enter = "Enter",
27
+ CtrlEnter = "Ctrl + Enter",
28
+ ShiftEnter = "Shift + Enter",
29
+ AltEnter = "Alt + Enter",
30
+ MetaEnter = "Meta + Enter",
31
+ }
32
+
33
+ export enum Theme {
34
+ Auto = "auto",
35
+ Dark = "dark",
36
+ Light = "light",
37
+ }
38
+
39
+ const config = getClientConfig();
40
+
41
+ export type ModelConfig = {
42
+ model: ModelType;
43
+ providerName: ServiceProvider;
44
+ temperature: number;
45
+ top_p: number;
46
+ max_tokens: number;
47
+ presence_penalty: number;
48
+ frequency_penalty: number;
49
+ sendMemory: boolean;
50
+ historyMessageCount: number;
51
+ compressMessageLengthThreshold: number;
52
+ compressModel: string;
53
+ compressProviderName: string;
54
+ enableInjectSystemPrompts: boolean;
55
+ template: string;
56
+ size: DalleSize;
57
+ quality: DalleQuality;
58
+ style: DalleStyle;
59
+ };
60
+
61
+ export type AppConfig = {
62
+ lastUpdate: number;
63
+ submitKey: SubmitKey;
64
+ avatar: string;
65
+ fontSize: number;
66
+ fontFamily: string;
67
+ theme: Theme;
68
+ tightBorder: boolean;
69
+ sendPreviewBubble: boolean;
70
+ enableAutoGenerateTitle: boolean;
71
+ sidebarWidth: number;
72
+ enableArtifacts: boolean;
73
+ enableCodeFold: boolean;
74
+ disablePromptHint: boolean;
75
+ dontShowMaskSplashScreen: boolean;
76
+ hideBuiltinMasks: boolean;
77
+ customModels: string;
78
+ models: LLMModel[];
79
+ modelConfig: ModelConfig;
80
+ ttsConfig: TTSConfig;
81
+ realtimeConfig: RealtimeConfig;
82
+ enableModelSearch: boolean;
83
+ enableThemeChange: boolean;
84
+ enablePromptHints: boolean;
85
+ enableClearContext: boolean;
86
+ enablePlugins: boolean;
87
+ enableShortcuts: boolean;
88
+ };
89
+
90
+ export const DEFAULT_CONFIG: AppConfig = {
91
+ lastUpdate: Date.now(),
92
+ submitKey: SubmitKey.CtrlEnter,
93
+ avatar: "1f603",
94
+ fontSize: 12,
95
+ fontFamily: "",
96
+ theme: Theme.Auto,
97
+ tightBorder: !!config?.isApp,
98
+ sendPreviewBubble: true,
99
+ enableAutoGenerateTitle: true,
100
+ sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
101
+ enableArtifacts: true,
102
+ enableCodeFold: true,
103
+ disablePromptHint: false,
104
+ dontShowMaskSplashScreen: true,
105
+ hideBuiltinMasks: false,
106
+ customModels: "",
107
+ models: DEFAULT_MODELS as any as LLMModel[],
108
+ modelConfig: {
109
+ model: "gpt-4o-mini" as ModelType,
110
+ providerName: "OpenAI" as ServiceProvider,
111
+ temperature: 1,
112
+ top_p: 0.9,
113
+ max_tokens: 14000,
114
+ presence_penalty: 0.2,
115
+ frequency_penalty: 0.2,
116
+ sendMemory: true,
117
+ historyMessageCount: 30,
118
+ compressMessageLengthThreshold: 42000,
119
+ compressModel: "",
120
+ compressProviderName: "",
121
+ enableInjectSystemPrompts: true,
122
+ template: config?.template ?? DEFAULT_INPUT_TEMPLATE,
123
+ size: "1024x1024" as DalleSize,
124
+ quality: "standard" as DalleQuality,
125
+ style: "vivid" as DalleStyle,
126
+ },
127
+ ttsConfig: {
128
+ enable: false,
129
+ autoplay: false,
130
+ engine: DEFAULT_TTS_ENGINE,
131
+ model: DEFAULT_TTS_MODEL,
132
+ voice: DEFAULT_TTS_VOICE,
133
+ speed: 1.0,
134
+ },
135
+ realtimeConfig: {
136
+ enable: false,
137
+ provider: "OpenAI" as ServiceProvider,
138
+ model: "gpt-4o-realtime-preview-2024-10-01",
139
+ apiKey: "",
140
+ azure: {
141
+ endpoint: "",
142
+ deployment: "",
143
+ },
144
+ temperature: 0.9,
145
+ voice: "alloy" as Voice,
146
+ },
147
+ enableModelSearch: false,
148
+ enableThemeChange: false,
149
+ enablePromptHints: false,
150
+ enableClearContext: true,
151
+ enablePlugins: false,
152
+ enableShortcuts: false,
153
+ };
154
+
155
+ export type ChatConfig = typeof DEFAULT_CONFIG;
156
+
157
+ export type TTSConfig = {
158
+ enable: boolean;
159
+ autoplay: boolean;
160
+ engine: TTSEngineType;
161
+ model: TTSModelType;
162
+ voice: TTSVoiceType;
163
+ speed: number;
164
+ };
165
+
166
+ export type RealtimeConfig = {
167
+ enable: boolean;
168
+ provider: ServiceProvider;
169
+ model: string;
170
+ apiKey: string;
171
+ azure: {
172
+ endpoint: string;
173
+ deployment: string;
174
+ };
175
+ temperature: number;
176
+ voice: Voice;
177
+ };
178
+
179
+ export function limitNumber(
180
+ x: number,
181
+ min: number,
182
+ max: number,
183
+ defaultValue: number,
184
+ ) {
185
+ if (isNaN(x)) {
186
+ return defaultValue;
187
+ }
188
+
189
+ return Math.min(max, Math.max(min, x));
190
+ }
191
+
192
+ export const TTSConfigValidator = {
193
+ engine(x: string) {
194
+ return x as TTSEngineType;
195
+ },
196
+ model(x: string) {
197
+ return x as TTSModelType;
198
+ },
199
+ voice(x: string) {
200
+ return x as TTSVoiceType;
201
+ },
202
+ speed(x: number) {
203
+ return limitNumber(x, 0.25, 4.0, 1.0);
204
+ },
205
+ };
206
+
207
+ export const ModalConfigValidator = {
208
+ model(x: string) {
209
+ return x as ModelType;
210
+ },
211
+ max_tokens(x: number) {
212
+ return limitNumber(x, 0, 512000, 1024);
213
+ },
214
+ presence_penalty(x: number) {
215
+ return limitNumber(x, -2, 2, 0);
216
+ },
217
+ frequency_penalty(x: number) {
218
+ return limitNumber(x, -2, 2, 0);
219
+ },
220
+ temperature(x: number) {
221
+ return limitNumber(x, 0, 2, 1);
222
+ },
223
+ top_p(x: number) {
224
+ return limitNumber(x, 0, 1, 1);
225
+ },
226
+ };
227
+
228
+ export const useAppConfig = createPersistStore(
229
+ { ...DEFAULT_CONFIG },
230
+ (set, get) => ({
231
+ reset() {
232
+ set(() => ({ ...DEFAULT_CONFIG }));
233
+ },
234
+
235
+ mergeModels(newModels: LLMModel[]) {
236
+ if (!newModels || newModels.length === 0) {
237
+ return;
238
+ }
239
+
240
+ const oldModels = get().models;
241
+ const modelMap: Record<string, LLMModel> = {};
242
+
243
+ for (const model of oldModels) {
244
+ model.available = false;
245
+ modelMap[`${model.name}@${model?.provider?.id}`] = model;
246
+ }
247
+
248
+ for (const model of newModels) {
249
+ model.available = true;
250
+ modelMap[`${model.name}@${model?.provider?.id}`] = model;
251
+ }
252
+
253
+ set(() => ({
254
+ models: Object.values(modelMap),
255
+ }));
256
+ },
257
+
258
+ allModels() {},
259
+ }),
260
+ {
261
+ name: StoreKey.Config,
262
+ version: 4.1,
263
+
264
+ merge(persistedState, currentState) {
265
+ const state = persistedState as ChatConfig | undefined;
266
+ if (!state) return { ...currentState };
267
+ const models = currentState.models.slice();
268
+ state.models.forEach((pModel) => {
269
+ const idx = models.findIndex(
270
+ (v) => v.name === pModel.name && v.provider === pModel.provider,
271
+ );
272
+ if (idx !== -1) models[idx] = pModel;
273
+ else models.push(pModel);
274
+ });
275
+ return { ...currentState, ...state, models: models };
276
+ },
277
+
278
+ migrate(persistedState, version) {
279
+ const state = persistedState as ChatConfig;
280
+
281
+ if (version < 3.4) {
282
+ state.modelConfig.sendMemory = true;
283
+ state.modelConfig.historyMessageCount = 4;
284
+ state.modelConfig.compressMessageLengthThreshold = 1000;
285
+ state.modelConfig.frequency_penalty = 0;
286
+ state.modelConfig.top_p = 1;
287
+ state.modelConfig.template = DEFAULT_INPUT_TEMPLATE;
288
+ state.dontShowMaskSplashScreen = false;
289
+ state.hideBuiltinMasks = false;
290
+ }
291
+
292
+ if (version < 3.5) {
293
+ state.customModels = "claude,claude-100k";
294
+ }
295
+
296
+ if (version < 3.6) {
297
+ state.modelConfig.enableInjectSystemPrompts = true;
298
+ }
299
+
300
+ if (version < 3.7) {
301
+ state.enableAutoGenerateTitle = true;
302
+ }
303
+
304
+ if (version < 3.8) {
305
+ state.lastUpdate = Date.now();
306
+ }
307
+
308
+ if (version < 3.9) {
309
+ state.modelConfig.template =
310
+ state.modelConfig.template !== DEFAULT_INPUT_TEMPLATE
311
+ ? state.modelConfig.template
312
+ : config?.template ?? DEFAULT_INPUT_TEMPLATE;
313
+ }
314
+
315
+ if (version < 4.1) {
316
+ state.modelConfig.compressModel =
317
+ DEFAULT_CONFIG.modelConfig.compressModel;
318
+ state.modelConfig.compressProviderName =
319
+ DEFAULT_CONFIG.modelConfig.compressProviderName;
320
+ }
321
+
322
+ return state as any;
323
+ },
324
+ },
325
+ );
app/utils.ts ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { showToast } from "./components/ui-lib";
3
+ import Locale from "./locales";
4
+ import { RequestMessage } from "./client/api";
5
+ // import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
6
+ import { fetch as tauriStreamFetch } from "./utils/stream";
7
+ import { ServiceProvider } from "./constant";
8
+
9
+ export function trimTopic(topic: string) {
10
+ // Fix an issue where double quotes still show in the Indonesian language
11
+ // This will remove the specified punctuation from the end of the string
12
+ // and also trim quotes from both the start and end if they exist.
13
+ return (
14
+ topic
15
+ // fix for gemini
16
+ .replace(/^["""*]+|["""*]+$/g, "")
17
+ .replace(/[,。!?""""、,.!?*]*$/, "")
18
+ );
19
+ }
20
+
21
+ export async function copyToClipboard(text: string) {
22
+ try {
23
+ if (window.__TAURI__) {
24
+ window.__TAURI__.writeText(text);
25
+ } else {
26
+ await navigator.clipboard.writeText(text);
27
+ }
28
+
29
+ showToast(Locale.Copy.Success);
30
+ } catch (error) {
31
+ const textArea = document.createElement("textarea");
32
+ textArea.value = text;
33
+ document.body.appendChild(textArea);
34
+ textArea.focus();
35
+ textArea.select();
36
+ try {
37
+ document.execCommand("copy");
38
+ showToast(Locale.Copy.Success);
39
+ } catch (error) {
40
+ showToast(Locale.Copy.Failed);
41
+ }
42
+ document.body.removeChild(textArea);
43
+ }
44
+ }
45
+
46
+ export async function downloadAs(text: string, filename: string) {
47
+ if (window.__TAURI__) {
48
+ const result = await window.__TAURI__.dialog.save({
49
+ defaultPath: `${filename}`,
50
+ filters: [
51
+ {
52
+ name: `${filename.split(".").pop()} files`,
53
+ extensions: [`${filename.split(".").pop()}`],
54
+ },
55
+ {
56
+ name: "All Files",
57
+ extensions: ["*"],
58
+ },
59
+ ],
60
+ });
61
+
62
+ if (result !== null) {
63
+ try {
64
+ await window.__TAURI__.fs.writeTextFile(result, text);
65
+ showToast(Locale.Download.Success);
66
+ } catch (error) {
67
+ showToast(Locale.Download.Failed);
68
+ }
69
+ } else {
70
+ showToast(Locale.Download.Failed);
71
+ }
72
+ } else {
73
+ const element = document.createElement("a");
74
+ element.setAttribute(
75
+ "href",
76
+ "data:text/plain;charset=utf-8," + encodeURIComponent(text),
77
+ );
78
+ element.setAttribute("download", filename);
79
+
80
+ element.style.display = "none";
81
+ document.body.appendChild(element);
82
+
83
+ element.click();
84
+
85
+ document.body.removeChild(element);
86
+ }
87
+ }
88
+
89
+ export function readFromFile() {
90
+ return new Promise<string>((res, rej) => {
91
+ const fileInput = document.createElement("input");
92
+ fileInput.type = "file";
93
+ fileInput.accept = "application/json";
94
+
95
+ fileInput.onchange = (event: any) => {
96
+ const file = event.target.files[0];
97
+ const fileReader = new FileReader();
98
+ fileReader.onload = (e: any) => {
99
+ res(e.target.result);
100
+ };
101
+ fileReader.onerror = (e) => rej(e);
102
+ fileReader.readAsText(file);
103
+ };
104
+
105
+ fileInput.click();
106
+ });
107
+ }
108
+
109
+ export function isIOS() {
110
+ const userAgent = navigator.userAgent.toLowerCase();
111
+ return /iphone|ipad|ipod/.test(userAgent);
112
+ }
113
+
114
+ export function useWindowSize() {
115
+ const [size, setSize] = useState({
116
+ width: window.innerWidth,
117
+ height: window.innerHeight,
118
+ });
119
+
120
+ useEffect(() => {
121
+ const onResize = () => {
122
+ setSize({
123
+ width: window.innerWidth,
124
+ height: window.innerHeight,
125
+ });
126
+ };
127
+
128
+ window.addEventListener("resize", onResize);
129
+
130
+ return () => {
131
+ window.removeEventListener("resize", onResize);
132
+ };
133
+ }, []);
134
+
135
+ return size;
136
+ }
137
+
138
+ export const MOBILE_MAX_WIDTH = 600;
139
+ export function useMobileScreen() {
140
+ const { width } = useWindowSize();
141
+
142
+ return width <= MOBILE_MAX_WIDTH;
143
+ }
144
+
145
+ export function isFirefox() {
146
+ return (
147
+ typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent)
148
+ );
149
+ }
150
+
151
+ export function selectOrCopy(el: HTMLElement, content: string) {
152
+ const currentSelection = window.getSelection();
153
+
154
+ if (currentSelection?.type === "Range") {
155
+ return false;
156
+ }
157
+
158
+ copyToClipboard(content);
159
+
160
+ return true;
161
+ }
162
+
163
+ function getDomContentWidth(dom: HTMLElement) {
164
+ const style = window.getComputedStyle(dom);
165
+ const paddingWidth =
166
+ parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
167
+ const width = dom.clientWidth - paddingWidth;
168
+ return width;
169
+ }
170
+
171
+ function getOrCreateMeasureDom(id: string, init?: (dom: HTMLElement) => void) {
172
+ let dom = document.getElementById(id);
173
+
174
+ if (!dom) {
175
+ dom = document.createElement("span");
176
+ dom.style.position = "absolute";
177
+ dom.style.wordBreak = "break-word";
178
+ dom.style.fontSize = "14px";
179
+ dom.style.transform = "translateY(-200vh)";
180
+ dom.style.pointerEvents = "none";
181
+ dom.style.opacity = "0";
182
+ dom.id = id;
183
+ document.body.appendChild(dom);
184
+ init?.(dom);
185
+ }
186
+
187
+ return dom!;
188
+ }
189
+
190
+ export function autoGrowTextArea(dom: HTMLTextAreaElement) {
191
+ const measureDom = getOrCreateMeasureDom("__measure");
192
+ const singleLineDom = getOrCreateMeasureDom("__single_measure", (dom) => {
193
+ dom.innerText = "TEXT_FOR_MEASURE";
194
+ });
195
+
196
+ const width = getDomContentWidth(dom);
197
+ measureDom.style.width = width + "px";
198
+ measureDom.innerText = dom.value !== "" ? dom.value : "1";
199
+ measureDom.style.fontSize = dom.style.fontSize;
200
+ measureDom.style.fontFamily = dom.style.fontFamily;
201
+ const endWithEmptyLine = dom.value.endsWith("\n");
202
+ const height = parseFloat(window.getComputedStyle(measureDom).height);
203
+ const singleLineHeight = parseFloat(
204
+ window.getComputedStyle(singleLineDom).height,
205
+ );
206
+
207
+ const rows =
208
+ Math.round(height / singleLineHeight) + (endWithEmptyLine ? 1 : 0);
209
+
210
+ return rows;
211
+ }
212
+
213
+ export function getCSSVar(varName: string) {
214
+ return getComputedStyle(document.body).getPropertyValue(varName).trim();
215
+ }
216
+
217
+ /**
218
+ * Detects Macintosh
219
+ */
220
+ export function isMacOS(): boolean {
221
+ if (typeof window !== "undefined") {
222
+ let userAgent = window.navigator.userAgent.toLocaleLowerCase();
223
+ const macintosh = /iphone|ipad|ipod|macintosh/.test(userAgent);
224
+ return !!macintosh;
225
+ }
226
+ return false;
227
+ }
228
+
229
+ export function getMessageTextContent(message: RequestMessage) {
230
+ if (typeof message.content === "string") {
231
+ return message.content;
232
+ }
233
+ for (const c of message.content) {
234
+ if (c.type === "text") {
235
+ return c.text ?? "";
236
+ }
237
+ }
238
+ return "";
239
+ }
240
+
241
+ export function getMessageTextContentWithoutThinking(message: RequestMessage) {
242
+ let content = "";
243
+ if (typeof message.content === "string") {
244
+ content = message.content;
245
+ } else {
246
+ for (const c of message.content) {
247
+ if (c.type === "text") {
248
+ content = c.text ?? "";
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ // 匹配以 <think> 开头,至闭合 </think>之间的内容,如果没有闭合,则匹配到结尾
254
+ const pattern = /^<think>[\s\S]*?(<\/think>|$)/;
255
+ return content.replace(pattern, "").trim(); // 直接移除匹配的部分
256
+ }
257
+
258
+ export function getMessageImages(message: RequestMessage): string[] {
259
+ if (typeof message.content === "string") {
260
+ return [];
261
+ }
262
+ const urls: string[] = [];
263
+ for (const c of message.content) {
264
+ if (c.type === "image_url") {
265
+ urls.push(c.image_url?.url ?? "");
266
+ }
267
+ }
268
+ return urls;
269
+ }
270
+
271
+ export function isVisionModel(model: string) {
272
+ const modelLower = model.toLowerCase();
273
+ const excludeKeywords = ["claude-3-5-haiku-20241022"];
274
+ const visionKeywords = [
275
+ "vision",
276
+ "gemini",
277
+ "grok",
278
+ "gpt-4o",
279
+ "gpt-4.1",
280
+ "gpt-5",
281
+ "claude-3",
282
+ "learnlm",
283
+ "vl",
284
+ "QVQ-72B-Preview",
285
+ ];
286
+ const isGpt4Turbo =
287
+ modelLower.includes("gpt-4-turbo") && !modelLower.includes("preview");
288
+
289
+ return (
290
+ !excludeKeywords.some((keyword) =>
291
+ modelLower.includes(keyword.toLowerCase()),
292
+ ) &&
293
+ (visionKeywords.some((keyword) =>
294
+ modelLower.includes(keyword.toLowerCase()),
295
+ ) ||
296
+ isGpt4Turbo ||
297
+ isDalle3(modelLower))
298
+ );
299
+ }
300
+
301
+ export function isDalle3(model: string) {
302
+ return "dall-e-3" === model;
303
+ }
304
+
305
+ export function showPlugins(providerName?: string, model?: string) {
306
+ // 始终允许gemini-2.0-flash-exp使用插件(联网功能)
307
+ if (model === "gemini-2.0-flash-exp") {
308
+ return true;
309
+ }
310
+
311
+ // 恢复原来的功能,允许特定provider使用插件
312
+ if (!providerName || !model) return false;
313
+
314
+ const provider = providerName as ServiceProvider;
315
+
316
+ // 检查模型名称是否包含 deepseek-chat 或 deepseek-v3(不区分大小写)
317
+ if (
318
+ model.toLowerCase().includes("deepseek-chat") ||
319
+ model.toLowerCase().includes("deepseek-v3") ||
320
+ model.toLowerCase().includes("deepseek-r1") ||
321
+ model.toLowerCase().includes("deepseek-reasoner")
322
+ ) {
323
+ return true;
324
+ }
325
+
326
+ if (
327
+ provider === ServiceProvider.OpenAI ||
328
+ provider === ServiceProvider.Azure ||
329
+ provider === ServiceProvider.Moonshot ||
330
+ provider === ServiceProvider.ChatGLM
331
+ ) {
332
+ return true;
333
+ }
334
+
335
+ if (provider === ServiceProvider.Anthropic && !model.includes("claude-2")) {
336
+ return true;
337
+ }
338
+
339
+ if (provider === ServiceProvider.Google && !model.includes("vision")) {
340
+ return true;
341
+ }
342
+
343
+ return false;
344
+ }
345
+
346
+ export function fetch(
347
+ url: string,
348
+ options?: Record<string, unknown>,
349
+ ): Promise<any> {
350
+ if (window.__TAURI__) {
351
+ return tauriStreamFetch(url, options);
352
+ }
353
+ return window.fetch(url, options);
354
+ }
355
+
356
+ export function adapter(config: Record<string, unknown>) {
357
+ const { baseURL, url, params, data: body, ...rest } = config;
358
+ const path = baseURL ? `${baseURL}${url}` : url;
359
+ const fetchUrl = params
360
+ ? `${path}?${new URLSearchParams(params as any).toString()}`
361
+ : path;
362
+ return fetch(fetchUrl as string, { ...rest, body }).then((res) => {
363
+ const { status, headers, statusText } = res;
364
+ return res
365
+ .text()
366
+ .then((data: string) => ({ status, statusText, headers, data }));
367
+ });
368
+ }
369
+
370
+ export function safeLocalStorage(): {
371
+ getItem: (key: string) => string | null;
372
+ setItem: (key: string, value: string) => void;
373
+ removeItem: (key: string) => void;
374
+ clear: () => void;
375
+ } {
376
+ let storage: Storage | null;
377
+
378
+ try {
379
+ if (typeof window !== "undefined" && window.localStorage) {
380
+ storage = window.localStorage;
381
+ } else {
382
+ storage = null;
383
+ }
384
+ } catch (e) {
385
+ console.error("localStorage is not available:", e);
386
+ storage = null;
387
+ }
388
+
389
+ return {
390
+ getItem(key: string): string | null {
391
+ if (storage) {
392
+ return storage.getItem(key);
393
+ } else {
394
+ console.warn(
395
+ `Attempted to get item "${key}" from localStorage, but localStorage is not available.`,
396
+ );
397
+ return null;
398
+ }
399
+ },
400
+ setItem(key: string, value: string): void {
401
+ if (storage) {
402
+ storage.setItem(key, value);
403
+ } else {
404
+ console.warn(
405
+ `Attempted to set item "${key}" in localStorage, but localStorage is not available.`,
406
+ );
407
+ }
408
+ },
409
+ removeItem(key: string): void {
410
+ if (storage) {
411
+ storage.removeItem(key);
412
+ } else {
413
+ console.warn(
414
+ `Attempted to remove item "${key}" from localStorage, but localStorage is not available.`,
415
+ );
416
+ }
417
+ },
418
+ clear(): void {
419
+ if (storage) {
420
+ storage.clear();
421
+ } else {
422
+ console.warn(
423
+ "Attempted to clear localStorage, but localStorage is not available.",
424
+ );
425
+ }
426
+ },
427
+ };
428
+ }
429
+
430
+ export function getOperationId(operation: {
431
+ operationId?: string;
432
+ method: string;
433
+ path: string;
434
+ }) {
435
+ // pattern '^[a-zA-Z0-9_-]+$'
436
+ return (
437
+ operation?.operationId ||
438
+ `${operation.method.toUpperCase()}${operation.path.replaceAll("/", "_")}`
439
+ );
440
+ }
441
+
442
+ export function clientUpdate() {
443
+ // this a wild for updating client app
444
+ return window.__TAURI__?.updater
445
+ .checkUpdate()
446
+ .then((updateResult) => {
447
+ if (updateResult.shouldUpdate) {
448
+ window.__TAURI__?.updater
449
+ .installUpdate()
450
+ .then((result) => {
451
+ showToast(Locale.Settings.Update.Success);
452
+ })
453
+ .catch((e) => {
454
+ console.error("[Install Update Error]", e);
455
+ showToast(Locale.Settings.Update.Failed);
456
+ });
457
+ }
458
+ })
459
+ .catch((e) => {
460
+ console.error("[Check Update Error]", e);
461
+ showToast(Locale.Settings.Update.Failed);
462
+ });
463
+ }
464
+
465
+ // https://gist.github.com/iwill/a83038623ba4fef6abb9efca87ae9ccb
466
+ export function semverCompare(a: string, b: string) {
467
+ if (a.startsWith(b + "-")) return -1;
468
+ if (b.startsWith(a + "-")) return 1;
469
+ return a.localeCompare(b, undefined, {
470
+ numeric: true,
471
+ sensitivity: "case",
472
+ caseFirst: "upper",
473
+ });
474
+ }