feat: migrate to DSH 0.1.5-rc.2 with TypeScript refactor, official slot footer and codex selector
This commit is contained in:
@@ -1,41 +1,47 @@
|
||||
# dsh-plugin-model-enhancer
|
||||
|
||||
DeepSeek Harness (DSH) 自定义模型增强与交互插件。
|
||||
DeepSeek Harness (DSH) Model Settings Enhancer & Codex Selector Plugin (Compatible with DSH 0.1.5-rc.2).
|
||||
|
||||
## ✨ 特性一览
|
||||
## ✨ Features
|
||||
|
||||
1. **设置页面模型目录增强**:
|
||||
- 支持图像/视觉(Vision)输入模态开关。
|
||||
- 支持多档位推理思考深度(Reasoning Efforts,包含 `minimal` / `low` / `medium` / `high` / `xhigh` / `max`)。
|
||||
- 官方模型目录与模型条目右侧状态胶囊徽标展示(图像、思考档位)。
|
||||
- **models.dev 智能预填充**:
|
||||
- 单模型「智能补全」与对比确认弹窗(支持按项选用推荐值)。
|
||||
- 顶部一键「一键补全」批量安全清洗与参数匹配。
|
||||
1. **Official Extension Slots Integration (Non-destructive to `ui-settings-models`)**:
|
||||
- Integrates model enhancement configuration via `settings.models.footer` and `settings.models.provider-card` (targeted at `llm-pi-ai`).
|
||||
- Supports models.dev auto-fill matching, prefilling missing capacities, image modalities (`input: ["text", "image"]`), and multi-level reasoning efforts (`minimal` / `low` / `medium` / `high` / `xhigh` / `max`).
|
||||
- Uses `ctx.remote.settings.mutate` with revision fence checking for safe, non-destructive configuration persistence.
|
||||
- Preserves official file attachment and upload pipeline (`ui-attachment` & `file-upload`), removing obsolete custom upload endpoint routes.
|
||||
|
||||
2. **输入栏左侧连体药丸操作小岛(Pill Island)**:
|
||||
- 整合 `[+]`(命令菜单)与 `[📎]`(多模态图片/文件上传)为一体化胶囊岛。
|
||||
- 正圆形 Hover 微光微交互。
|
||||
2. **Codex-style Model & Reasoning Effort Selector**:
|
||||
- Injected into `conversation.input.right` seamlessly alongside the official composer.
|
||||
- Reuses `ctx.modelDirectories` (`ModelDirectoryResolver`) for per-session directory resolution and selection dispatch.
|
||||
- Interactive sliding track for reasoning effort adjustments and grouped model picker popup.
|
||||
|
||||
3. **输入栏右下角 Codex 风格推理滑块与模型切换**:
|
||||
- 无边框极简常态,悬浮/激活呈现通透线框轮廓。
|
||||
- 水平胶囊滑轨与海蓝渐变激活填充(Soft Sky-Blue Gradient)。
|
||||
- 饱满悬浮大圆球(Thumb),突破滑槽高度,悬停/拖拽轻微弹性放大。
|
||||
- 点击顶部模型名平滑展开带供应商分类徽标与细分割线的模型选择树。
|
||||
3. **models.dev Cache & Proxy**:
|
||||
- Exposes exact WebServer proxy route `/api/models-dev/models` with in-memory caching and fallback.
|
||||
|
||||
4. **全面双语支持(i18n)**:
|
||||
- 自动跟随 DSH 系统语言(中文 / English)无缝切换所有文案与档位标签。
|
||||
4. **Full Bilingual Support (i18n)**:
|
||||
- Dynamic localization supporting Simplified Chinese (`zh`) and English (`en`).
|
||||
|
||||
## 📦 安装与配置
|
||||
## 🛠 Building & Development
|
||||
|
||||
在 DSH Web Profile 的 `package.json` 中添加依赖并注册 bundle:
|
||||
```bash
|
||||
npm run build # Bundles client code to lib/client.js and emits types
|
||||
npm test # Runs Vitest unit tests
|
||||
npm run typecheck # TypeScript static type check
|
||||
```
|
||||
|
||||
## 📦 Profile Configuration
|
||||
|
||||
In your profile's `package.json`:
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"dsh-plugin-model-enhancer": "^1.0.0"
|
||||
"dsh-plugin-model-enhancer": "file:../dsh-plugin-model-enhancer"
|
||||
},
|
||||
"dsh": {
|
||||
"profile": {
|
||||
"bundles": [
|
||||
"@deepseek-ai/dsh-base",
|
||||
"@deepseek-ai/dsh-web-app",
|
||||
"dsh-plugin-model-enhancer"
|
||||
]
|
||||
}
|
||||
|
||||
-2157
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
async function build() {
|
||||
console.log('Building host entry: src/index.ts -> lib/index.js...');
|
||||
await esbuild.build({
|
||||
entryPoints: [path.resolve(__dirname, 'src/index.ts')],
|
||||
outfile: path.resolve(__dirname, 'lib/index.js'),
|
||||
bundle: false,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node18'
|
||||
});
|
||||
|
||||
console.log('Building client bundle: src/client/index.ts -> lib/client.js...');
|
||||
const clientBuildResult = await esbuild.build({
|
||||
entryPoints: [path.resolve(__dirname, 'src/client/index.ts')],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: 'cjs',
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
external: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/jsx-runtime',
|
||||
'@deepseek-ai/cordis',
|
||||
'@deepseek-ai/dsh-client-store',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-ui-dockkit'
|
||||
]
|
||||
});
|
||||
|
||||
const bundledCjs = clientBuildResult.outputFiles[0].text;
|
||||
|
||||
// Wrap in window.__ModuleLoader__.load({ id: "dsh-plugin-model-enhancer", factory: (require) => { ... } })
|
||||
const wrappedClientCode = `window.__ModuleLoader__.load({
|
||||
id: "dsh-plugin-model-enhancer",
|
||||
factory: (require) => {
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
${bundledCjs}
|
||||
return module.exports;
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
fs.mkdirSync(path.resolve(__dirname, 'lib'), { recursive: true });
|
||||
fs.writeFileSync(path.resolve(__dirname, 'lib/client.js'), wrappedClientCode, 'utf8');
|
||||
console.log('Successfully generated lib/client.js (length: ' + wrappedClientCode.length + ')');
|
||||
}
|
||||
|
||||
build().catch((err) => {
|
||||
console.error('Build failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+1
-4
@@ -1,7 +1,4 @@
|
||||
# dsh bundle patch: replace default ui-settings-models with enhanced model settings
|
||||
- id: ui-settings-models
|
||||
disabled: true
|
||||
|
||||
# dsh bundle patch: insert dsh-plugin-model-enhancer alongside official plugins
|
||||
- insert:
|
||||
- id: dsh-plugin-model-enhancer
|
||||
name: 'dsh-plugin-model-enhancer'
|
||||
|
||||
+1052
-4201
File diff suppressed because one or more lines are too long
+27
-97
@@ -1,22 +1,17 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const name = 'dsh-plugin-model-enhancer';
|
||||
export const inject = ['webServer'];
|
||||
|
||||
const name = "dsh-plugin-model-enhancer";
|
||||
const inject = ["webServer"];
|
||||
let cachedData = null;
|
||||
let lastFetchedAt = 0;
|
||||
const CACHE_TTL_MS = 3600 * 1000; // 1 hour
|
||||
|
||||
const CACHE_TTL_MS = 3600 * 1e3;
|
||||
async function getModelsDevData() {
|
||||
const now = Date.now();
|
||||
if (cachedData && now - lastFetchedAt < CACHE_TTL_MS) {
|
||||
return cachedData;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('https://models.dev/api.json', {
|
||||
const res = await fetch("https://models.dev/api.json", {
|
||||
headers: {
|
||||
'User-Agent': 'DSH-Model-Enhancer/1.0.0'
|
||||
"User-Agent": "DSH-Model-Enhancer/1.0.0"
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -28,114 +23,49 @@ async function getModelsDevData() {
|
||||
return json;
|
||||
} catch (err) {
|
||||
if (cachedData) {
|
||||
// Fallback to stale cache if network fails
|
||||
return cachedData;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
// 1. Models.dev Cache Proxy
|
||||
function apply(ctx) {
|
||||
ctx.effect(() => {
|
||||
return ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/api/models-dev/models',
|
||||
kind: "exact",
|
||||
path: "/api/models-dev/models",
|
||||
handler: async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
if (req.method !== "GET") {
|
||||
res.writeHead(405, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getModelsDevData();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=3600"
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
} catch (err) {
|
||||
ctx.logger?.error?.('[model-enhancer] Error fetching models.dev:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message || 'Internal Server Error' }));
|
||||
ctx.logger?.error?.("[model-enhancer] Error fetching models.dev:", err);
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err.message || "Internal Server Error" }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 'dsh-plugin-model-enhancer: webServer proxy route');
|
||||
|
||||
// 2. Binary / Large Document File Upload to Workspace Endpoint
|
||||
ctx.effect(() => {
|
||||
return ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/api/model-enhancer/upload',
|
||||
handler: async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Filename');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
||||
const rawName = req.headers['x-filename'] || urlObj.searchParams.get('filename') || `upload_${Date.now()}.bin`;
|
||||
const decodedName = decodeURIComponent(rawName);
|
||||
const safeName = path.basename(decodedName);
|
||||
|
||||
const uploadDir = path.join(process.cwd(), 'uploads');
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const targetPath = path.join(uploadDir, safeName);
|
||||
const writeStream = fs.createWriteStream(targetPath);
|
||||
|
||||
let totalBytes = 0;
|
||||
req.on('data', (chunk) => {
|
||||
totalBytes += chunk.length;
|
||||
});
|
||||
|
||||
req.pipe(writeStream);
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
filename: safeName,
|
||||
path: path.join('uploads', safeName),
|
||||
size: totalBytes
|
||||
}));
|
||||
});
|
||||
|
||||
writeStream.on('error', (err) => {
|
||||
ctx.logger?.error?.('[model-enhancer] Upload stream error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message || 'File write error' }));
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger?.error?.('[model-enhancer] Upload handler error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message || 'Upload processing error' }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 'dsh-plugin-model-enhancer: workspace file upload endpoint');
|
||||
}, "dsh-plugin-model-enhancer: webServer proxy route");
|
||||
}
|
||||
export {
|
||||
apply,
|
||||
getModelsDevData,
|
||||
inject,
|
||||
name
|
||||
};
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
export interface CodexModelSelectProps {
|
||||
directory: {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getSnapshot: () => any;
|
||||
};
|
||||
load: () => void;
|
||||
select: (selection: {
|
||||
provider: string;
|
||||
model: string;
|
||||
reasoningEffort?: string;
|
||||
}) => Promise<boolean>;
|
||||
available: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
export declare function CodexModelSelect(props: CodexModelSelectProps): React.DetailedReactHTMLElement<{
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
style: {
|
||||
position: "relative";
|
||||
display: "inline-flex";
|
||||
alignItems: "center";
|
||||
};
|
||||
}, HTMLDivElement>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
export interface ModelsFooterSettingsProps {
|
||||
ctx: any;
|
||||
}
|
||||
export declare function ModelsFooterSettings({ ctx }: ModelsFooterSettingsProps): React.DetailedReactHTMLElement<{
|
||||
className: string;
|
||||
}, HTMLElement>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
export interface ProviderCardExtrasProps {
|
||||
provider: {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
settingsNs: string;
|
||||
settingsPath: readonly string[];
|
||||
active: boolean;
|
||||
declared?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
configured: boolean;
|
||||
keyConfigured: boolean;
|
||||
ctx: any;
|
||||
}
|
||||
export declare function ProviderCardExtras({ provider, configured, keyConfigured, ctx }: ProviderCardExtrasProps): React.DetailedReactHTMLElement<{
|
||||
style: {
|
||||
padding: string;
|
||||
marginTop: string;
|
||||
background: string;
|
||||
borderRadius: string;
|
||||
border: string;
|
||||
display: "flex";
|
||||
alignItems: "center";
|
||||
justifyContent: "space-between";
|
||||
gap: string;
|
||||
};
|
||||
}, HTMLElement>;
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
export interface TranslationMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
export declare const I18N_DICT: {
|
||||
zh: TranslationMap;
|
||||
en: TranslationMap;
|
||||
};
|
||||
export declare function isZhLocale(): boolean;
|
||||
export declare function getTranslation(key: string, isZh?: boolean, params?: Record<string, any>): string;
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export declare const name = "dsh-plugin-model-enhancer";
|
||||
export declare const inject: string[];
|
||||
export declare function apply(ctx: any): void;
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
export interface DevModelInfo {
|
||||
providerKey: string;
|
||||
providerName: string;
|
||||
id: string;
|
||||
rawKey: string;
|
||||
name?: string;
|
||||
limit?: {
|
||||
context?: number;
|
||||
output?: number;
|
||||
};
|
||||
modalities?: {
|
||||
input?: string[];
|
||||
output?: string[];
|
||||
};
|
||||
reasoning?: boolean;
|
||||
reasoning_options?: Array<{
|
||||
type?: string;
|
||||
values?: string[];
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
export declare const THINKING_LEVELS_ALL: string[];
|
||||
export declare function fetchAllModelsDev(): Promise<DevModelInfo[]>;
|
||||
export declare function normModelKey(key: string): string;
|
||||
export declare function findDevModel(queryId: string, allList: DevModelInfo[]): DevModelInfo | null;
|
||||
Vendored
+11
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export declare const name = "dsh-plugin-model-enhancer";
|
||||
export declare const inject: string[];
|
||||
export declare function getModelsDevData(): Promise<any>;
|
||||
export declare function apply(ctx: any): void;
|
||||
Generated
+2108
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -6,12 +6,21 @@
|
||||
"main": "./lib/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client": "./lib/client.js",
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.cjs && tsc --emitDeclarationOnly",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
@@ -30,5 +39,12 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.2",
|
||||
"@types/react": "^18.3.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { CODEX_LEVELS_ZH, CODEX_LEVELS_EN } from './styles';
|
||||
import { isZhLocale } from './i18n';
|
||||
|
||||
export interface CodexModelSelectProps {
|
||||
directory: {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getSnapshot: () => any;
|
||||
};
|
||||
load: () => void;
|
||||
select: (selection: { provider: string; model: string; reasoningEffort?: string }) => Promise<boolean>;
|
||||
available: boolean;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function CodexModelSelect(props: CodexModelSelectProps) {
|
||||
const { directory, load, select, available, locked } = props;
|
||||
const state = useSyncExternalStore(directory.subscribe, directory.getSnapshot);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showModelPicker, setShowModelPicker] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const isZh = isZhLocale();
|
||||
const codexLevels = isZh ? CODEX_LEVELS_ZH : CODEX_LEVELS_EN;
|
||||
|
||||
useEffect(() => {
|
||||
if (available) load();
|
||||
}, [available]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setShowModelPicker(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [open]);
|
||||
|
||||
const choices = useMemo(() => (state.groups || []).flatMap((g: any) => (g.models || []).map((m: any) => ({
|
||||
group: g,
|
||||
model: m
|
||||
}))), [state.groups]);
|
||||
|
||||
const currentChoice = choices.find((c: any) => c.group.id === state.current?.provider && c.model.id === state.current?.model);
|
||||
const currentModel = currentChoice?.model;
|
||||
const reasoning = currentModel?.reasoning;
|
||||
|
||||
const availableEfforts = useMemo(() => {
|
||||
if (!reasoning || !reasoning.efforts || reasoning.efforts.length === 0) return [];
|
||||
return reasoning.efforts.map((e: any) => {
|
||||
const found = codexLevels.find((l) => l.id === e.id);
|
||||
return {
|
||||
id: e.id,
|
||||
label: found?.label || e.name || e.id,
|
||||
short: found?.short || e.name || e.id
|
||||
};
|
||||
});
|
||||
}, [reasoning, isZh]);
|
||||
|
||||
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort;
|
||||
const currentEffortIndex = useMemo(() => {
|
||||
if (availableEfforts.length === 0) return 0;
|
||||
const idx = availableEfforts.findIndex((e: any) => e.id === effectiveEffort);
|
||||
return idx === -1 ? 0 : idx;
|
||||
}, [availableEfforts, effectiveEffort]);
|
||||
|
||||
const currentEffortObj = availableEfforts[currentEffortIndex];
|
||||
const effortDisplayLabel = currentEffortObj ? currentEffortObj.label : (isZh ? "中" : "Medium");
|
||||
|
||||
const triggerModelName = currentModel?.name || (isZh ? "选择模型" : "Select model");
|
||||
const triggerEffortLabel = reasoning ? (currentEffortObj ? currentEffortObj.short : (isZh ? "默认" : "Default")) : null;
|
||||
|
||||
const onSelectEffortByIndex = (idx: number) => {
|
||||
if (!currentChoice || !availableEfforts[idx]) return;
|
||||
const effort = availableEfforts[idx].id;
|
||||
select({
|
||||
provider: currentChoice.group.id,
|
||||
model: currentChoice.model.id,
|
||||
reasoningEffort: effort
|
||||
});
|
||||
};
|
||||
|
||||
const onResetDefault = () => {
|
||||
if (!currentChoice) return;
|
||||
select({
|
||||
provider: currentChoice.group.id,
|
||||
model: currentChoice.model.id,
|
||||
reasoningEffort: reasoning?.defaultEffort
|
||||
});
|
||||
};
|
||||
|
||||
const onTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (availableEfforts.length <= 1) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const idx = Math.round(ratio * (availableEfforts.length - 1));
|
||||
onSelectEffortByIndex(idx);
|
||||
};
|
||||
|
||||
if (!available) return null;
|
||||
|
||||
return React.createElement("div", {
|
||||
ref: rootRef,
|
||||
style: { position: "relative", display: "inline-flex", alignItems: "center" }
|
||||
},
|
||||
React.createElement("button", {
|
||||
type: "button",
|
||||
className: `me-codex-trigger ${open ? "active-open" : ""}`,
|
||||
disabled: locked,
|
||||
onClick: () => setOpen(!open)
|
||||
},
|
||||
React.createElement("span", null, triggerModelName),
|
||||
triggerEffortLabel ? React.createElement("span", {
|
||||
style: { color: "#3b82f6", fontWeight: 600 }
|
||||
}, "· ", triggerEffortLabel) : null,
|
||||
React.createElement("svg", {
|
||||
className: `me-codex-trigger-chevron ${open ? "open" : ""}`,
|
||||
width: "12",
|
||||
height: "12",
|
||||
viewBox: "0 0 16 16",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: "1.8"
|
||||
}, React.createElement("path", { d: "M4 6l4 4 4-4" }))
|
||||
),
|
||||
open ? React.createElement("div", {
|
||||
className: "me-codex-popup"
|
||||
},
|
||||
React.createElement("div", {
|
||||
className: "me-codex-topbar"
|
||||
},
|
||||
React.createElement("div", {
|
||||
className: "me-codex-icon-btn",
|
||||
title: isZh ? "推理强度" : "Reasoning Effort"
|
||||
},
|
||||
React.createElement("svg", {
|
||||
width: "16",
|
||||
height: "16",
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: "2"
|
||||
}, React.createElement("path", { d: "M13 2L3 14h9l-1 8 10-12h-9l1-8z" }))
|
||||
),
|
||||
React.createElement("div", {
|
||||
className: "me-codex-center",
|
||||
onClick: () => setShowModelPicker(!showModelPicker),
|
||||
title: isZh ? "点击切换模型" : "Click to switch model"
|
||||
},
|
||||
React.createElement("div", {
|
||||
className: "me-codex-effort-title"
|
||||
},
|
||||
effortDisplayLabel,
|
||||
React.createElement("svg", {
|
||||
width: "12",
|
||||
height: "12",
|
||||
viewBox: "0 0 16 16",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: "2",
|
||||
style: { transform: showModelPicker ? "rotate(90deg)" : undefined, transition: "transform 140ms ease" }
|
||||
}, React.createElement("path", { d: "M6 3.5L10.5 8L6 12.5" }))
|
||||
),
|
||||
React.createElement("div", {
|
||||
className: "me-codex-model-name"
|
||||
}, currentModel?.name || (isZh ? "选择模型" : "Select model"))
|
||||
),
|
||||
React.createElement("button", {
|
||||
type: "button",
|
||||
className: "me-codex-icon-btn",
|
||||
title: isZh ? "恢复默认档位" : "Reset to default effort",
|
||||
onClick: onResetDefault
|
||||
},
|
||||
React.createElement("svg", {
|
||||
width: "16",
|
||||
height: "16",
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: "2"
|
||||
}, React.createElement("path", { d: "M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8m0 0V3m0 5h5" }))
|
||||
)
|
||||
),
|
||||
showModelPicker ? React.createElement("div", {
|
||||
className: "me-model-picker-list"
|
||||
},
|
||||
(state.groups || []).map((group: any) => React.createElement("div", {
|
||||
key: group.id,
|
||||
className: "me-model-group-section"
|
||||
},
|
||||
React.createElement("div", {
|
||||
className: "me-model-picker-group"
|
||||
},
|
||||
React.createElement("svg", {
|
||||
width: "12",
|
||||
height: "12",
|
||||
viewBox: "0 0 16 16",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: "1.8"
|
||||
}, React.createElement("path", { d: "M2 4h12M2 8h12M2 12h8" })),
|
||||
group.name
|
||||
),
|
||||
(group.models || []).map((m: any) => {
|
||||
const active = state.current?.provider === group.id && state.current?.model === m.id;
|
||||
return React.createElement("button", {
|
||||
key: m.id,
|
||||
type: "button",
|
||||
className: `me-model-picker-item ${active ? "active" : ""}`,
|
||||
onClick: () => {
|
||||
select({
|
||||
provider: group.id,
|
||||
model: m.id,
|
||||
reasoningEffort: m.reasoning?.defaultEffort
|
||||
});
|
||||
setShowModelPicker(false);
|
||||
}
|
||||
},
|
||||
React.createElement("span", null, m.name),
|
||||
active ? React.createElement("svg", {
|
||||
width: "14",
|
||||
height: "14",
|
||||
viewBox: "0 0 16 16",
|
||||
fill: "none",
|
||||
stroke: "#38bdf8",
|
||||
strokeWidth: "2"
|
||||
}, React.createElement("path", { d: "M3.5 8.5l3 3 6-6" })) : null
|
||||
);
|
||||
})
|
||||
))
|
||||
) : React.createElement("div", {
|
||||
className: "me-slider-track-wrap",
|
||||
onClick: onTrackClick
|
||||
},
|
||||
React.createElement("div", {
|
||||
className: "me-slider-track"
|
||||
},
|
||||
React.createElement("div", {
|
||||
className: "me-slider-active-fill",
|
||||
style: {
|
||||
width: availableEfforts.length <= 1 ? "100%" : `calc(16px + (100% - 32px) * ${currentEffortIndex / (availableEfforts.length - 1)})`
|
||||
}
|
||||
}),
|
||||
React.createElement("div", {
|
||||
className: "me-slider-dots-layer"
|
||||
},
|
||||
availableEfforts.map((eff: any, i: number) => React.createElement("div", {
|
||||
key: eff.id,
|
||||
className: `me-slider-dot ${i <= currentEffortIndex ? "lit" : ""}`
|
||||
}))
|
||||
),
|
||||
React.createElement("div", {
|
||||
className: "me-slider-thumb",
|
||||
style: {
|
||||
left: availableEfforts.length <= 1 ? "calc(100% - 16px)" : `calc(16px + (100% - 32px) * ${currentEffortIndex / (availableEfforts.length - 1)})`
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
) : null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { fetchAllModelsDev, findDevModel, THINKING_LEVELS_ALL, DevModelInfo } from './modelsDev';
|
||||
import { isZhLocale, getTranslation } from './i18n';
|
||||
|
||||
export interface ModelsFooterSettingsProps {
|
||||
ctx: any;
|
||||
}
|
||||
|
||||
export function ModelsFooterSettings({ ctx }: ModelsFooterSettingsProps) {
|
||||
const isZh = isZhLocale();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [providerList, setProviderList] = useState<any[]>([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('');
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [batching, setBatching] = useState(false);
|
||||
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await ctx.remote?.llm?.listConfigurableProviders?.();
|
||||
if (res?.ok && Array.isArray(res.value)) {
|
||||
setProviderList(res.value);
|
||||
if (res.value.length > 0 && !selectedProvider) {
|
||||
setSelectedProvider(res.value[0].provider);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[model-enhancer] Failed to list providers:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
}, []);
|
||||
|
||||
const handleBatchDevMatch = async () => {
|
||||
if (!selectedProvider) return;
|
||||
setBatching(true);
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const descRes = await ctx.remote?.settings?.describe?.();
|
||||
const namespaces = descRes?.ok ? descRes.value.namespaces : [];
|
||||
const piAiNs = namespaces.find((n: any) => n.ns === 'llm-pi-ai');
|
||||
|
||||
if (!piAiNs) {
|
||||
setStatusMsg({ type: 'err', text: 'llm-pi-ai settings namespace not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const provConfig = piAiNs.value?.providers?.[selectedProvider] || {};
|
||||
const models = Array.isArray(provConfig.models) ? provConfig.models : [];
|
||||
|
||||
if (models.length === 0) {
|
||||
setStatusMsg({ type: 'err', text: getTranslation('fillNoIdError', isZh) });
|
||||
return;
|
||||
}
|
||||
|
||||
const allDevModels = await fetchAllModelsDev();
|
||||
let count = 0;
|
||||
|
||||
const nextModels = models.map((m: any) => {
|
||||
if (!m || !m.id) return m;
|
||||
const matched = findDevModel(m.id, allDevModels);
|
||||
if (!matched) return m;
|
||||
count++;
|
||||
const hasImage = Boolean(matched.modalities?.input?.includes('image'));
|
||||
const hasReasoning = Boolean(matched.reasoning);
|
||||
let reasoningLevels: any = undefined;
|
||||
|
||||
if (hasReasoning) {
|
||||
reasoningLevels = { off: "none" };
|
||||
let added = false;
|
||||
if (Array.isArray(matched.reasoning_options)) {
|
||||
for (const opt of matched.reasoning_options) {
|
||||
if (opt.type === "effort" && Array.isArray(opt.values)) {
|
||||
for (const val of opt.values) {
|
||||
if (val === "none" || val === "off") reasoningLevels.off = "none";
|
||||
else if (THINKING_LEVELS_ALL.includes(val)) {
|
||||
reasoningLevels[val] = val;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!added) {
|
||||
reasoningLevels.low = "low";
|
||||
reasoningLevels.medium = "medium";
|
||||
reasoningLevels.high = "high";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...m,
|
||||
name: m.name || matched.name,
|
||||
contextWindow: m.contextWindow || matched.limit?.context,
|
||||
maxTokens: m.maxTokens || matched.limit?.output,
|
||||
input: m.input || (hasImage ? ["text", "image"] : ["text"]),
|
||||
reasoningEfforts: m.reasoningEfforts !== undefined ? m.reasoningEfforts : (hasReasoning ? reasoningLevels : false)
|
||||
};
|
||||
});
|
||||
|
||||
// revision-safe mutate
|
||||
const ops = [{
|
||||
op: 'set',
|
||||
path: ['providers', selectedProvider, 'models'],
|
||||
value: nextModels
|
||||
}];
|
||||
|
||||
const mutateRes = await ctx.remote.settings.mutate('llm-pi-ai', ops, piAiNs.revision);
|
||||
if (mutateRes?.ok) {
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: getTranslation('fillSuccessNotice', isZh, { count, total: models.length })
|
||||
});
|
||||
} else {
|
||||
setStatusMsg({
|
||||
type: 'err',
|
||||
text: mutateRes?.error?.message || 'Failed to mutate settings'
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setStatusMsg({
|
||||
type: 'err',
|
||||
text: err.message || String(err)
|
||||
});
|
||||
} finally {
|
||||
setBatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return React.createElement("div", {
|
||||
className: "me-footer-card"
|
||||
},
|
||||
React.createElement("div", {
|
||||
className: "me-footer-title"
|
||||
},
|
||||
React.createElement("svg", {
|
||||
width: "18",
|
||||
height: "18",
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: "2"
|
||||
}, React.createElement("path", { d: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" })),
|
||||
React.createElement("span", null, getTranslation('enhancerTitle', isZh))
|
||||
),
|
||||
React.createElement("div", {
|
||||
className: "me-footer-desc"
|
||||
}, getTranslation('enhancerIntro', isZh)),
|
||||
React.createElement("div", {
|
||||
style: { display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }
|
||||
},
|
||||
React.createElement("label", {
|
||||
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '13px', fontWeight: 500 }
|
||||
},
|
||||
React.createElement("span", null, getTranslation('targetProvider', isZh)),
|
||||
React.createElement("select", {
|
||||
value: selectedProvider,
|
||||
onChange: (e: any) => setSelectedProvider(e.target.value),
|
||||
style: {
|
||||
height: '32px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
|
||||
background: 'var(--dsw-alias-bg-module-platform, #fff)',
|
||||
color: 'var(--dsw-alias-label-primary, #000)',
|
||||
padding: '0 8px',
|
||||
fontSize: '13px'
|
||||
}
|
||||
},
|
||||
providerList.map((p) => React.createElement("option", { key: p.provider, value: p.provider }, `${p.displayName || p.provider} (${p.settingsNs || 'generic'})`))
|
||||
)
|
||||
),
|
||||
React.createElement("button", {
|
||||
type: "button",
|
||||
disabled: batching || !selectedProvider,
|
||||
onClick: handleBatchDevMatch,
|
||||
style: {
|
||||
height: '32px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
background: '#2563eb',
|
||||
color: '#fff',
|
||||
padding: '0 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: (batching || !selectedProvider) ? 'not-allowed' : 'pointer',
|
||||
opacity: (batching || !selectedProvider) ? 0.6 : 1,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}
|
||||
},
|
||||
batching ? getTranslation('batchBtnBusy', isZh) : getTranslation('batchBtn', isZh)
|
||||
),
|
||||
React.createElement("button", {
|
||||
type: "button",
|
||||
disabled: loading,
|
||||
onClick: loadProviders,
|
||||
style: {
|
||||
height: '32px',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
|
||||
background: 'transparent',
|
||||
color: 'var(--dsw-alias-label-secondary, #666)',
|
||||
padding: '0 12px',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}, getTranslation('refreshSettings', isZh))
|
||||
),
|
||||
statusMsg ? React.createElement("div", {
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '6px',
|
||||
background: statusMsg.type === 'ok' ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||
color: statusMsg.type === 'ok' ? '#10b981' : '#ef4444'
|
||||
}
|
||||
}, statusMsg.text) : null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useState } from 'react';
|
||||
import { fetchAllModelsDev, findDevModel, THINKING_LEVELS_ALL, DevModelInfo } from './modelsDev';
|
||||
import { isZhLocale, getTranslation } from './i18n';
|
||||
|
||||
export interface ProviderCardExtrasProps {
|
||||
provider: {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
settingsNs: string;
|
||||
settingsPath: readonly string[];
|
||||
active: boolean;
|
||||
declared?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
configured: boolean;
|
||||
keyConfigured: boolean;
|
||||
ctx: any;
|
||||
}
|
||||
|
||||
export function ProviderCardExtras({ provider, configured, keyConfigured, ctx }: ProviderCardExtrasProps) {
|
||||
const isZh = isZhLocale();
|
||||
const [matching, setMatching] = useState(false);
|
||||
const [notice, setNotice] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
if (provider.settingsNs !== 'llm-pi-ai') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAutoFillProvider = async () => {
|
||||
setMatching(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const descRes = await ctx.remote?.settings?.describe?.();
|
||||
const namespaces = descRes?.ok ? descRes.value.namespaces : [];
|
||||
const piAiNs = namespaces.find((n: any) => n.ns === 'llm-pi-ai');
|
||||
|
||||
if (!piAiNs) {
|
||||
setNotice({ type: 'err', text: 'llm-pi-ai namespace missing.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const provKey = provider.provider;
|
||||
const provConfig = piAiNs.value?.providers?.[provKey] || {};
|
||||
const models = Array.isArray(provConfig.models) ? provConfig.models : [];
|
||||
|
||||
if (models.length === 0) {
|
||||
setNotice({ type: 'err', text: getTranslation('fillNoIdError', isZh) });
|
||||
return;
|
||||
}
|
||||
|
||||
const allDevModels = await fetchAllModelsDev();
|
||||
let count = 0;
|
||||
|
||||
const nextModels = models.map((m: any) => {
|
||||
if (!m || !m.id) return m;
|
||||
const matched = findDevModel(m.id, allDevModels);
|
||||
if (!matched) return m;
|
||||
count++;
|
||||
const hasImage = Boolean(matched.modalities?.input?.includes('image'));
|
||||
const hasReasoning = Boolean(matched.reasoning);
|
||||
let reasoningLevels: any = undefined;
|
||||
|
||||
if (hasReasoning) {
|
||||
reasoningLevels = { off: "none" };
|
||||
let added = false;
|
||||
if (Array.isArray(matched.reasoning_options)) {
|
||||
for (const opt of matched.reasoning_options) {
|
||||
if (opt.type === "effort" && Array.isArray(opt.values)) {
|
||||
for (const val of opt.values) {
|
||||
if (val === "none" || val === "off") reasoningLevels.off = "none";
|
||||
else if (THINKING_LEVELS_ALL.includes(val)) {
|
||||
reasoningLevels[val] = val;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!added) {
|
||||
reasoningLevels.low = "low";
|
||||
reasoningLevels.medium = "medium";
|
||||
reasoningLevels.high = "high";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...m,
|
||||
name: m.name || matched.name,
|
||||
contextWindow: m.contextWindow || matched.limit?.context,
|
||||
maxTokens: m.maxTokens || matched.limit?.output,
|
||||
input: m.input || (hasImage ? ["text", "image"] : ["text"]),
|
||||
reasoningEfforts: m.reasoningEfforts !== undefined ? m.reasoningEfforts : (hasReasoning ? reasoningLevels : false)
|
||||
};
|
||||
});
|
||||
|
||||
const ops = [{
|
||||
op: 'set',
|
||||
path: ['providers', provKey, 'models'],
|
||||
value: nextModels
|
||||
}];
|
||||
|
||||
const mutateRes = await ctx.remote.settings.mutate('llm-pi-ai', ops, piAiNs.revision);
|
||||
if (mutateRes?.ok) {
|
||||
setNotice({
|
||||
type: 'ok',
|
||||
text: getTranslation('fillSuccessNotice', isZh, { count, total: models.length })
|
||||
});
|
||||
} else {
|
||||
setNotice({
|
||||
type: 'err',
|
||||
text: mutateRes?.error?.message || 'Mutation rejected'
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
setNotice({ type: 'err', text: e.message || String(e) });
|
||||
} finally {
|
||||
setMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return React.createElement("div", {
|
||||
style: {
|
||||
padding: '8px 12px',
|
||||
marginTop: '4px',
|
||||
background: 'var(--dsw-alias-bg-module-platform, rgba(0, 0, 0, 0.02))',
|
||||
borderRadius: '8px',
|
||||
border: '1px dashed var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px'
|
||||
}
|
||||
},
|
||||
React.createElement("span", {
|
||||
style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #666)' }
|
||||
}, getTranslation('activeEnhancerNotice', isZh, { provider: provider.displayName })),
|
||||
React.createElement("button", {
|
||||
type: "button",
|
||||
disabled: matching,
|
||||
onClick: handleAutoFillProvider,
|
||||
style: {
|
||||
height: '26px',
|
||||
padding: '0 10px',
|
||||
borderRadius: '13px',
|
||||
border: 'none',
|
||||
background: '#2563eb',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: matching ? 'not-allowed' : 'pointer',
|
||||
opacity: matching ? 0.7 : 1
|
||||
}
|
||||
}, matching ? getTranslation('singleBtnBusy', isZh) : getTranslation('batchBtn', isZh)),
|
||||
notice ? React.createElement("span", {
|
||||
style: {
|
||||
fontSize: '11px',
|
||||
color: notice.type === 'ok' ? '#10b981' : '#ef4444',
|
||||
marginLeft: '8px'
|
||||
}
|
||||
}, notice.text) : null
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
// Complete i18n Dictionary for Model Enhancer Plugin
|
||||
const ENHANCER_I18N = {
|
||||
export interface TranslationMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export const I18N_DICT: { zh: TranslationMap; en: TranslationMap } = {
|
||||
zh: {
|
||||
// ModelListEditor & Badges
|
||||
batchBtn: "一键补全",
|
||||
@@ -27,7 +30,12 @@ const ENHANCER_I18N = {
|
||||
singleFillFetchFailed: "获取 models.dev 失败: {message}",
|
||||
singleFillApplied: '已预填充 "{id}" 推荐参数',
|
||||
|
||||
// PrefillModal
|
||||
// PrefillModal & Footer Settings
|
||||
enhancerTitle: "Model Enhancer (多模态与推理参数增强)",
|
||||
enhancerIntro: "配置 models.dev 预填充、输入模态与推理思考档位,并可选择目标 Provider 一键应用增强配置。",
|
||||
targetProvider: "目标 Provider:",
|
||||
refreshSettings: "刷新配置",
|
||||
activeEnhancerNotice: "当前正在对「{provider}」生效增强功能。",
|
||||
modalTitle: "models.dev 预填充确认与选择",
|
||||
matchedModelLabel: "检索到匹配模型:",
|
||||
thSelect: "选用",
|
||||
@@ -48,9 +56,6 @@ const ENHANCER_I18N = {
|
||||
cancel: "取消",
|
||||
applySelected: "应用已选推荐项",
|
||||
|
||||
// Paperclip
|
||||
attachTitle: "上传图片或文件",
|
||||
|
||||
// Codex Slider
|
||||
effortOff: "关",
|
||||
effortMinimal: "极低",
|
||||
@@ -92,7 +97,12 @@ const ENHANCER_I18N = {
|
||||
singleFillFetchFailed: "Failed to fetch models.dev: {message}",
|
||||
singleFillApplied: 'Prefilled recommended parameters for "{id}"',
|
||||
|
||||
// PrefillModal
|
||||
// PrefillModal & Footer Settings
|
||||
enhancerTitle: "Model Enhancer Settings",
|
||||
enhancerIntro: "Configure models.dev auto-fill, input modalities and reasoning effort levels across providers.",
|
||||
targetProvider: "Target Provider:",
|
||||
refreshSettings: "Refresh",
|
||||
activeEnhancerNotice: "Enhancer active on \"{provider}\".",
|
||||
modalTitle: "models.dev Prefill Confirmation",
|
||||
matchedModelLabel: "Matched Model: ",
|
||||
thSelect: "Apply",
|
||||
@@ -113,9 +123,6 @@ const ENHANCER_I18N = {
|
||||
cancel: "Cancel",
|
||||
applySelected: "Apply Selected",
|
||||
|
||||
// Paperclip
|
||||
attachTitle: "Attach files or images",
|
||||
|
||||
// Codex Slider
|
||||
effortOff: "Off",
|
||||
effortMinimal: "Minimal",
|
||||
@@ -131,3 +138,22 @@ const ENHANCER_I18N = {
|
||||
commandsTitle: "Commands (/)"
|
||||
}
|
||||
};
|
||||
|
||||
export function isZhLocale(): boolean {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const lang = (navigator.language || '').toLowerCase();
|
||||
if (lang.startsWith('zh')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getTranslation(key: string, isZh = isZhLocale(), params?: Record<string, any>): string {
|
||||
const dict = isZh ? I18N_DICT.zh : I18N_DICT.en;
|
||||
let str = dict[key] || I18N_DICT.en[key] || key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { ENHANCER_CSS } from './styles';
|
||||
import { CodexModelSelect } from './CodexModelSelect';
|
||||
import { ModelsFooterSettings } from './ModelsFooterSettings';
|
||||
import { ProviderCardExtras } from './ProviderCardExtras';
|
||||
|
||||
export const name = 'dsh-plugin-model-enhancer';
|
||||
|
||||
export const inject = [
|
||||
'slots',
|
||||
'remote',
|
||||
'remote.settings',
|
||||
'remote.llm',
|
||||
'modelDirectories',
|
||||
'sessions'
|
||||
];
|
||||
|
||||
export function apply(ctx: any) {
|
||||
// Inject CSS once
|
||||
if (typeof document !== 'undefined' && !document.querySelector("style[data-plugin='dsh-model-enhancer']")) {
|
||||
const tag = document.createElement("style");
|
||||
tag.dataset.plugin = "dsh-model-enhancer";
|
||||
tag.textContent = ENHANCER_CSS;
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
// 1. Register Codex-style Model and Reasoning Selector in conversation.input.right
|
||||
ctx.inject(['slots'], (scope: any) => {
|
||||
const sessions = ctx.get('sessions');
|
||||
scope.slots.inject('conversation.input.right', () => scope.slots.register({
|
||||
name: 'conversation.input.right',
|
||||
id: 'me-codex-model-selector',
|
||||
order: 100,
|
||||
inject: (sessionId: string) => {
|
||||
const dirSvc = ctx.get('modelDirectories');
|
||||
const directory = dirSvc ? dirSvc.directoryFor(sessionId) : null;
|
||||
const available = sessions ? sessions.subagentAddress(sessionId) === undefined : true;
|
||||
return {
|
||||
available: available && Boolean(directory),
|
||||
directory: directory ? directory.store : {
|
||||
subscribe: () => () => {},
|
||||
getSnapshot: () => ({ status: 'idle', groups: [], current: null })
|
||||
},
|
||||
load: () => {
|
||||
if (available && directory) directory.load().catch(() => {});
|
||||
},
|
||||
select: (selection: any) => (available && directory)
|
||||
? directory.select(selection).then(() => true, () => false)
|
||||
: Promise.resolve(false)
|
||||
};
|
||||
}
|
||||
}, CodexModelSelect));
|
||||
});
|
||||
|
||||
// 2. Register into official settings extension slot: settings.models.footer
|
||||
ctx.inject(['slots'], (scope: any) => {
|
||||
scope.slots.inject('settings.models.footer', () => scope.slots.register({
|
||||
name: 'settings.models.footer',
|
||||
id: 'me-models-footer',
|
||||
order: 50,
|
||||
inject: () => ({ ctx })
|
||||
}, ModelsFooterSettings));
|
||||
});
|
||||
|
||||
// 3. Register into official settings extension slot: settings.models.provider-card for llm-pi-ai
|
||||
ctx.inject(['slots'], (scope: any) => {
|
||||
scope.slots.inject('settings.models.provider-card', () => scope.slots.register({
|
||||
name: 'settings.models.provider-card',
|
||||
key: 'llm-pi-ai',
|
||||
inject: (ownerProps: any) => ({
|
||||
...ownerProps,
|
||||
ctx
|
||||
})
|
||||
}, ProviderCardExtras));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export interface DevModelInfo {
|
||||
providerKey: string;
|
||||
providerName: string;
|
||||
id: string;
|
||||
rawKey: string;
|
||||
name?: string;
|
||||
limit?: {
|
||||
context?: number;
|
||||
output?: number;
|
||||
};
|
||||
modalities?: {
|
||||
input?: string[];
|
||||
output?: string[];
|
||||
};
|
||||
reasoning?: boolean;
|
||||
reasoning_options?: Array<{
|
||||
type?: string;
|
||||
values?: string[];
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const THINKING_LEVELS_ALL = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
let modelsDevGlobalCache: DevModelInfo[] | null = null;
|
||||
let modelsDevGlobalPromise: Promise<DevModelInfo[]> | null = null;
|
||||
|
||||
export async function fetchAllModelsDev(): Promise<DevModelInfo[]> {
|
||||
if (modelsDevGlobalCache) return modelsDevGlobalCache;
|
||||
if (modelsDevGlobalPromise) return modelsDevGlobalPromise;
|
||||
|
||||
modelsDevGlobalPromise = (async () => {
|
||||
const endpoints = ["/api/models-dev/models", "https://models.dev/api.json"];
|
||||
let lastError: any = null;
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
const list: DevModelInfo[] = [];
|
||||
for (const [pId, p] of Object.entries(json as Record<string, any>)) {
|
||||
if (p && p.models) {
|
||||
for (const [mId, m] of Object.entries(p.models as Record<string, any>)) {
|
||||
list.push({
|
||||
providerKey: pId,
|
||||
providerName: p.name || pId,
|
||||
id: m.id || mId,
|
||||
rawKey: mId,
|
||||
...m
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
modelsDevGlobalCache = list;
|
||||
return list;
|
||||
}
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
throw lastError || new Error("Failed to load models.dev");
|
||||
})();
|
||||
|
||||
try {
|
||||
return await modelsDevGlobalPromise;
|
||||
} finally {
|
||||
modelsDevGlobalPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normModelKey(key: string): string {
|
||||
if (!key || typeof key !== "string") return "";
|
||||
return key.toLowerCase()
|
||||
.replace(/^[a-z0-9_-]+\//, "")
|
||||
.replace(/[-_.:/]/g, "")
|
||||
.replace(/\d{8}$/, "");
|
||||
}
|
||||
|
||||
export function findDevModel(queryId: string, allList: DevModelInfo[]): DevModelInfo | null {
|
||||
if (!queryId || !allList) return null;
|
||||
const q = queryId.trim().toLowerCase();
|
||||
const qNorm = normModelKey(q);
|
||||
|
||||
let m = allList.find((x) => x.id?.toLowerCase() === q || x.rawKey?.toLowerCase() === q);
|
||||
if (m) return m;
|
||||
|
||||
m = allList.find((x) => x.id?.split("/").pop()?.toLowerCase() === q);
|
||||
if (m) return m;
|
||||
|
||||
m = allList.find((x) => normModelKey(x.id) === qNorm || normModelKey(x.name || "") === qNorm);
|
||||
if (m) return m;
|
||||
|
||||
m = allList.find((x) => {
|
||||
const n = normModelKey(x.id);
|
||||
return n.length >= 3 && (n.includes(qNorm) || qNorm.includes(n));
|
||||
});
|
||||
return m || null;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
export const CODEX_LEVELS_ZH = [
|
||||
{ id: "off", label: "关", short: "关" },
|
||||
{ id: "minimal", label: "极低", short: "极低" },
|
||||
{ id: "low", label: "低", short: "低" },
|
||||
{ id: "medium", label: "中", short: "中" },
|
||||
{ id: "high", label: "高", short: "高" },
|
||||
{ id: "xhigh", label: "超高", short: "超高" },
|
||||
{ id: "max", label: "最大", short: "最大" }
|
||||
];
|
||||
|
||||
export const CODEX_LEVELS_EN = [
|
||||
{ id: "off", label: "Off", short: "Off" },
|
||||
{ id: "minimal", label: "Minimal", short: "Minimal" },
|
||||
{ id: "low", label: "Low", short: "Low" },
|
||||
{ id: "medium", label: "Medium", short: "Medium" },
|
||||
{ id: "high", label: "High", short: "High" },
|
||||
{ id: "xhigh", label: "X-High", short: "X-High" },
|
||||
{ id: "max", label: "Max", short: "Max" }
|
||||
];
|
||||
|
||||
export const ENHANCER_CSS = `
|
||||
/* Hide default trigger when Codex trigger is active */
|
||||
div[class*="_7KE1Ra_root"], div[class*="ModelSelect_module_css_root"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.me-codex-trigger {
|
||||
box-sizing: border-box;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary, #61666b);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 120ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
.me-codex-trigger:hover:not(:disabled),
|
||||
.me-codex-trigger.active-open {
|
||||
border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.15));
|
||||
color: var(--dsw-alias-label-primary, #0f1115);
|
||||
}
|
||||
body[data-ds-dark-theme] .me-codex-trigger:hover:not(:disabled),
|
||||
body[data-ds-dark-theme] .me-codex-trigger.active-open {
|
||||
border-color: var(--dsw-alias-border-l3, rgba(255, 255, 255, 0.2));
|
||||
color: var(--dsw-alias-label-primary, #fff);
|
||||
}
|
||||
.me-codex-trigger-chevron {
|
||||
transition: transform 140ms ease;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.me-codex-trigger-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.me-codex-popup {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 280px;
|
||||
background: var(--dsw-specific-menu, #ffffff);
|
||||
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px 12px;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-codex-popup {
|
||||
background: var(--dsw-specific-menu, #1b1d22);
|
||||
border-color: var(--dsw-alias-border-l3, rgba(255, 255, 255, 0.1));
|
||||
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.5), 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.me-codex-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.me-codex-icon-btn {
|
||||
box-sizing: border-box;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary, #64748b);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.me-codex-icon-btn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
|
||||
color: var(--dsw-alias-label-primary, #0f172a);
|
||||
}
|
||||
body[data-ds-dark-theme] .me-codex-icon-btn {
|
||||
color: var(--dsw-alias-label-secondary, #94a3b8);
|
||||
}
|
||||
body[data-ds-dark-theme] .me-codex-icon-btn:hover {
|
||||
background: var(--dsw-alias-surface-l2, rgba(255, 255, 255, 0.08));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.me-codex-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.me-codex-effort-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary, #0f172a);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-codex-effort-title {
|
||||
color: #fff;
|
||||
}
|
||||
.me-codex-model-name {
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary, #64748b);
|
||||
white-space: nowrap;
|
||||
max-width: 170px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.me-codex-model-name:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-codex-model-name {
|
||||
color: var(--dsw-alias-label-tertiary, #94a3b8);
|
||||
}
|
||||
body[data-ds-dark-theme] .me-codex-model-name:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.me-slider-track-wrap {
|
||||
box-sizing: border-box;
|
||||
padding: 4px 0 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.me-slider-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-slider-track {
|
||||
background: var(--dsw-alias-border-l3, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.me-slider-active-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: #3b82f6;
|
||||
pointer-events: none;
|
||||
transition: width 120ms ease;
|
||||
}
|
||||
|
||||
.me-slider-dots-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.me-slider-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-border-l3, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
body[data-ds-dark-theme] .me-slider-dot {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.me-slider-dot.lit {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.me-slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 2px solid #3b82f6;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
transition: left 120ms ease;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-slider-thumb {
|
||||
background: #1e293b;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.me-model-picker-list {
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.me-model-group-section {
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
.me-model-group-section:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.me-model-picker-group {
|
||||
font-size: 11px;
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-model-picker-group {
|
||||
color: #60a5fa;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
.me-model-picker-item {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--dsw-alias-label-primary, #0f1115);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.me-model-picker-item:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
.me-model-picker-item.active {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-model-picker-item {
|
||||
color: var(--dsw-alias-label-primary, #e2e8f0);
|
||||
}
|
||||
body[data-ds-dark-theme] .me-model-picker-item:hover {
|
||||
background: var(--dsw-alias-surface-l2, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
body[data-ds-dark-theme] .me-model-picker-item.active {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
/* Footer Section */
|
||||
.me-footer-card {
|
||||
box-sizing: border-box;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
||||
background: var(--dsw-alias-bg-module-platform, rgba(0, 0, 0, 0.02));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-footer-card {
|
||||
border-color: var(--dsw-alias-border-l3, rgba(255, 255, 255, 0.1));
|
||||
background: var(--dsw-alias-surface-l1, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
.me-footer-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary, #0f1115);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
body[data-ds-dark-theme] .me-footer-title {
|
||||
color: #fff;
|
||||
}
|
||||
.me-footer-desc {
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-secondary, #61666b);
|
||||
line-height: 20px;
|
||||
}
|
||||
.me-diff-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.me-diff-table th, .me-diff-table td {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
body[data-ds-dark-theme] .me-diff-table th, body[data-ds-dark-theme] .me-diff-table td {
|
||||
border-color: var(--dsw-alias-border-l3, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
.me-val-new {
|
||||
color: var(--dsw-alias-state-success-primary, #10b981);
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,73 @@
|
||||
export const name = 'dsh-plugin-model-enhancer';
|
||||
export const inject = ['webServer'];
|
||||
|
||||
let cachedData: any = null;
|
||||
let lastFetchedAt = 0;
|
||||
const CACHE_TTL_MS = 3600 * 1000; // 1 hour
|
||||
|
||||
export async function getModelsDevData() {
|
||||
const now = Date.now();
|
||||
if (cachedData && now - lastFetchedAt < CACHE_TTL_MS) {
|
||||
return cachedData;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('https://models.dev/api.json', {
|
||||
headers: {
|
||||
'User-Agent': 'DSH-Model-Enhancer/1.0.0'
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch models.dev: HTTP ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
cachedData = json;
|
||||
lastFetchedAt = now;
|
||||
return json;
|
||||
} catch (err) {
|
||||
if (cachedData) {
|
||||
// Fallback to stale cache if network fails
|
||||
return cachedData;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: any) {
|
||||
// 1. Models.dev Cache Proxy
|
||||
ctx.effect(() => {
|
||||
return ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/api/models-dev/models',
|
||||
handler: async (req: any, res: any) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getModelsDevData();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
} catch (err: any) {
|
||||
ctx.logger?.error?.('[model-enhancer] Error fetching models.dev:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message || 'Internal Server Error' }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 'dsh-plugin-model-enhancer: webServer proxy route');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { apply } from '../src/index';
|
||||
|
||||
describe('Host plugin', () => {
|
||||
it('registers models dev exact proxy route', () => {
|
||||
const registerMock = vi.fn();
|
||||
const mockCtx = {
|
||||
effect: (fn: any) => fn(),
|
||||
webServer: {
|
||||
register: registerMock,
|
||||
},
|
||||
};
|
||||
|
||||
apply(mockCtx as any);
|
||||
expect(registerMock).toHaveBeenCalled();
|
||||
const callArg = registerMock.mock.calls[0][0];
|
||||
expect(callArg.path).toBe('/api/models-dev/models');
|
||||
expect(callArg.kind).toBe('exact');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normModelKey, findDevModel } from '../src/client/modelsDev';
|
||||
import { I18N_DICT } from '../src/client/i18n';
|
||||
|
||||
describe('modelsDev utils', () => {
|
||||
it('normalizes model keys consistently', () => {
|
||||
expect(normModelKey('openai/gpt-4o-mini-20240718')).toBe('gpt4omini');
|
||||
expect(normModelKey('claude-3-5-sonnet-20241022')).toBe('claude35sonnet');
|
||||
expect(normModelKey('deepseek/deepseek-chat')).toBe('deepseekchat');
|
||||
});
|
||||
|
||||
it('matches fuzzy dev models', () => {
|
||||
const list = [
|
||||
{ id: 'openai/gpt-4o', rawKey: 'gpt-4o', name: 'GPT-4o', modalities: { input: ['text', 'image'] }, reasoning: false },
|
||||
{ id: 'anthropic/claude-3-5-sonnet', rawKey: 'claude-3-5-sonnet', name: 'Claude 3.5 Sonnet', modalities: { input: ['text', 'image'] }, reasoning: false },
|
||||
{ id: 'deepseek/deepseek-r1', rawKey: 'deepseek-r1', name: 'DeepSeek R1', modalities: { input: ['text'] }, reasoning: true }
|
||||
];
|
||||
|
||||
expect(findDevModel('gpt-4o', list)?.id).toBe('openai/gpt-4o');
|
||||
expect(findDevModel('claude-3-5-sonnet-20241022', list)?.id).toBe('anthropic/claude-3-5-sonnet');
|
||||
expect(findDevModel('deepseek-r1', list)?.id).toBe('deepseek/deepseek-r1');
|
||||
});
|
||||
|
||||
it('provides bilingual dictionary keys', () => {
|
||||
expect(I18N_DICT.zh.modalTitle).toBeDefined();
|
||||
expect(I18N_DICT.en.modalTitle).toBeDefined();
|
||||
expect(I18N_DICT.zh.batchBtn).toBeDefined();
|
||||
expect(I18N_DICT.en.batchBtn).toBeDefined();
|
||||
expect(I18N_DICT.zh.effortReasoningTitle).toBeDefined();
|
||||
expect(I18N_DICT.en.effortReasoningTitle).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"declaration": true,
|
||||
"outDir": "./lib/types",
|
||||
"rootDir": "./src",
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user