Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html data-theme="light" data-color-scheme="default">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="application-name" content="NomiFun" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="NomiFun" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#4E5969" />
|
||||
<link rel="icon" type="image/png" href="./pwa/icon-192.png" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="./pwa/icon-180.png" />
|
||||
<title>NomiFun</title>
|
||||
<script>
|
||||
// Node-global shim for the Tauri/web webview. The renderer was ported from
|
||||
// Electron (where `process` exists in the renderer) and still reads a few
|
||||
// `process.env.*` keys at module-eval time — e.g. WEBUI_DEFAULT_PORT in
|
||||
// common/config/constants.ts. Vite replaces `process.env.NODE_ENV` but NOT
|
||||
// other `process.env.*`, so in dev those survive to runtime and throw
|
||||
// "process is not defined", aborting the whole module graph → white screen.
|
||||
// This classic script runs before the deferred `type="module"` entry, so
|
||||
// the shim is in place before any app code evaluates.
|
||||
window.process = window.process || { env: {} };
|
||||
</script>
|
||||
<script>
|
||||
// Synchronously restore theme from localStorage to prevent theme flash
|
||||
(function () {
|
||||
try {
|
||||
var theme = localStorage.getItem('__nomifun_theme');
|
||||
var colorScheme = localStorage.getItem('__nomifun_colorScheme');
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
if (colorScheme) {
|
||||
document.documentElement.setAttribute('data-color-scheme', colorScheme);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Set arco-theme on body synchronously
|
||||
try {
|
||||
var t = localStorage.getItem('__nomifun_theme');
|
||||
if (t) document.body.setAttribute('arco-theme', t);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/renderer/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"name": "@nomifun-tauri/ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:web": "vite --mode webdev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.18.2",
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@arco-design/web-react": "^2.66.1",
|
||||
"@aws-sdk/client-bedrock": "^3.987.0",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"@google/genai": "^1.16.0",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@icon-park/react": "^1.4.2",
|
||||
"@larksuiteoapi/node-sdk": "^1.58.0",
|
||||
"@modelcontextprotocol/sdk": "^1.20.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-deep-link": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.1",
|
||||
"@uiw/react-codemirror": "^4.25.2",
|
||||
"@wecom/aibot-node-sdk": "^1.0.6",
|
||||
"@xmldom/xmldom": "^0.8.11",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/addon-web-links": "0.11.0",
|
||||
"@xterm/addon-webgl": "0.18.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.5.1",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"croner": "^9.1.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"diff": "^8.0.3",
|
||||
"diff2html": "^3.4.55",
|
||||
"dingtalk-stream": "^2.1.4",
|
||||
"docx": "^9.5.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"grammy": "^1.39.3",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"i18next": "^23.7.16",
|
||||
"jsonrepair": "^3.13.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"katex": "^0.16.22",
|
||||
"mammoth": "^1.11.0",
|
||||
"mermaid": "^11.13.0",
|
||||
"multer": "^2.1.1",
|
||||
"officeparser": "^5.2.2",
|
||||
"onnxruntime-web": "^1.26.0",
|
||||
"openai": "^5.12.2",
|
||||
"pptx2json": "^0.0.10",
|
||||
"process": "^0.11.10",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^14.0.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.8.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"semver": "^7.7.2",
|
||||
"smol-toml": "^1.6.1",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"streamdown": "^1.5.1",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"swr": "^2.3.6",
|
||||
"tiny-csrf": "^1.1.6",
|
||||
"turndown": "^7.2.2",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"web-tree-sitter": "^0.25.10",
|
||||
"ws": "^8.18.3",
|
||||
"xlsx-republish": "^0.20.3",
|
||||
"yauzl": "^3.2.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.8.3",
|
||||
"unocss": "^66.3.3",
|
||||
"unocss-preset-extra": "^1.0.0",
|
||||
"vite": "^6.4.1"
|
||||
}
|
||||
}
|
||||
Generated
+8398
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "NomiFun",
|
||||
"short_name": "NomiFun",
|
||||
"description": "NomiFun WebUI for mobile and desktop browsers.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#4E5969",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./pwa/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./pwa/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,162 @@
|
||||
// Bumped from v1: the v1 networkFirst fallback returned OFFLINE_PAGE_URL
|
||||
// (index.html) for failed script requests, causing "module script MIME text/html"
|
||||
// errors when the server was down or served a different asset hash. The v2
|
||||
// activate handler deletes v1, flushing any poisoned cached entries.
|
||||
const CACHE_NAME = 'nomifun-webui-v2';
|
||||
const NON_CACHEABLE_PATHS = new Set(['/qr-login']);
|
||||
const OFFLINE_PAGE_URL = new URL('./index.html', self.location.href).toString();
|
||||
const PRECACHE_URLS = [
|
||||
new URL('./', self.location.href).toString(),
|
||||
OFFLINE_PAGE_URL,
|
||||
new URL('./manifest.webmanifest', self.location.href).toString(),
|
||||
new URL('./pwa/icon-192.png', self.location.href).toString(),
|
||||
new URL('./pwa/icon-512.png', self.location.href).toString(),
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys.map((key) => {
|
||||
if (key === CACHE_NAME) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return caches.delete(key);
|
||||
})
|
||||
)
|
||||
)
|
||||
.then(() => self.clients.claim())
|
||||
// After claim, tell any open WebUI tabs running under the previous SW
|
||||
// to reload so they pick up the fixed fetch handler immediately.
|
||||
// Without this, an already-open tab keeps serving from the old v1 SW
|
||||
// until the user manually refreshes.
|
||||
.then(() =>
|
||||
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
client.navigate(client.url).catch(() => undefined);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
function shouldHandleRequest(request) {
|
||||
if (request.method !== 'GET') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !url.pathname.startsWith('/api/') && !NON_CACHEABLE_PATHS.has(url.pathname);
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Only fall back to the offline index.html for navigation requests.
|
||||
// Returning index.html for a `<script>` request gives the browser HTML with
|
||||
// a text/html MIME and breaks the page with "module script MIME" errors.
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
if (request.mode === 'navigate') {
|
||||
return (await cache.match(OFFLINE_PAGE_URL)) || Response.error();
|
||||
}
|
||||
return Response.error();
|
||||
}
|
||||
}
|
||||
|
||||
// Asset requests (script/style) must never serve a cached entry whose content
|
||||
// type has diverged from what the browser is asking for. A hash-mismatched
|
||||
// script file on disk is served as index.html by the fallback, which then
|
||||
// gets cached — the next request replays the corrupted entry even if the
|
||||
// network is up. This helper drops entries whose content-type lies.
|
||||
function isAssetContentTypeMismatch(request, response) {
|
||||
if (request.mode === 'navigate') return false;
|
||||
const destination = request.destination;
|
||||
if (destination !== 'script' && destination !== 'style') return false;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (destination === 'script' && !/javascript|ecmascript|wasm/i.test(contentType)) return true;
|
||||
if (destination === 'style' && !/css/i.test(contentType)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
const networkFetch = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
if (cached) {
|
||||
void networkFetch;
|
||||
return cached;
|
||||
}
|
||||
|
||||
return (await networkFetch) || Response.error();
|
||||
}
|
||||
|
||||
async function networkOnlyWithTypeGuard(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const response = await fetch(request);
|
||||
if (isAssetContentTypeMismatch(request, response)) {
|
||||
// Server is probably serving the SPA fallback (index.html) for a script
|
||||
// URL whose hash no longer exists on disk — typically a stale asset
|
||||
// reference from a previous app version. Drop any poisoned cache entry
|
||||
// and fail fast so the browser shows a clear error instead of trying to
|
||||
// execute HTML as JavaScript.
|
||||
await cache.delete(request);
|
||||
return Response.error();
|
||||
}
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (!shouldHandleRequest(event.request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
const destination = event.request.destination;
|
||||
if (['script', 'style'].includes(destination)) {
|
||||
// Script/style must stay network-fresh. An asset hash mismatch between
|
||||
// two app builds would otherwise let networkFirst serve a stale cached
|
||||
// bundle that no longer matches index.html's <script> tags.
|
||||
event.respondWith(networkOnlyWithTypeGuard(event.request));
|
||||
} else if (['image', 'font'].includes(destination)) {
|
||||
event.respondWith(staleWhileRevalidate(event.request));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { fromApiConversation } from './apiModelMapper';
|
||||
|
||||
// 最小 ApiConversation 片段:只构造 mapper 关心的字段
|
||||
const apiConv = (o: Record<string, unknown>) => ({ id: 'c1', name: 'conv', type: 'acp', created_at: 1, modified_at: 2, ...o });
|
||||
|
||||
type MappedExtra = { pinned?: boolean; pinned_at?: number; custom_workspace?: boolean } | null | undefined;
|
||||
const extraOf = (raw: Record<string, unknown>): MappedExtra => (fromApiConversation(raw) as { extra?: MappedExtra }).extra;
|
||||
|
||||
describe('fromApiConversation 置顶镜像(DB 顶层 pinned 列 → extra)', () => {
|
||||
test('顶层列置顶 → 镜像进 extra(含服务端维护的 pinned_at)', () => {
|
||||
const extra = extraOf(apiConv({ pinned: true, pinned_at: 1712345678000, extra: {} }));
|
||||
expect(extra?.pinned).toBe(true);
|
||||
expect(extra?.pinned_at).toBe(1712345678000);
|
||||
});
|
||||
|
||||
test('extra 为空/缺失时列置顶也能生成镜像', () => {
|
||||
const extra = extraOf(apiConv({ pinned: true, pinned_at: 100, extra: null }));
|
||||
expect(extra?.pinned).toBe(true);
|
||||
expect(extra?.pinned_at).toBe(100);
|
||||
});
|
||||
|
||||
test('冲突时列优先:列置顶覆盖 extra.pinned=false,pinned_at 取列值', () => {
|
||||
const extra = extraOf(apiConv({ pinned: true, pinned_at: 200, extra: { pinned: false, pinned_at: 999 } }));
|
||||
expect(extra?.pinned).toBe(true);
|
||||
expect(extra?.pinned_at).toBe(200);
|
||||
});
|
||||
|
||||
test('OR 兼容:列未置顶但旧数据仅 extra 置顶 → 不丢,pinned_at 保留 extra 来源', () => {
|
||||
const extra = extraOf(apiConv({ pinned: false, extra: { pinned: true, pinned_at: 300 } }));
|
||||
expect(extra?.pinned).toBe(true);
|
||||
expect(extra?.pinned_at).toBe(300);
|
||||
});
|
||||
|
||||
test('两侧均未置顶 → 不注入 pinned key', () => {
|
||||
const extra = extraOf(apiConv({ pinned: false, extra: {} }));
|
||||
expect(extra && 'pinned' in extra).toBe(false);
|
||||
});
|
||||
|
||||
test('列置顶但列 pinned_at 缺失 → 回退 extra.pinned_at', () => {
|
||||
const extra = extraOf(apiConv({ pinned: true, extra: { pinned: true, pinned_at: 400 } }));
|
||||
expect(extra?.pinned).toBe(true);
|
||||
expect(extra?.pinned_at).toBe(400);
|
||||
});
|
||||
|
||||
test('custom_workspace 推导不受置顶镜像影响', () => {
|
||||
const extra = extraOf(apiConv({ pinned: true, pinned_at: 1, extra: { workspace: '/w/p1' } }));
|
||||
expect(extra?.custom_workspace).toBe(true);
|
||||
expect(extra?.pinned).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { TProviderWithModel } from '../config/storage';
|
||||
|
||||
export type ApiProviderWithModel = {
|
||||
provider_id: string;
|
||||
model: string;
|
||||
use_model?: string;
|
||||
};
|
||||
|
||||
function hasCompleteModelIdentity(
|
||||
model?: TProviderWithModel
|
||||
): model is TProviderWithModel & { id: string; use_model: string } {
|
||||
return Boolean(
|
||||
model &&
|
||||
typeof model.id === 'string' &&
|
||||
model.id.trim().length > 0 &&
|
||||
typeof model.use_model === 'string' &&
|
||||
model.use_model.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
// ── Frontend → Backend ──────────────────────────────────────────────────
|
||||
|
||||
export function toApiModel(m: TProviderWithModel): ApiProviderWithModel {
|
||||
return {
|
||||
provider_id: m.id,
|
||||
model: m.use_model,
|
||||
};
|
||||
}
|
||||
|
||||
export function toApiModelOptional(m?: TProviderWithModel): ApiProviderWithModel | undefined {
|
||||
return hasCompleteModelIdentity(m) ? toApiModel(m) : undefined;
|
||||
}
|
||||
|
||||
// ── Backend → Frontend ──────────────────────────────────────────────────
|
||||
|
||||
export function fromApiModel(raw: ApiProviderWithModel): TProviderWithModel {
|
||||
return {
|
||||
id: raw.provider_id,
|
||||
platform: '',
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
use_model: raw.use_model ?? raw.model,
|
||||
};
|
||||
}
|
||||
|
||||
function fromApiModelOptional(raw?: ApiProviderWithModel | null): TProviderWithModel | undefined {
|
||||
return raw ? fromApiModel(raw) : undefined;
|
||||
}
|
||||
|
||||
/** ConversationResponse 顶层置顶字段(conversations 表真列,服务端维护 pinned_at)。 */
|
||||
export type ApiConversationPinnedFields = {
|
||||
pinned?: boolean | null;
|
||||
/** 毫秒时间戳;未置顶时服务端省略该 key */
|
||||
pinned_at?: number | null;
|
||||
};
|
||||
|
||||
export function fromApiConversation<T>(raw: T): T {
|
||||
if (!raw || typeof raw !== 'object') return raw;
|
||||
|
||||
const r = raw as T &
|
||||
ApiConversationPinnedFields & {
|
||||
model?: ApiProviderWithModel | null;
|
||||
extra?: Record<string, unknown> | null;
|
||||
/** Promoted to a top-level conversations column (was extra.cronJobId). */
|
||||
cron_job_id?: string | null;
|
||||
};
|
||||
const next = { ...r } as unknown as T & {
|
||||
model?: TProviderWithModel;
|
||||
extra?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
if ('model' in r) {
|
||||
next.model = fromApiModelOptional(r.model);
|
||||
}
|
||||
|
||||
let extra = r.extra && typeof r.extra === 'object' ? r.extra : null;
|
||||
|
||||
if (extra && !('custom_workspace' in extra)) {
|
||||
const workspace = typeof extra.workspace === 'string' ? extra.workspace : '';
|
||||
const isTemporary = extra.is_temporary_workspace === true;
|
||||
extra = {
|
||||
...extra,
|
||||
custom_workspace: workspace.length > 0 && !isTemporary,
|
||||
};
|
||||
}
|
||||
|
||||
// cron_job_id 镜像:后端把它从 extra.cronJobId 提升为顶层列;为保持既有读路径
|
||||
// (多处读 conversation.extra?.cron_job_id)不变,将顶层值镜像回 extra。
|
||||
if (typeof r.cron_job_id === 'string' && r.cron_job_id.length > 0) {
|
||||
extra = { ...(extra ?? {}), cron_job_id: r.cron_job_id };
|
||||
}
|
||||
|
||||
// 置顶镜像:DB 顶层 pinned/pinned_at 列为权威,镜像进 extra,客户端读路径
|
||||
// 保持单一(isConversationPinned / workpathTree 只读 extra)。
|
||||
// 兼容规则:extra.pinned = 列 || extra(列为准,但旧数据仅 extra 置顶的不丢);
|
||||
// 冲突时 pinned_at 取列值(服务端维护),仅 extra 置顶时保留 extra.pinned_at。
|
||||
if (r.pinned === true) {
|
||||
const base = extra ?? {};
|
||||
const pinnedAt = typeof r.pinned_at === 'number' ? r.pinned_at : typeof base.pinned_at === 'number' ? (base.pinned_at as number) : undefined;
|
||||
extra = {
|
||||
...base,
|
||||
pinned: true,
|
||||
...(pinnedAt !== undefined ? { pinned_at: pinnedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (extra && extra !== r.extra) {
|
||||
next.extra = extra;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function fromApiPaginatedConversations<T>(result: { items: T[]; total: number; has_more: boolean }): {
|
||||
items: T[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
} {
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map(fromApiConversation),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { bridge, logger } from '@/platform';
|
||||
import { WEBUI_DEFAULT_PORT } from '@/common/config/constants';
|
||||
|
||||
interface CustomWindow extends Window {
|
||||
__bridgeEmitter?: { emit: (name: string, data: unknown) => void };
|
||||
__emitBridgeCallback?: (name: string, data: unknown) => void;
|
||||
__websocketReconnect?: () => void;
|
||||
}
|
||||
|
||||
const win = window as CustomWindow;
|
||||
|
||||
/**
|
||||
* 适配electron的API到浏览器中,建立renderer和main的通信桥梁, 与preload.ts中的注入对应
|
||||
* */
|
||||
{
|
||||
// WebUI / Tauri runtime: WebSocket transport for the legacy bridge hub.
|
||||
// (The former Electron `window.electronAPI` IPC branch was removed — there is
|
||||
// no Electron preload under Tauri/web, so this WebSocket path always runs.)
|
||||
// Web 环境 - 使用 WebSocket 通信,并在登录后自动补上已获取 Cookie 的连接
|
||||
// Web runtime bridge: ensure the socket reconnects after login so session cookie can be sent.
|
||||
// Path must be `/ws` — web-host's static-server only proxies WebSocket upgrades under /ws.
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const defaultHost = `${window.location.hostname}:${WEBUI_DEFAULT_PORT}`;
|
||||
const socketUrl = `${protocol}//${window.location.host || defaultHost}/ws`;
|
||||
|
||||
type QueuedMessage = { name: string; data: unknown };
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let emitterRef: { emit: (name: string, data: unknown) => void } | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let reconnectDelay = 500;
|
||||
let shouldReconnect = true; // Flag to control reconnection
|
||||
|
||||
const messageQueue: QueuedMessage[] = [];
|
||||
|
||||
// 1.发送队列中积压的消息,确保在重新建立连接后不会丢事件
|
||||
const flushQueue = () => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (messageQueue.length > 0) {
|
||||
const queued = messageQueue.shift();
|
||||
if (queued) {
|
||||
socket.send(JSON.stringify(queued));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2.简单的指数退避重连,等待服务端在登录成功后接受新连接
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimer !== null || !shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 8000);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
};
|
||||
|
||||
// 3.建立 WebSocket 连接(或复用已有的 OPEN/CONNECTING 状态)
|
||||
const connect = () => {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
socket = new WebSocket(socketUrl);
|
||||
} catch (error) {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the socket created in this call so the close handler only
|
||||
// nulls the outer reference when it still points at THIS socket.
|
||||
// Without this guard, a late-firing close event from the OLD socket
|
||||
// could wipe the reference to a NEWLY created replacement socket.
|
||||
const currentSocket = socket;
|
||||
|
||||
currentSocket.addEventListener('open', () => {
|
||||
reconnectDelay = 500;
|
||||
flushQueue();
|
||||
});
|
||||
|
||||
currentSocket.addEventListener('message', (event: MessageEvent) => {
|
||||
if (!emitterRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.data as string) as {
|
||||
name: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
// 处理服务端心跳 ping,立即回复 pong 以保持连接
|
||||
// Handle server heartbeat ping - respond with pong immediately to keep connection alive
|
||||
if (payload.name === 'ping') {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ name: 'pong', data: { timestamp: Date.now() } }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理认证过期 - 停止重连并跳转到登录页
|
||||
// Handle auth expiration - stop reconnecting and redirect to login
|
||||
if (payload.name === 'auth-expired') {
|
||||
console.warn('[WebSocket] Authentication expired, stopping reconnection');
|
||||
shouldReconnect = false;
|
||||
|
||||
// 清除所有待执行的重连定时器
|
||||
// Clear any pending reconnection timer
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
// 关闭 socket 并跳转到登录页
|
||||
// Close the socket and redirect to login page
|
||||
socket?.close();
|
||||
|
||||
// 已在登录页则不再重定向,防止无限刷新循环
|
||||
// Skip redirect if already on login page to prevent infinite reload loop
|
||||
if (window.location.pathname === '/login' || window.location.hash.includes('/login')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 短暂延迟后跳转到登录页,以便显示 UI 反馈
|
||||
// Redirect to login page after a short delay to show any UI feedback
|
||||
// Use hash navigation to stay within the SPA (HashRouter), avoiding a full
|
||||
// page reload that would land on an empty hash and cause a blank screen.
|
||||
setTimeout(() => {
|
||||
window.location.hash = '/login';
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
emitterRef.emit(payload.name, payload.data);
|
||||
} catch (error) {
|
||||
// 忽略格式错误的消息 / Ignore malformed payloads
|
||||
}
|
||||
});
|
||||
|
||||
currentSocket.addEventListener('close', (event: CloseEvent) => {
|
||||
// Only null the outer reference if it still points at this socket.
|
||||
if (socket === currentSocket) {
|
||||
socket = null;
|
||||
}
|
||||
|
||||
// Detect auth failure from close code (server sends 1008 for token issues).
|
||||
// This acts as a fallback in case the auth-expired message was not received
|
||||
// (e.g., socket not yet ready for sending during initial handshake).
|
||||
if (event.code === 1008 && !shouldReconnect) {
|
||||
return; // Already handled by auth-expired message handler
|
||||
}
|
||||
if (event.code === 1008) {
|
||||
console.warn('[WebSocket] Connection rejected by server (policy violation), redirecting to login');
|
||||
shouldReconnect = false;
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
// 已在登录页则不再重定向,防止无限刷新循环
|
||||
// Skip redirect if already on login page to prevent infinite reload loop
|
||||
if (window.location.pathname === '/login' || window.location.hash.includes('/login')) {
|
||||
return;
|
||||
}
|
||||
// Use hash navigation to stay within the SPA (HashRouter)
|
||||
setTimeout(() => {
|
||||
window.location.hash = '/login';
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
currentSocket.addEventListener('error', () => {
|
||||
currentSocket.close();
|
||||
});
|
||||
};
|
||||
|
||||
// 4.确保在发送/订阅前已经发起连接
|
||||
const ensureSocket = () => {
|
||||
if (!socket || socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
|
||||
bridge.adapter({
|
||||
emit(name, data) {
|
||||
const message: QueuedMessage = { name, data };
|
||||
|
||||
ensureSocket();
|
||||
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
socket.send(JSON.stringify(message));
|
||||
return;
|
||||
} catch (error) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
messageQueue.push(message);
|
||||
},
|
||||
on(emitter) {
|
||||
emitterRef = emitter;
|
||||
win.__bridgeEmitter = emitter;
|
||||
|
||||
// Expose callback emitter for bridge provider pattern
|
||||
// Used by components to send responses back through WebSocket
|
||||
win.__emitBridgeCallback = (name: string, data: unknown) => {
|
||||
emitter.emit(name, data);
|
||||
};
|
||||
|
||||
ensureSocket();
|
||||
},
|
||||
});
|
||||
|
||||
connect();
|
||||
|
||||
// Expose reconnection control for login flow
|
||||
win.__websocketReconnect = () => {
|
||||
shouldReconnect = true;
|
||||
reconnectDelay = 500;
|
||||
connect();
|
||||
};
|
||||
}
|
||||
|
||||
logger.provider({
|
||||
log(log) {
|
||||
console.log('process.log', log.type, ...log.logs);
|
||||
},
|
||||
path() {
|
||||
return Promise.resolve('');
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export const ADAPTER_BRIDGE_EVENT_KEY = 'nomifun-bridge-adapter';
|
||||
|
||||
/**
|
||||
* File/Directory selection events
|
||||
* 用于 WebUI 模式下的文件选择请求
|
||||
*/
|
||||
export const SHOW_OPEN_REQUEST_EVENT = 'show-open-request';
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { CompareResult, FileChangeInfo, FileChangeOperation } from '@/common/types/platform/fileSnapshot';
|
||||
|
||||
export type RawFileChange = {
|
||||
file_path: string;
|
||||
relative_path: string;
|
||||
operation: FileChangeOperation;
|
||||
};
|
||||
|
||||
export type RawCompareResult = {
|
||||
staged: RawFileChange[];
|
||||
unstaged: RawFileChange[];
|
||||
};
|
||||
|
||||
// Backend serializes `relative_path` (snake_case); the frontend type uses
|
||||
// `relativePath` (camelCase). Without this mapping, downstream code reads
|
||||
// `change.relativePath` as undefined and POSTs `{ workspace, file_path: undefined }`
|
||||
// to /api/fs/snapshot/baseline, producing a 400 "missing field `file_path`".
|
||||
function mapFileChange(c: RawFileChange): FileChangeInfo {
|
||||
return {
|
||||
file_path: c.file_path,
|
||||
relativePath: c.relative_path,
|
||||
operation: c.operation,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromBackendCompareResult(raw: RawCompareResult): CompareResult {
|
||||
return {
|
||||
staged: (raw?.staged ?? []).map(mapFileChange),
|
||||
unstaged: (raw?.unstaged ?? []).map(mapFileChange),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* HTTP/WS bridge factory — drop-in replacement for bridge.buildProvider / bridge.buildEmitter
|
||||
* that routes calls to nomicore via REST API and WebSocket.
|
||||
*
|
||||
* Exported helpers produce objects with the same shape as @/platform bridge,
|
||||
* so existing renderer code works without changes.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base URL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__backendPort?: number;
|
||||
/**
|
||||
* Per-boot local-trust secret injected by the Tauri desktop shell
|
||||
* (`apps/desktop/src/main.rs`). The renderer presents it on every request so
|
||||
* the desktop's own webview is trusted with no login while remote LAN
|
||||
* browsers must authenticate. Absent in WebUI browser mode.
|
||||
*/
|
||||
__nomiLocalTrust?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-log gating. PTY output and streaming responses arrive as a high-frequency
|
||||
* flood of WebSocket messages / HTTP calls; logging each one drowns the console
|
||||
* when a `claude` terminal runs. Default OFF. Opt in at runtime with
|
||||
* `localStorage.setItem('debug:ws', '1')` (or `'debug:http'`).
|
||||
*/
|
||||
const isDebugEnabled = (key: 'debug:ws' | 'debug:http'): boolean => {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined' && localStorage.getItem(key) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Event names that fire per PTY chunk / per stream token — never auto-logged. */
|
||||
const NOISY_WS_EVENTS = new Set(['terminal.output', 'message.stream', 'conversation.artifact']);
|
||||
|
||||
/** Path fragments that fire per keystroke / per chunk — never auto-logged. */
|
||||
const NOISY_HTTP_FRAGMENTS = ['/input', '/resize'];
|
||||
|
||||
/** CSRF double-submit cookie + header names (must match the backend constants). */
|
||||
const CSRF_COOKIE_NAME = 'nomifun-csrf-token';
|
||||
const CSRF_HEADER_NAME = 'x-csrf-token';
|
||||
|
||||
/** Local-trust header the desktop webview presents (must match `nomifun_auth::LOCAL_TRUST_HEADER`). */
|
||||
const LOCAL_TRUST_HEADER = 'x-nomi-local-trust';
|
||||
|
||||
/**
|
||||
* The per-boot local-trust secret injected by the Tauri desktop shell, or null
|
||||
* in WebUI browser mode (where auth is via login/JWT cookie instead).
|
||||
*/
|
||||
function getLocalTrustSecret(): string | null {
|
||||
if (typeof window !== 'undefined' && (window as Window).__nomiLocalTrust) {
|
||||
return (window as Window).__nomiLocalTrust as string;
|
||||
}
|
||||
const g = globalThis as typeof globalThis & { __nomiLocalTrust?: string };
|
||||
return g.__nomiLocalTrust ?? null;
|
||||
}
|
||||
|
||||
/** HTTP methods the backend CSRF middleware guards (state-changing). */
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
/** Read a non-HttpOnly cookie value from `document.cookie`, or null if absent. */
|
||||
function readCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const prefix = `${name}=`;
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return decodeURIComponent(trimmed.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the backend port, honoring both renderer and main-process contexts.
|
||||
*
|
||||
* - Renderer (Electron): the preload bridge writes `window.__backendPort` before
|
||||
* the first HTTP call, so reading from window is authoritative.
|
||||
* - Renderer (WebUI browser): no preload, so `window.__backendPort` is missing.
|
||||
* Requests must go to the same origin that served the page; web-host's
|
||||
* static-server reverse-proxies `/api/*` and upgrades `/ws` to the backend
|
||||
* port. See getBaseUrl / getWsUrl below for the WebUI branch.
|
||||
* - Main process: `window` is undefined. `src/index.ts` writes the port to
|
||||
* `globalThis.__backendPort` immediately after `backendManager.start()`
|
||||
* resolves, so any main-process ipcBridge caller (e.g. the one-shot
|
||||
* assistant migration hook) hits the correct port.
|
||||
* - Fallback `13400` only applies when neither is initialized — the request
|
||||
* will still fail cleanly with ECONNREFUSED rather than masking the bug.
|
||||
*/
|
||||
function getBackendPort(): number {
|
||||
if (typeof window !== 'undefined' && (window as Window).__backendPort) {
|
||||
return (window as Window).__backendPort as number;
|
||||
}
|
||||
const g = globalThis as typeof globalThis & { __backendPort?: number };
|
||||
return g.__backendPort ?? 13400;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebUI (browser) mode: no Electron preload, so `window.__backendPort` is not
|
||||
* injected. Use same-origin URLs; web-host's static-server handles the reverse
|
||||
* proxy / WS upgrade to the backend.
|
||||
*/
|
||||
function isWebUiBrowserMode(): boolean {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined' && !(window as Window).__backendPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the auth/CSRF headers every backend request must carry.
|
||||
*
|
||||
* Single source of truth shared by `httpRequest` (fetch/JSON) and the multipart
|
||||
* upload `XMLHttpRequest` in `FileService`. The desktop shell's `fetch`
|
||||
* interceptor (`apps/desktop/src/main.rs`) only patches `window.fetch`, so a raw
|
||||
* XHR escapes it — without applying these headers itself the upload reaches the
|
||||
* `TrustLocalToken`-guarded `/api/fs/upload` with no `x-nomi-local-trust` and is
|
||||
* rejected 403. In WebUI browser mode the same XHR also needs the CSRF header on
|
||||
* state-changing requests.
|
||||
*
|
||||
* @param method HTTP method — decides whether the CSRF (mutating) header applies.
|
||||
*/
|
||||
export function buildBackendAuthHeaders(method: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Desktop shell: present the per-boot local-trust secret so the backend
|
||||
// (running under TrustLocalToken) recognizes this webview as the trusted
|
||||
// local client and skips login. Absent in WebUI browser mode.
|
||||
const trustSecret = getLocalTrustSecret();
|
||||
if (trustSecret) {
|
||||
headers[LOCAL_TRUST_HEADER] = trustSecret;
|
||||
}
|
||||
|
||||
// In WebUI browser mode the backend runs authenticated, which enables the
|
||||
// CSRF double-submit guard. Echo the (non-HttpOnly) csrf cookie into the
|
||||
// x-csrf-token header on state-changing requests. In desktop (Tauri) mode the
|
||||
// backend runs local/no-CSRF and the cookie is absent, so this is a no-op.
|
||||
if (isWebUiBrowserMode() && MUTATING_METHODS.has(method.toUpperCase())) {
|
||||
const csrf = readCookie(CSRF_COOKIE_NAME);
|
||||
if (csrf) {
|
||||
headers[CSRF_HEADER_NAME] = csrf;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function getBaseUrl(): string {
|
||||
if (isWebUiBrowserMode()) {
|
||||
// Same-origin: calls like fetch(`${baseUrl}/api/foo`) resolve to `/api/foo`
|
||||
// on whatever host the page was served from.
|
||||
return '';
|
||||
}
|
||||
return `http://127.0.0.1:${getBackendPort()}`;
|
||||
}
|
||||
|
||||
function getWsUrl(): string {
|
||||
if (isWebUiBrowserMode()) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.host}/ws`;
|
||||
}
|
||||
return `ws://127.0.0.1:${getBackendPort()}/ws`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured backend error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Error thrown by `httpRequest` when the backend returns a non-2xx response.
|
||||
* Carries the structured error envelope (`success: false, error, code`) so
|
||||
* callers can branch on `code` without parsing the stringified message.
|
||||
*
|
||||
* @example
|
||||
* try { await ipcBridge.conversation.sendMessage.invoke(...); }
|
||||
* catch (e) {
|
||||
* if (isBackendHttpError(e) && e.code === 'CONVERSATION_ARCHIVED') { ... }
|
||||
* }
|
||||
*/
|
||||
export class BackendHttpError extends Error {
|
||||
readonly status: number;
|
||||
/** Machine-readable error code from the backend `ErrorResponse.code`, or `''` when parse failed. */
|
||||
readonly code: string;
|
||||
/** Backend-provided human message from `ErrorResponse.error`, or the raw body when parse failed. */
|
||||
readonly backendMessage: string;
|
||||
/** Structured backend metadata from `ErrorResponse.details`, when present. */
|
||||
readonly details: unknown;
|
||||
/** Raw parsed body (object on JSON response, string on text/non-JSON). */
|
||||
readonly body: unknown;
|
||||
|
||||
constructor(params: { method: string; path: string; status: number; body: unknown }) {
|
||||
const { method, path, status, body } = params;
|
||||
let code = '';
|
||||
let backendMessage = '';
|
||||
let details: unknown;
|
||||
if (body && typeof body === 'object') {
|
||||
const b = body as { code?: unknown; error?: unknown; details?: unknown };
|
||||
if (typeof b.code === 'string') code = b.code;
|
||||
if (typeof b.error === 'string') backendMessage = b.error;
|
||||
details = b.details;
|
||||
} else if (typeof body === 'string') {
|
||||
backendMessage = body;
|
||||
}
|
||||
super(`Backend ${method} ${path} failed (${status}): ${JSON.stringify(body)}`);
|
||||
this.name = 'BackendHttpError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.backendMessage = backendMessage;
|
||||
this.details = details;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackendHttpError(error: unknown): error is BackendHttpError {
|
||||
// Prefer instanceof — fast path in production/bundled contexts.
|
||||
if (error instanceof BackendHttpError) return true;
|
||||
// Fallback: vite-dev HMR can split the module across chunks, breaking
|
||||
// instanceof. Detect by duck-typing on the shape produced by our
|
||||
// constructor.
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'name' in error &&
|
||||
(error as { name: unknown }).name === 'BackendHttpError' &&
|
||||
'status' in error &&
|
||||
typeof (error as { status: unknown }).status === 'number' &&
|
||||
'code' in error &&
|
||||
typeof (error as { code: unknown }).code === 'string'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP request helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-request overrides for `httpRequest`.
|
||||
*
|
||||
* `silentStatuses` lets known-soft failures (e.g. `GET /:id/model` returning
|
||||
* 404 before the agent has attached) skip the noisy `console.error` and the
|
||||
* Sentry breadcrumb that comes with it. The error is still thrown so the
|
||||
* caller's existing try/catch keeps working.
|
||||
*/
|
||||
export type HttpRequestOptions = {
|
||||
silentStatuses?: number[];
|
||||
};
|
||||
|
||||
const SENSITIVE_LOG_KEY_PATTERN = /api[_-]?key|authorization|auth[_-]?token|access[_-]?token|refresh[_-]?token|secret/i;
|
||||
|
||||
function redactForLog(value: unknown, depth = 0): unknown {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactForLog(item, depth + 1));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
||||
key,
|
||||
SENSITIVE_LOG_KEY_PATTERN.test(key) ? '[REDACTED]' : redactForLog(entry, depth + 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function httpRequest<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: HttpRequestOptions
|
||||
): Promise<T> {
|
||||
const url = `${getBaseUrl()}${path}`;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Trust (desktop) + CSRF (WebUI) headers — shared with the FileService upload XHR.
|
||||
Object.assign(headers, buildBackendAuthHeaders(method));
|
||||
|
||||
const isNoisyPath = NOISY_HTTP_FRAGMENTS.some((frag) => path.includes(frag));
|
||||
if (isDebugEnabled('debug:http') && !isNoisyPath) {
|
||||
console.debug(
|
||||
`[httpBridge] ${method} ${path}`,
|
||||
body !== undefined ? JSON.stringify(redactForLog(body)).slice(0, 500) : '(no body)'
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Read the body exactly once. A `Response` body is a one-shot stream, so
|
||||
// calling `.json()` then `.text()` (e.g. as a parse fallback) throws
|
||||
// "body stream already read". Many error responses have an empty or
|
||||
// non-JSON body (axum-default 404/405, plain-text 5xx), so read text first
|
||||
// and opportunistically parse it as JSON.
|
||||
const rawText = await response.text();
|
||||
let errorBody: unknown;
|
||||
try {
|
||||
errorBody = rawText ? JSON.parse(rawText) : '';
|
||||
} catch {
|
||||
errorBody = rawText;
|
||||
}
|
||||
if (options?.silentStatuses?.includes(response.status)) {
|
||||
console.debug(`[httpBridge] ${method} ${path} → ${response.status} (silenced)`, errorBody);
|
||||
} else {
|
||||
console.error(`[httpBridge] ${method} ${path} → ${response.status}`, errorBody);
|
||||
}
|
||||
throw new BackendHttpError({ method, path, status: response.status, body: errorBody });
|
||||
}
|
||||
|
||||
if (isDebugEnabled('debug:http') && !isNoisyPath) {
|
||||
console.debug(`[httpBridge] ${method} ${path} → ${response.status} OK`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (!contentType?.includes('application/json')) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
// Backend wraps in { success, data, ... } — unwrap when present
|
||||
if (json && typeof json === 'object' && 'data' in json) {
|
||||
return json.data as T;
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider factories (same shape as bridge.buildProvider)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ProviderLike<Data, Params> = {
|
||||
provider: (handler: (params: Params) => Promise<Data>) => void;
|
||||
invoke: Params extends undefined ? () => Promise<Data> : (params: Params) => Promise<Data>;
|
||||
};
|
||||
|
||||
export function withResponseMap<Raw, Mapped, Params>(
|
||||
inner: ProviderLike<Raw, Params>,
|
||||
map: (data: Raw) => Mapped
|
||||
): ProviderLike<Mapped, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: (async (params?: Params) => {
|
||||
const raw = await (inner.invoke as (p?: Params) => Promise<Raw>)(params);
|
||||
return map(raw);
|
||||
}) as ProviderLike<Mapped, Params>['invoke'],
|
||||
};
|
||||
}
|
||||
|
||||
export function httpGet<Data, Params = undefined>(
|
||||
path: string | ((params: Params) => string),
|
||||
options?: HttpRequestOptions
|
||||
): ProviderLike<Data, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: (async (params?: Params) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(params!) : path;
|
||||
return httpRequest<Data>('GET', resolvedPath, undefined, options);
|
||||
}) as ProviderLike<Data, Params>['invoke'],
|
||||
};
|
||||
}
|
||||
|
||||
export function httpPost<Data, Params = undefined>(
|
||||
path: string | ((params: Params) => string),
|
||||
mapBody?: (params: Params) => unknown
|
||||
): ProviderLike<Data, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: (async (params?: Params) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(params!) : path;
|
||||
const body = mapBody ? mapBody(params!) : params;
|
||||
return httpRequest<Data>('POST', resolvedPath, body);
|
||||
}) as ProviderLike<Data, Params>['invoke'],
|
||||
};
|
||||
}
|
||||
|
||||
export function httpPut<Data, Params = undefined>(
|
||||
path: string | ((params: Params) => string),
|
||||
mapBody?: (params: Params) => unknown
|
||||
): ProviderLike<Data, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: (async (params?: Params) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(params!) : path;
|
||||
const body = mapBody ? mapBody(params!) : params;
|
||||
return httpRequest<Data>('PUT', resolvedPath, body);
|
||||
}) as ProviderLike<Data, Params>['invoke'],
|
||||
};
|
||||
}
|
||||
|
||||
export function httpPatch<Data, Params = undefined>(
|
||||
path: string | ((params: Params) => string),
|
||||
mapBody?: (params: Params) => unknown
|
||||
): ProviderLike<Data, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: (async (params?: Params) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(params!) : path;
|
||||
const body = mapBody ? mapBody(params!) : params;
|
||||
return httpRequest<Data>('PATCH', resolvedPath, body);
|
||||
}) as ProviderLike<Data, Params>['invoke'],
|
||||
};
|
||||
}
|
||||
|
||||
export function httpDelete<Data, Params = undefined>(
|
||||
path: string | ((params: Params) => string)
|
||||
): ProviderLike<Data, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: (async (params?: Params) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(params!) : path;
|
||||
return httpRequest<Data>('DELETE', resolvedPath);
|
||||
}) as ProviderLike<Data, Params>['invoke'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub provider for features not yet implemented in the backend.
|
||||
* Returns a sensible default value and logs a warning.
|
||||
*/
|
||||
export function stubProvider<Data, Params = undefined>(name: string, defaultValue: Data): ProviderLike<Data, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: (async (_params?: Params) => {
|
||||
console.warn(`[httpBridge] stub: ${name} not yet implemented in backend`);
|
||||
return defaultValue;
|
||||
}) as ProviderLike<Data, Params>['invoke'],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WsCallback = (data: unknown) => void;
|
||||
const wsListeners = new Map<string, Set<WsCallback>>();
|
||||
let ws: WebSocket | null = null;
|
||||
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let wsReconnectAttempt = 0;
|
||||
|
||||
function ensureWs(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
console.debug('[ensureWs] skipped: no window');
|
||||
return;
|
||||
}
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
console.debug('[ensureWs] skipped: already open/connecting, readyState=', ws.readyState);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getWsUrl();
|
||||
console.debug('[ensureWs] connecting to', url);
|
||||
try {
|
||||
// Desktop shell: carry the local-trust secret as a WebSocket subprotocol
|
||||
// (browsers cannot set custom headers on the WS handshake). The backend
|
||||
// reads it from `Sec-WebSocket-Protocol` and echoes it back so the
|
||||
// handshake succeeds. WebUI browser mode authenticates via the session
|
||||
// cookie instead, so no subprotocol is sent.
|
||||
const trustSecret = getLocalTrustSecret();
|
||||
ws = trustSecret ? new WebSocket(url, [trustSecret]) : new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.error('[ensureWs] WebSocket constructor threw:', e);
|
||||
scheduleWsReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const current = ws;
|
||||
|
||||
current.addEventListener('open', () => {
|
||||
console.debug('[ensureWs] CONNECTED');
|
||||
// A non-zero attempt counter means we got here by reconnecting (the socket
|
||||
// had dropped and `scheduleWsReconnect` ran). Notify local listeners so a
|
||||
// live view can resync: the server only does a live fan-out with no replay,
|
||||
// so every frame emitted while the socket was down was lost. Dispatch BEFORE
|
||||
// resetting the counter. `ws.reconnected` is a synthetic local event name,
|
||||
// never sent by the server.
|
||||
const wasReconnect = wsReconnectAttempt > 0;
|
||||
wsReconnectAttempt = 0;
|
||||
if (wasReconnect) {
|
||||
const handlers = wsListeners.get('ws.reconnected');
|
||||
if (handlers) {
|
||||
for (const h of [...handlers]) {
|
||||
try {
|
||||
h(undefined);
|
||||
} catch {
|
||||
/* never crash listener */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
current.addEventListener('close', (e) => {
|
||||
console.debug('[ensureWs] CLOSED code=' + e.code + ' reason=' + e.reason);
|
||||
if (ws === current) ws = null;
|
||||
scheduleWsReconnect();
|
||||
});
|
||||
|
||||
current.addEventListener('error', (e) => {
|
||||
console.error('[ensureWs] ERROR', e);
|
||||
current.close();
|
||||
});
|
||||
|
||||
current.addEventListener('message', (event: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string) as {
|
||||
name?: string;
|
||||
event?: string;
|
||||
data?: unknown;
|
||||
payload?: unknown;
|
||||
};
|
||||
const eventName = msg.name ?? msg.event;
|
||||
const payload = msg.data ?? msg.payload;
|
||||
if (isDebugEnabled('debug:ws') && eventName && !NOISY_WS_EVENTS.has(eventName)) {
|
||||
console.debug('[WS:msg]', eventName, JSON.stringify(payload).slice(0, 200));
|
||||
}
|
||||
if (eventName) {
|
||||
const handlers = wsListeners.get(eventName);
|
||||
if (handlers) {
|
||||
for (const h of handlers) {
|
||||
try {
|
||||
h(payload);
|
||||
} catch {
|
||||
/* never crash listener */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleWsReconnect(): void {
|
||||
if (wsReconnectTimer) return;
|
||||
const delay = Math.min(1000 * Math.pow(2, wsReconnectAttempt), 30000);
|
||||
wsReconnectAttempt++;
|
||||
wsReconnectTimer = setTimeout(() => {
|
||||
wsReconnectTimer = null;
|
||||
ensureWs();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Emitter factory (same shape as bridge.buildEmitter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EmitterLike<Params> = {
|
||||
on: (callback: Params extends undefined ? () => void : (params: Params) => void) => () => void;
|
||||
emit: Params extends undefined ? () => void : (params: Params) => void;
|
||||
};
|
||||
|
||||
export function wsEmitter<Params = undefined>(eventName: string): EmitterLike<Params> {
|
||||
return {
|
||||
on: (callback: (params: Params) => void) => {
|
||||
ensureWs();
|
||||
if (!wsListeners.has(eventName)) {
|
||||
wsListeners.set(eventName, new Set());
|
||||
}
|
||||
const cb = callback as WsCallback;
|
||||
wsListeners.get(eventName)!.add(cb);
|
||||
return () => {
|
||||
wsListeners.get(eventName)?.delete(cb);
|
||||
};
|
||||
},
|
||||
emit: (() => {}) as EmitterLike<Params>['emit'],
|
||||
};
|
||||
}
|
||||
|
||||
export function wsMappedEmitter<Params = undefined>(
|
||||
eventName: string,
|
||||
transform: (raw: unknown) => Params
|
||||
): EmitterLike<Params> {
|
||||
const inner = wsEmitter<unknown>(eventName);
|
||||
return {
|
||||
on: (callback: (params: Params) => void) => {
|
||||
return inner.on((raw) => {
|
||||
callback(transform(raw));
|
||||
});
|
||||
},
|
||||
emit: (() => {}) as EmitterLike<Params>['emit'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub emitter for events not yet implemented in the backend.
|
||||
*/
|
||||
export function stubEmitter<Params = undefined>(_name: string): EmitterLike<Params> {
|
||||
return {
|
||||
on: () => () => {},
|
||||
emit: (() => {}) as EmitterLike<Params>['emit'],
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared WebSocket broadcaster registry and bridge emitter reference.
|
||||
* No Electron imports — safe to use in both Electron main process and WebUI mode.
|
||||
*/
|
||||
|
||||
type WebSocketBroadcastFn = (name: string, data: unknown) => void;
|
||||
|
||||
const webSocketBroadcasters: WebSocketBroadcastFn[] = [];
|
||||
|
||||
let bridgeEmitter: { emit: (name: string, data: unknown) => unknown } | null = null;
|
||||
|
||||
/**
|
||||
* Register a WebSocket broadcast function.
|
||||
* Returns an unregister function.
|
||||
*/
|
||||
export function registerWebSocketBroadcaster(fn: WebSocketBroadcastFn): () => void {
|
||||
webSocketBroadcasters.push(fn);
|
||||
return () => {
|
||||
const idx = webSocketBroadcasters.indexOf(fn);
|
||||
if (idx > -1) webSocketBroadcasters.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a message to all registered WebSocket clients.
|
||||
*/
|
||||
export function broadcastToAll(name: string, data: unknown): void {
|
||||
for (const broadcast of webSocketBroadcasters) {
|
||||
try {
|
||||
broadcast(name, data);
|
||||
} catch (error) {
|
||||
console.error('[registry] WebSocket broadcast error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getBridgeEmitter(): typeof bridgeEmitter {
|
||||
return bridgeEmitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bridge emitter reference (called by adapter implementations).
|
||||
*/
|
||||
export function setBridgeEmitter(emitter: typeof bridgeEmitter): void {
|
||||
bridgeEmitter = emitter;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { TMessage } from '../chat/chatLib';
|
||||
import type { TChatConversation } from '../config/storage';
|
||||
import type { IMessageSearchItem } from '../types/team/database';
|
||||
import type { PaginatedResult } from './ipcBridge';
|
||||
import { fromApiConversation } from './apiModelMapper';
|
||||
|
||||
export interface ApiMessageSearchItem {
|
||||
message_id: string;
|
||||
message_type: string;
|
||||
message_created_at: number;
|
||||
preview_text: string;
|
||||
conversation: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
model?: { provider_id: string; model: string; use_model?: string } | null;
|
||||
status: string;
|
||||
source?: string | null;
|
||||
pinned: boolean;
|
||||
pinned_at?: number | null;
|
||||
channel_chat_id?: string | null;
|
||||
created_at: number;
|
||||
modified_at: number;
|
||||
extra: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export function fromApiSearchResult(
|
||||
result: PaginatedResult<ApiMessageSearchItem>
|
||||
): PaginatedResult<IMessageSearchItem> {
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map(fromApiSearchItem),
|
||||
};
|
||||
}
|
||||
|
||||
function fromApiSearchItem(item: ApiMessageSearchItem): IMessageSearchItem {
|
||||
return {
|
||||
conversation: fromApiConversation({
|
||||
...item.conversation,
|
||||
model: item.conversation.model ?? undefined,
|
||||
}) as unknown as TChatConversation,
|
||||
message_id: item.message_id,
|
||||
message_type: item.message_type as TMessage['type'],
|
||||
message_created_at: item.message_created_at,
|
||||
preview_text: item.preview_text,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tauri desktop-shell adapter — the Tauri-native replacement for the former
|
||||
* Electron `bridge.buildProvider/buildEmitter` IPC channels that ipcBridge.ts
|
||||
* used for OS-shell operations.
|
||||
*
|
||||
* Every operation is implemented with a Tauri v2 JS API (a plugin or
|
||||
* `@tauri-apps/api`) and is GUARDED by `isTauri()`:
|
||||
* - In the Tauri desktop shell → the real Tauri call runs.
|
||||
* - In the WebUI browser → providers return a web-safe fallback and
|
||||
* emitters are inert (no transport, no throw).
|
||||
*
|
||||
* Operations with no Tauri equivalent (Chrome DevTools Protocol, GPU-process
|
||||
* recovery, devtools open/close, renderer-log piping, WebUI-server lifecycle,
|
||||
* close-to-tray window behavior) are intentionally DEGRADED to safe stubs here
|
||||
* — marked `DEGRADE_STUB`. They no longer depend on the deleted `@/platform`
|
||||
* bridge. Each carries a TODO if a real Tauri port is wanted later.
|
||||
*
|
||||
* Tauri modules are loaded via dynamic `import()` inside the guarded branch so
|
||||
* the WebUI browser bundle never evaluates Tauri IPC code.
|
||||
*/
|
||||
|
||||
// Tauri-runtime detection. Tauri v2 injects `window.isTauri`; the IPC layer also
|
||||
// always sets `window.__TAURI_INTERNALS__`. Check BOTH so detection is robust
|
||||
// regardless of `withGlobalTauri` config — a single missing signal must not make
|
||||
// every shell op silently no-op (which is what broke the window controls).
|
||||
const isTauri = (): boolean =>
|
||||
typeof window !== 'undefined' &&
|
||||
(Boolean((window as { isTauri?: boolean }).isTauri) || '__TAURI_INTERNALS__' in window);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel shapes — mirror the bridge.buildProvider / bridge.buildEmitter API
|
||||
// that existing ipcBridge consumers depend on.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ShellProvider<Data, Params> {
|
||||
/** No-op on the renderer side; kept for API compatibility with bridge.buildProvider. */
|
||||
provider: () => void;
|
||||
invoke: (params: Params) => Promise<Data>;
|
||||
}
|
||||
|
||||
export interface ShellEmitter<Params> {
|
||||
on: (callback: (params: Params) => void) => () => void;
|
||||
emit: (params: Params) => void;
|
||||
}
|
||||
|
||||
/** A provider backed by a Tauri call, with a web-safe fallback for the browser. */
|
||||
export function shellProvider<Data, Params = void>(
|
||||
handler: (params: Params) => Promise<Data>,
|
||||
webFallback: Data | (() => Data | Promise<Data>)
|
||||
): ShellProvider<Data, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: async (params: Params): Promise<Data> => {
|
||||
if (isTauri()) return handler(params);
|
||||
return typeof webFallback === 'function' ? (webFallback as () => Data | Promise<Data>)() : webFallback;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** DEGRADE_STUB provider: returns a constant value in every runtime (no Tauri equivalent). */
|
||||
export function stubShellProvider<Data, Params = void>(value: Data | (() => Data)): ShellProvider<Data, Params> {
|
||||
return {
|
||||
provider: () => {},
|
||||
invoke: async (): Promise<Data> => (typeof value === 'function' ? (value as () => Data)() : value),
|
||||
};
|
||||
}
|
||||
|
||||
/** An emitter backed by a Tauri event subscription, inert in the browser. */
|
||||
export function shellEmitter<Params = void>(
|
||||
subscribe: (callback: (params: Params) => void) => Promise<() => void>
|
||||
): ShellEmitter<Params> {
|
||||
return {
|
||||
on: (callback: (params: Params) => void): (() => void) => {
|
||||
if (!isTauri()) return () => {};
|
||||
let unlisten: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
void subscribe(callback)
|
||||
.then((un) => {
|
||||
if (disposed) un();
|
||||
else unlisten = un;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
},
|
||||
emit: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
/** DEGRADE_STUB emitter: never fires (no Tauri source for this signal). */
|
||||
export function noopEmitter<Params = void>(): ShellEmitter<Params> {
|
||||
return {
|
||||
on: () => () => {},
|
||||
emit: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operations (Tauri v2 JS APIs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Restart the desktop shell (tauri-plugin-process). */
|
||||
export async function tauriRelaunch(): Promise<void> {
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
await relaunch();
|
||||
}
|
||||
|
||||
/** OS directory paths (@tauri-apps/api/path). */
|
||||
export async function tauriGetPath(name: 'desktop' | 'home' | 'downloads'): Promise<string> {
|
||||
const path = await import('@tauri-apps/api/path');
|
||||
if (name === 'home') return path.homeDir();
|
||||
if (name === 'downloads') return path.downloadDir();
|
||||
return path.desktopDir();
|
||||
}
|
||||
|
||||
// Tauri exposes no zoom *getter*; remember the last value set this session.
|
||||
let lastZoomFactor = 1;
|
||||
export async function tauriSetZoom(factor: number): Promise<number> {
|
||||
const { getCurrentWebview } = await import('@tauri-apps/api/webview');
|
||||
await getCurrentWebview().setZoom(factor);
|
||||
lastZoomFactor = factor;
|
||||
return factor;
|
||||
}
|
||||
export function tauriGetZoom(): number {
|
||||
return lastZoomFactor;
|
||||
}
|
||||
|
||||
/** 开/关 OS 级保持唤醒(防系统休眠),走桌面 Tauri command;非桌面环境会抛错,由上层吞掉。
|
||||
* Apply/clear the OS-level keep-awake (sleep inhibitor) via the desktop command. */
|
||||
export async function tauriSetKeepAwake(enabled: boolean): Promise<void> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('set_keep_awake', { enabled });
|
||||
}
|
||||
|
||||
/** 本地化原生系统托盘菜单(「显示」「退出」)。Rust 侧无法解析 i18n,创建时用英文兜底,
|
||||
* 渲染层在挂载/切换语言时调用此命令传入译文。非桌面环境会抛错,由上层吞掉。
|
||||
* Localize the native system-tray menu labels (Show / Quit) via the desktop command. */
|
||||
export async function tauriSetTrayLabels(show: string, quit: string): Promise<void> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('set_tray_labels', { show, quit });
|
||||
}
|
||||
|
||||
/** Electron-style OpenDialog options accepted by call sites. */
|
||||
export interface ShellOpenDialogOptions {
|
||||
properties?: Array<'openFile' | 'openDirectory' | 'multiSelections' | 'createDirectory' | 'showHiddenFiles'>;
|
||||
filters?: Array<{ name: string; extensions: string[] }>;
|
||||
defaultPath?: string;
|
||||
}
|
||||
|
||||
/** Native open file/folder dialog (tauri-plugin-dialog), normalized to string[] | undefined. */
|
||||
export async function tauriOpenDialog(options?: ShellOpenDialogOptions): Promise<string[] | undefined> {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const props = options?.properties ?? [];
|
||||
const result = await open({
|
||||
directory: props.includes('openDirectory'),
|
||||
multiple: props.includes('multiSelections'),
|
||||
defaultPath: options?.defaultPath,
|
||||
filters: options?.filters,
|
||||
});
|
||||
if (result == null) return undefined;
|
||||
return Array.isArray(result) ? result : [result];
|
||||
}
|
||||
|
||||
/** OS auto-launch (tauri-plugin-autostart). */
|
||||
export async function tauriIsAutostartEnabled(): Promise<boolean> {
|
||||
const { isEnabled } = await import('@tauri-apps/plugin-autostart');
|
||||
return isEnabled();
|
||||
}
|
||||
export async function tauriSetAutostart(enabled: boolean): Promise<void> {
|
||||
const mod = await import('@tauri-apps/plugin-autostart');
|
||||
if (enabled) await mod.enable();
|
||||
else await mod.disable();
|
||||
}
|
||||
|
||||
/** Native OS notification (tauri-plugin-notification). */
|
||||
export async function tauriSendNotification(opts: { title: string; body: string; icon?: string }): Promise<void> {
|
||||
const mod = await import('@tauri-apps/plugin-notification');
|
||||
let granted = await mod.isPermissionGranted();
|
||||
if (!granted) granted = (await mod.requestPermission()) === 'granted';
|
||||
if (granted) mod.sendNotification({ title: opts.title, body: opts.body, icon: opts.icon });
|
||||
}
|
||||
|
||||
function parseDeepLink(url: string): { action: string; params: Record<string, string> } {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const action = u.hostname || u.pathname.replace(/^\/+/, '');
|
||||
const params: Record<string, string> = {};
|
||||
u.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
return { action, params };
|
||||
} catch {
|
||||
return { action: '', params: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to `nomifun://` deep links. The Rust shell (apps/desktop/src/main.rs)
|
||||
* forwards opened URLs on the Tauri event `deep-link://received` as a string[].
|
||||
*/
|
||||
export async function subscribeDeepLink(
|
||||
callback: (payload: { action: string; params: Record<string, string> }) => void
|
||||
): Promise<() => void> {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
return listen<string[]>('deep-link://received', (event) => {
|
||||
for (const url of event.payload ?? []) callback(parseDeepLink(url));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- window controls (@tauri-apps/api/window) ----
|
||||
|
||||
export async function tauriWindowMinimize(): Promise<void> {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().minimize();
|
||||
}
|
||||
export async function tauriWindowMaximize(): Promise<void> {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().maximize();
|
||||
}
|
||||
export async function tauriWindowUnmaximize(): Promise<void> {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().unmaximize();
|
||||
}
|
||||
export async function tauriWindowToggleMaximize(): Promise<void> {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().toggleMaximize();
|
||||
}
|
||||
export async function tauriWindowClose(): Promise<void> {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
export async function tauriWindowIsMaximized(): Promise<boolean> {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
return getCurrentWindow().isMaximized();
|
||||
}
|
||||
export async function subscribeWindowMaximized(
|
||||
callback: (payload: { is_maximized: boolean }) => void
|
||||
): Promise<() => void> {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const win = getCurrentWindow();
|
||||
return win.onResized(() => {
|
||||
void win.isMaximized().then((is_maximized) => callback({ is_maximized }));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- WebUI / LAN remote-access lifecycle (Tauri commands + status event) ----
|
||||
|
||||
/** Invoke a Tauri command via `@tauri-apps/api/core`. */
|
||||
async function invokeCommand<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke<T>(cmd, args);
|
||||
}
|
||||
|
||||
/** Current WebUI/LAN serving status (backend `webui_get_status`). */
|
||||
export function tauriWebuiGetStatus<T>(): Promise<T> {
|
||||
return invokeCommand<T>('webui_get_status');
|
||||
}
|
||||
|
||||
/** Start LAN serving (backend `webui_start`). Returns the resulting status. */
|
||||
export function tauriWebuiStart<T>(): Promise<T> {
|
||||
return invokeCommand<T>('webui_start');
|
||||
}
|
||||
|
||||
/** Stop LAN serving (backend `webui_stop`). Returns the resulting status. */
|
||||
export function tauriWebuiStop<T>(): Promise<T> {
|
||||
return invokeCommand<T>('webui_stop');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to backend-emitted WebUI/LAN status changes
|
||||
* (`apps/desktop/src/main.rs` forwards them on `webui://status-changed`).
|
||||
*/
|
||||
export async function subscribeWebuiStatus<T>(callback: (status: T) => void): Promise<() => void> {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
return listen<T>('webui://status-changed', (event) => callback(event.payload));
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { TeamAgent, TeammateRole, TeammateStatus, TTeam, WorkspaceMode } from '../types/team/teamTypes';
|
||||
|
||||
// ── Parameter types for team API calls ─────────────────────────────────
|
||||
|
||||
export type ICreateTeamParams = {
|
||||
user_id: string;
|
||||
name: string;
|
||||
workspace: string;
|
||||
workspace_mode: WorkspaceMode;
|
||||
agents: Omit<TeamAgent, 'slot_id' | 'conversation_id'>[];
|
||||
};
|
||||
|
||||
export type IAddTeamAgentParams = {
|
||||
team_id: string;
|
||||
agent: Omit<TeamAgent, 'slot_id' | 'conversation_id'>;
|
||||
};
|
||||
|
||||
// ── Backend → Frontend ─────────────────────────────────────────────────
|
||||
|
||||
const VALID_ROLES = new Set<TeammateRole>(['leader', 'teammate']);
|
||||
const VALID_WORKSPACE_MODES = new Set<WorkspaceMode>(['shared', 'isolated']);
|
||||
|
||||
function toRole(raw: string | undefined): TeammateRole {
|
||||
if (raw === 'lead') return 'leader';
|
||||
return VALID_ROLES.has(raw as TeammateRole) ? (raw as TeammateRole) : 'teammate';
|
||||
}
|
||||
|
||||
function toStatus(raw: string | undefined): TeammateStatus {
|
||||
const statusMap: Record<string, TeammateStatus> = {
|
||||
pending: 'pending',
|
||||
idle: 'idle',
|
||||
working: 'active',
|
||||
thinking: 'active',
|
||||
tool_use: 'active',
|
||||
completed: 'completed',
|
||||
error: 'failed',
|
||||
};
|
||||
return statusMap[raw ?? ''] ?? 'idle';
|
||||
}
|
||||
|
||||
function toWorkspaceMode(raw: string | undefined): WorkspaceMode {
|
||||
return VALID_WORKSPACE_MODES.has(raw as WorkspaceMode) ? (raw as WorkspaceMode) : 'shared';
|
||||
}
|
||||
|
||||
const NON_ACP_BACKENDS = new Set(['nomi', 'openclaw-gateway', 'nanobot', 'remote']);
|
||||
|
||||
function resolveConversationType(backend: string): string {
|
||||
return NON_ACP_BACKENDS.has(backend) ? backend : 'acp';
|
||||
}
|
||||
|
||||
export function fromBackendAgent(raw: unknown): TeamAgent {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const agentType = (r.agent_type as string | undefined) ?? (r.backend as string | undefined) ?? '';
|
||||
const backend = (r.backend as string | undefined) ?? agentType;
|
||||
const conversationType = resolveConversationType(backend);
|
||||
return {
|
||||
slot_id: (r.slot_id as string | undefined) ?? '',
|
||||
conversation_id: (r.conversation_id as string | undefined) ?? '',
|
||||
role: toRole(r.role as string | undefined),
|
||||
agent_type: agentType,
|
||||
icon: r.icon as string | undefined,
|
||||
agent_name: (r.agent_name as string | undefined) ?? (r.name as string | undefined) ?? '',
|
||||
conversation_type: conversationType,
|
||||
status: toStatus(r.status as string | undefined),
|
||||
cli_path: r.cli_path as string | undefined,
|
||||
custom_agent_id: r.custom_agent_id as string | undefined,
|
||||
model: r.model as string | undefined,
|
||||
pending_confirmations: (r.pending_confirmations ?? r.pendingConfirmations ?? 0) as number,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromBackendTeam(raw: unknown): TTeam {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const agents = Array.isArray(r.agents) ? (r.agents as unknown[]).map(fromBackendAgent) : [];
|
||||
return {
|
||||
id: (r.id as string | undefined) ?? '',
|
||||
user_id: (r.user_id as string | undefined) ?? '',
|
||||
name: (r.name as string | undefined) ?? '',
|
||||
workspace: (r.workspace as string | undefined) ?? '',
|
||||
workspace_mode: toWorkspaceMode(r.workspace_mode as string | undefined),
|
||||
// Backend serializes this as `lead_agent_id` (see TeamResponse in
|
||||
// crates/backend/nomifun-api-types/src/team.rs); the frontend field is
|
||||
// `leader_agent_id`. Read the backend key, write the frontend key.
|
||||
leader_agent_id: (r.lead_agent_id as string | undefined) ?? '',
|
||||
agents,
|
||||
session_mode: r.session_mode as string | undefined,
|
||||
created_at: (r.created_at as number | undefined) ?? 0,
|
||||
updated_at: (r.updated_at as number | undefined) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromBackendTeamList(raw: unknown): TTeam[] {
|
||||
return Array.isArray(raw) ? (raw as unknown[]).map(fromBackendTeam) : [];
|
||||
}
|
||||
|
||||
export function fromBackendTeamOptional(raw: unknown): TTeam | null {
|
||||
return raw == null ? null : fromBackendTeam(raw);
|
||||
}
|
||||
|
||||
// ── Frontend → Backend ─────────────────────────────────────────────────
|
||||
|
||||
export function toBackendAgent(a: Omit<TeamAgent, 'slot_id' | 'conversation_id'>): Record<string, unknown> {
|
||||
return {
|
||||
name: a.agent_name,
|
||||
role: a.role === 'leader' ? 'lead' : a.role,
|
||||
backend: a.agent_type,
|
||||
model: a.model || 'default',
|
||||
...(a.custom_agent_id ? { custom_agent_id: a.custom_agent_id } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { IDirOrFile, IWorkspaceFlatFile } from './ipcBridge';
|
||||
|
||||
type RawFsEntry = { name: string; type: string };
|
||||
export type RawWorkspaceFlatFile = { name: string; full_path: string; relative_path: string };
|
||||
|
||||
// ── Path helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function normalizeSlashes(p: string): string {
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function stripTrailingSlash(p: string): string {
|
||||
return p.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// ── Frontend → Backend ─────────────────────────────────────────────────
|
||||
|
||||
export function absoluteToRelativePath(absolutePath: string, workspace: string): string {
|
||||
if (!absolutePath || !workspace) return absolutePath || '.';
|
||||
const abs = stripTrailingSlash(normalizeSlashes(absolutePath));
|
||||
const ws = stripTrailingSlash(normalizeSlashes(workspace));
|
||||
if (abs === ws) return '.';
|
||||
if (abs.startsWith(ws + '/')) {
|
||||
return abs.slice(ws.length + 1) || '.';
|
||||
}
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
// ── Backend → Frontend ─────────────────────────────────────────────────
|
||||
|
||||
export function fromBackendFsEntry(item: RawFsEntry, workspace: string, parentRelPath: string): IDirOrFile {
|
||||
const ws = stripTrailingSlash(workspace);
|
||||
const name = item.name || '';
|
||||
const isDir = item.type === 'directory';
|
||||
const relativePath = parentRelPath ? `${parentRelPath}/${name}` : name;
|
||||
return {
|
||||
name,
|
||||
fullPath: `${ws}/${relativePath}`,
|
||||
relativePath,
|
||||
isDir,
|
||||
isFile: !isDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromBackendWorkspaceList(raw: RawFsEntry[], workspace: string, relPath: string): IDirOrFile[] {
|
||||
const ws = stripTrailingSlash(workspace);
|
||||
const base = relPath === '.' ? '' : relPath;
|
||||
const children = raw.map((item) => fromBackendFsEntry(item, ws, base));
|
||||
|
||||
if (relPath === '.' || !relPath) {
|
||||
const rootName = ws.split('/').pop() || '';
|
||||
return [
|
||||
{
|
||||
name: rootName,
|
||||
fullPath: ws,
|
||||
relativePath: '',
|
||||
isDir: true,
|
||||
isFile: false,
|
||||
children,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const dirName = relPath.split('/').pop() || '';
|
||||
return [
|
||||
{
|
||||
name: dirName,
|
||||
fullPath: `${ws}/${relPath}`,
|
||||
relativePath: relPath,
|
||||
isDir: true,
|
||||
isFile: false,
|
||||
children,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function fromBackendWorkspaceFlatFiles(raw: RawWorkspaceFlatFile[]): IWorkspaceFlatFile[] {
|
||||
return raw.map((item) => ({
|
||||
name: item.name,
|
||||
fullPath: item.full_path,
|
||||
relativePath: item.relative_path,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import Anthropic, { type ClientOptions as AnthropicClientOptions_ } from '@anthropic-ai/sdk';
|
||||
import { AuthType } from '@/common/api/authType';
|
||||
import type { RotatingApiClientOptions } from './RotatingApiClient';
|
||||
import { RotatingApiClient } from './RotatingApiClient';
|
||||
import {
|
||||
OpenAI2AnthropicConverter,
|
||||
type OpenAIChatCompletionParams,
|
||||
type OpenAIChatCompletionResponse,
|
||||
} from './OpenAI2AnthropicConverter';
|
||||
|
||||
export interface AnthropicClientConfig {
|
||||
model?: string;
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class AnthropicRotatingClient extends RotatingApiClient<Anthropic> {
|
||||
private readonly config: AnthropicClientConfig;
|
||||
private readonly converter: OpenAI2AnthropicConverter;
|
||||
|
||||
constructor(apiKeys: string, config: AnthropicClientConfig = {}, options: RotatingApiClientOptions = {}) {
|
||||
const createClient = (apiKey: string) => {
|
||||
const cleanedApiKey = apiKey.replace(/[\s\r\n\t]/g, '').trim();
|
||||
|
||||
const clientConfig: AnthropicClientOptions_ = {
|
||||
apiKey: cleanedApiKey,
|
||||
};
|
||||
|
||||
if (config.baseURL) {
|
||||
clientConfig.baseURL = config.baseURL;
|
||||
}
|
||||
|
||||
if (config.timeout) {
|
||||
clientConfig.timeout = config.timeout;
|
||||
}
|
||||
|
||||
return new Anthropic(clientConfig);
|
||||
};
|
||||
|
||||
super(apiKeys, AuthType.USE_ANTHROPIC, createClient, options);
|
||||
this.config = config;
|
||||
this.converter = new OpenAI2AnthropicConverter({
|
||||
defaultModel: config.model || 'claude-sonnet-4-20250514',
|
||||
});
|
||||
}
|
||||
|
||||
protected getCurrentApiKey(): string | undefined {
|
||||
if (this.apiKeyManager?.hasMultipleKeys()) {
|
||||
// For Anthropic, try to get from environment first
|
||||
return process.env.ANTHROPIC_API_KEY || this.apiKeyManager.getCurrentKey();
|
||||
}
|
||||
// Use base class method for single key
|
||||
return super.getCurrentApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible createChatCompletion method for unified interface
|
||||
*/
|
||||
async createChatCompletion(
|
||||
params: OpenAIChatCompletionParams,
|
||||
options?: { signal?: AbortSignal; timeout?: number }
|
||||
): Promise<OpenAIChatCompletionResponse> {
|
||||
// Handle request cancellation
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error('Request was aborted');
|
||||
}
|
||||
|
||||
return await this.executeWithRetry(async (client) => {
|
||||
// Convert OpenAI format to Anthropic format using converter
|
||||
const anthropicRequest = this.converter.convertRequest(params);
|
||||
|
||||
// Call Anthropic API
|
||||
const anthropicResponse = await client.messages.create(anthropicRequest);
|
||||
|
||||
// Convert Anthropic response back to OpenAI format using converter
|
||||
return this.converter.convertResponse(anthropicResponse, params.model);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct Anthropic API call for native usage
|
||||
*/
|
||||
async createMessage(request: Anthropic.MessageCreateParamsNonStreaming): Promise<Anthropic.Message> {
|
||||
return await this.executeWithRetry(async (client) => {
|
||||
return await client.messages.create(request);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { AuthType } from '@/common/api/authType';
|
||||
|
||||
/**
|
||||
* Multi-API Key Manager with Time-based Blacklisting
|
||||
* Handles rotation of multiple API keys for different authentication types
|
||||
* Blacklists failed keys for 90 seconds to allow rate limits to recover
|
||||
*/
|
||||
export class ApiKeyManager {
|
||||
private keys: string[] = [];
|
||||
private currentIndex = 0;
|
||||
private authType: AuthType;
|
||||
private envKey: string;
|
||||
private blacklistedUntil: Map<number, number> = new Map(); // keyIndex -> recoveryTimestamp
|
||||
private readonly BLACKLIST_DURATION = 90 * 1000; // 90 seconds
|
||||
|
||||
constructor(keysString: string, authType: AuthType) {
|
||||
this.authType = authType;
|
||||
this.envKey = this.getEnvironmentKey(authType);
|
||||
this.keys = this.parseKeys(keysString);
|
||||
this.initializeWithRandomKey();
|
||||
}
|
||||
|
||||
private getEnvironmentKey(authType: AuthType): string {
|
||||
switch (authType) {
|
||||
case AuthType.USE_OPENAI:
|
||||
return 'OPENAI_API_KEY';
|
||||
case AuthType.USE_ANTHROPIC:
|
||||
return 'ANTHROPIC_API_KEY';
|
||||
case AuthType.USE_GEMINI:
|
||||
return 'GEMINI_API_KEY';
|
||||
default:
|
||||
throw new Error(`Multi-key not supported for auth type: ${authType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseKeys(keysString: string): string[] {
|
||||
if (!keysString) return [];
|
||||
return keysString
|
||||
.split(/[,\n]/)
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0);
|
||||
}
|
||||
|
||||
private initializeWithRandomKey(): void {
|
||||
if (this.hasMultipleKeys()) {
|
||||
this.currentIndex = Math.floor(Math.random() * this.keys.length);
|
||||
this.updateEnvironment();
|
||||
}
|
||||
}
|
||||
|
||||
private updateEnvironment(): void {
|
||||
process.env[this.envKey] = this.keys[this.currentIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if multiple keys are available
|
||||
*/
|
||||
hasMultipleKeys(): boolean {
|
||||
return this.keys.length > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate to next available key after blacklisting current failed key
|
||||
* @returns true if more keys available, false if all keys blacklisted
|
||||
*/
|
||||
rotateKey(): boolean {
|
||||
if (!this.hasMultipleKeys()) return false;
|
||||
|
||||
// Blacklist current failed key
|
||||
this.blacklistCurrentKey();
|
||||
|
||||
// Find next available (non-blacklisted) key
|
||||
const availableIndex = this.findNextAvailableKey();
|
||||
|
||||
if (availableIndex !== -1) {
|
||||
const previousIndex = this.currentIndex;
|
||||
this.currentIndex = availableIndex;
|
||||
this.updateEnvironment();
|
||||
console.log(
|
||||
`[MultiKey] Rotated ${this.authType}: #${previousIndex + 1} → #${this.currentIndex + 1}/${this.keys.length}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`[MultiKey] All keys blacklisted for ${this.authType}, falling back`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blacklist current key for 90 seconds
|
||||
*/
|
||||
private blacklistCurrentKey(): void {
|
||||
const recoveryTime = Date.now() + this.BLACKLIST_DURATION;
|
||||
this.blacklistedUntil.set(this.currentIndex, recoveryTime);
|
||||
|
||||
const recoveryDate = new Date(recoveryTime);
|
||||
console.log(
|
||||
`[MultiKey] Blacklisted ${this.authType} key #${this.currentIndex + 1} until ${recoveryDate.toLocaleTimeString()}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is currently available (not blacklisted)
|
||||
*/
|
||||
private isKeyAvailable(index: number): boolean {
|
||||
const blacklistedUntil = this.blacklistedUntil.get(index);
|
||||
if (!blacklistedUntil) return true; // Never been blacklisted
|
||||
|
||||
if (Date.now() >= blacklistedUntil) {
|
||||
// Blacklist period expired, remove from blacklist
|
||||
this.blacklistedUntil.delete(index);
|
||||
console.log(`[MultiKey] ${this.authType} key #${index + 1} recovered from blacklist`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // Still blacklisted
|
||||
}
|
||||
|
||||
/**
|
||||
* Find next available key starting from current position
|
||||
*/
|
||||
private findNextAvailableKey(): number {
|
||||
// Search all other keys (excluding current)
|
||||
for (let i = 1; i < this.keys.length; i++) {
|
||||
const candidateIndex = (this.currentIndex + i) % this.keys.length;
|
||||
if (this.isKeyAvailable(candidateIndex)) {
|
||||
return candidateIndex;
|
||||
}
|
||||
}
|
||||
return -1; // No available keys found
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current key status for debugging
|
||||
*/
|
||||
getStatus(): {
|
||||
authType: AuthType;
|
||||
envKey: string;
|
||||
current: number;
|
||||
total: number;
|
||||
keys: string[];
|
||||
blacklisted: number[];
|
||||
} {
|
||||
const now = Date.now();
|
||||
const blacklisted: number[] = [];
|
||||
// Check which keys are currently blacklisted
|
||||
for (const [index, recoveryTime] of this.blacklistedUntil.entries()) {
|
||||
if (now < recoveryTime) {
|
||||
blacklisted.push(index + 1); // Convert to 1-based indexing for display
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authType: this.authType,
|
||||
envKey: this.envKey,
|
||||
current: this.currentIndex + 1,
|
||||
total: this.keys.length,
|
||||
keys: this.keys,
|
||||
blacklisted,
|
||||
};
|
||||
}
|
||||
|
||||
getCurrentKey(): string {
|
||||
if (this.keys.length === 0) return '';
|
||||
return this.keys[this.currentIndex] || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { AuthType } from '@/common/api/authType';
|
||||
import type { TProviderWithModel } from '../config/storage';
|
||||
import { OpenAIRotatingClient, type OpenAIClientConfig } from './OpenAIRotatingClient';
|
||||
import { GeminiRotatingClient, type GeminiClientConfig } from './GeminiRotatingClient';
|
||||
import { AnthropicRotatingClient, type AnthropicClientConfig } from './AnthropicRotatingClient';
|
||||
import type { RotatingApiClientOptions } from './RotatingApiClient';
|
||||
import { getProviderAuthType } from '../utils/platformAuthType';
|
||||
import { isNewApiPlatform } from '../utils/platformConstants';
|
||||
|
||||
export interface ClientOptions {
|
||||
timeout?: number;
|
||||
proxy?: string;
|
||||
baseConfig?: OpenAIClientConfig | GeminiClientConfig | AnthropicClientConfig;
|
||||
rotatingOptions?: RotatingApiClientOptions;
|
||||
}
|
||||
|
||||
export type RotatingClient = OpenAIRotatingClient | GeminiRotatingClient | AnthropicRotatingClient;
|
||||
|
||||
/**
|
||||
* 为 new-api 网关规范化 base URL
|
||||
* Normalize base URL for new-api gateway based on target protocol
|
||||
*
|
||||
* 策略:先剥离所有已知 API 路径后缀得到根 URL,再根据目标协议添加正确后缀。
|
||||
* Strategy: strip all known API path suffixes to get root URL, then add the correct suffix for target protocol.
|
||||
*
|
||||
* @param base_url 原始 base URL / Original base URL
|
||||
* @param authType 目标认证类型 / Target auth type
|
||||
* @returns 规范化后的 base URL / Normalized base URL
|
||||
*/
|
||||
export function normalizeNewApiBaseUrl(base_url: string, authType: AuthType): string {
|
||||
if (!base_url) return base_url;
|
||||
|
||||
// 1. 移除尾部斜杠,剥离所有已知 API 路径后缀,得到根 URL
|
||||
// Remove trailing slashes, strip all known API path suffixes to get root URL
|
||||
const rootUrl = base_url
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/v1$/, '')
|
||||
.replace(/\/v1beta$/, '');
|
||||
|
||||
// 2. 根据目标协议添加正确的路径后缀
|
||||
// Add the correct path suffix for the target protocol
|
||||
switch (authType) {
|
||||
case AuthType.USE_OPENAI:
|
||||
// OpenAI SDK 需要带 /v1 的路径 / OpenAI SDK expects URL with /v1 path
|
||||
return `${rootUrl}/v1`;
|
||||
case AuthType.USE_GEMINI:
|
||||
case AuthType.USE_ANTHROPIC:
|
||||
// Gemini/Anthropic SDKs need root URL (they append their own paths)
|
||||
return rootUrl;
|
||||
default:
|
||||
return rootUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientFactory {
|
||||
static async createRotatingClient(
|
||||
provider: TProviderWithModel,
|
||||
options: ClientOptions = {}
|
||||
): Promise<RotatingClient> {
|
||||
const authType = getProviderAuthType(provider);
|
||||
const rotatingOptions = options.rotatingOptions || { maxRetries: 3, retryDelay: 1000 };
|
||||
|
||||
// 对 new-api 网关进行 URL 规范化 / Normalize URL for new-api gateway
|
||||
const isNewApi = isNewApiPlatform(provider.platform);
|
||||
const base_url = isNewApi ? normalizeNewApiBaseUrl(provider.base_url, authType) : provider.base_url;
|
||||
|
||||
switch (authType) {
|
||||
case AuthType.USE_OPENAI: {
|
||||
const clientConfig: OpenAIClientConfig = {
|
||||
baseURL: base_url,
|
||||
timeout: options.timeout,
|
||||
defaultHeaders: {
|
||||
'HTTP-Referer': 'https://nomifun.com',
|
||||
'X-Title': 'NomiFun',
|
||||
},
|
||||
...(options.baseConfig as OpenAIClientConfig),
|
||||
};
|
||||
|
||||
// 添加代理配置(如果提供)
|
||||
if (options.proxy) {
|
||||
const { HttpsProxyAgent } = await import('https-proxy-agent');
|
||||
clientConfig.httpAgent = new HttpsProxyAgent(options.proxy);
|
||||
}
|
||||
|
||||
return new OpenAIRotatingClient(provider.api_key, clientConfig, rotatingOptions);
|
||||
}
|
||||
|
||||
case AuthType.USE_GEMINI: {
|
||||
const clientConfig: GeminiClientConfig = {
|
||||
model: provider.use_model,
|
||||
baseURL: base_url,
|
||||
...(options.baseConfig as GeminiClientConfig),
|
||||
};
|
||||
|
||||
return new GeminiRotatingClient(provider.api_key, clientConfig, rotatingOptions, authType);
|
||||
}
|
||||
|
||||
case AuthType.USE_VERTEX_AI: {
|
||||
const clientConfig: GeminiClientConfig = {
|
||||
model: provider.use_model,
|
||||
...(options.baseConfig as GeminiClientConfig),
|
||||
};
|
||||
|
||||
return new GeminiRotatingClient(provider.api_key, clientConfig, rotatingOptions, authType);
|
||||
}
|
||||
|
||||
case AuthType.USE_ANTHROPIC: {
|
||||
const clientConfig: AnthropicClientConfig = {
|
||||
model: provider.use_model,
|
||||
baseURL: base_url,
|
||||
timeout: options.timeout,
|
||||
...(options.baseConfig as AnthropicClientConfig),
|
||||
};
|
||||
|
||||
return new AnthropicRotatingClient(provider.api_key, clientConfig, rotatingOptions);
|
||||
}
|
||||
|
||||
default: {
|
||||
// 默认使用OpenAI兼容协议
|
||||
const clientConfig: OpenAIClientConfig = {
|
||||
baseURL: base_url,
|
||||
timeout: options.timeout,
|
||||
defaultHeaders: {
|
||||
'HTTP-Referer': 'https://nomifun.com',
|
||||
'X-Title': 'NomiFun',
|
||||
},
|
||||
...(options.baseConfig as OpenAIClientConfig),
|
||||
};
|
||||
|
||||
// 添加代理配置(如果提供)
|
||||
if (options.proxy) {
|
||||
const { HttpsProxyAgent } = await import('https-proxy-agent');
|
||||
clientConfig.httpAgent = new HttpsProxyAgent(options.proxy);
|
||||
}
|
||||
|
||||
return new OpenAIRotatingClient(provider.api_key, clientConfig, rotatingOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { GoogleGenAI, type GenerateContentParameters, type GoogleGenAIOptions } from '@google/genai';
|
||||
import { AuthType } from '@/common/api/authType';
|
||||
import type { RotatingApiClientOptions } from './RotatingApiClient';
|
||||
import { RotatingApiClient } from './RotatingApiClient';
|
||||
import {
|
||||
OpenAI2GeminiConverter,
|
||||
type OpenAIChatCompletionParams,
|
||||
type OpenAIChatCompletionResponse,
|
||||
} from './OpenAI2GeminiConverter';
|
||||
|
||||
export interface GeminiClientConfig {
|
||||
model?: string;
|
||||
baseURL?: string;
|
||||
requestOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class GeminiRotatingClient extends RotatingApiClient<GoogleGenAI> {
|
||||
private readonly config: GeminiClientConfig;
|
||||
private readonly converter: OpenAI2GeminiConverter;
|
||||
|
||||
constructor(
|
||||
apiKeys: string,
|
||||
config: GeminiClientConfig = {},
|
||||
options: RotatingApiClientOptions = {},
|
||||
authType: AuthType = AuthType.USE_GEMINI
|
||||
) {
|
||||
const createClient = (apiKey: string) => {
|
||||
const cleanedApiKey = apiKey.replace(/[\s\r\n\t]/g, '').trim();
|
||||
const clientConfig: GoogleGenAIOptions = {
|
||||
apiKey: cleanedApiKey === '' ? undefined : cleanedApiKey,
|
||||
vertexai: authType === AuthType.USE_VERTEX_AI,
|
||||
};
|
||||
if (config.baseURL) {
|
||||
clientConfig.httpOptions = {
|
||||
...clientConfig.httpOptions,
|
||||
baseUrl: config.baseURL,
|
||||
};
|
||||
}
|
||||
return new GoogleGenAI(clientConfig);
|
||||
};
|
||||
|
||||
super(apiKeys, authType, createClient, options);
|
||||
this.config = config;
|
||||
this.converter = new OpenAI2GeminiConverter({
|
||||
defaultModel: config.model || 'gemini-1.5-flash',
|
||||
});
|
||||
}
|
||||
|
||||
protected getCurrentApiKey(): string | undefined {
|
||||
if (this.apiKeyManager?.hasMultipleKeys()) {
|
||||
return process.env.GEMINI_API_KEY || this.apiKeyManager.getCurrentKey();
|
||||
}
|
||||
return super.getCurrentApiKey();
|
||||
}
|
||||
|
||||
async generateContent(prompt: string, config?: GenerateContentParameters['config']): Promise<unknown> {
|
||||
return await this.executeWithRetry(async (client) => {
|
||||
const request: GenerateContentParameters = {
|
||||
model: this.config.model || 'gemini-1.5-flash',
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
...(config ? { config } : {}),
|
||||
};
|
||||
return await client.models.generateContent(request);
|
||||
});
|
||||
}
|
||||
|
||||
async createChatCompletion(
|
||||
params: OpenAIChatCompletionParams,
|
||||
options?: { signal?: AbortSignal; timeout?: number }
|
||||
): Promise<OpenAIChatCompletionResponse> {
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error('Request was aborted');
|
||||
}
|
||||
|
||||
return await this.executeWithRetry(async (client) => {
|
||||
const geminiRequest = this.converter.convertRequest(params);
|
||||
const { generationConfig, ...generateContentRequest } = geminiRequest;
|
||||
const request: GenerateContentParameters = {
|
||||
...generateContentRequest,
|
||||
...(generationConfig ? { config: generationConfig } : {}),
|
||||
};
|
||||
const geminiResponse = await client.models.generateContent(request);
|
||||
return this.converter.convertResponse(geminiResponse, params.model);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { ProtocolConverter, ConverterConfig } from './ProtocolConverter';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
// OpenAI types - compatible with actual OpenAI SDK types
|
||||
export interface OpenAIChatCompletionParams {
|
||||
model: string;
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content:
|
||||
| string
|
||||
| Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: { url: string; detail?: string };
|
||||
}>;
|
||||
}>;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stop?: string | string[];
|
||||
stream?: boolean;
|
||||
tools?: Array<{
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
};
|
||||
}>;
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
}
|
||||
|
||||
export interface OpenAIChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string;
|
||||
images?: Array<{
|
||||
type: 'image_url';
|
||||
image_url: { url: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Anthropic types
|
||||
export type AnthropicMessageRequest = Anthropic.MessageCreateParamsNonStreaming;
|
||||
export type AnthropicMessageResponse = Anthropic.Message;
|
||||
|
||||
/**
|
||||
* Converter for transforming OpenAI chat completion format to/from Anthropic format
|
||||
*/
|
||||
export class OpenAI2AnthropicConverter implements ProtocolConverter<
|
||||
OpenAIChatCompletionParams,
|
||||
AnthropicMessageRequest,
|
||||
OpenAIChatCompletionResponse
|
||||
> {
|
||||
private readonly config: ConverterConfig;
|
||||
|
||||
constructor(config: ConverterConfig = {}) {
|
||||
this.config = {
|
||||
defaultModel: 'claude-sonnet-4-20250514',
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI chat completion params to Anthropic message request format
|
||||
*/
|
||||
convertRequest(params: OpenAIChatCompletionParams): AnthropicMessageRequest {
|
||||
// Extract system message (Anthropic uses separate system parameter)
|
||||
let systemMessage: string | undefined;
|
||||
const messages: Anthropic.MessageParam[] = [];
|
||||
|
||||
for (const msg of params.messages) {
|
||||
if (msg.role === 'system') {
|
||||
// Anthropic uses a separate system parameter
|
||||
systemMessage = this.extractTextContent(msg.content);
|
||||
} else if (msg.role === 'user' || msg.role === 'assistant') {
|
||||
const content = this.convertMessageContent(msg.content);
|
||||
messages.push({
|
||||
role: msg.role as 'user' | 'assistant',
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure messages alternate between user and assistant
|
||||
// Anthropic requires the first message to be from user
|
||||
const validatedMessages = this.ensureAlternatingMessages(messages);
|
||||
|
||||
const request: AnthropicMessageRequest = {
|
||||
model: this.config.defaultModel || params.model,
|
||||
max_tokens: params.max_tokens || 4096,
|
||||
messages: validatedMessages,
|
||||
};
|
||||
|
||||
// Add system message if present
|
||||
if (systemMessage) {
|
||||
request.system = systemMessage;
|
||||
}
|
||||
|
||||
// Add optional parameters — Anthropic API forbids sending both temperature and top_p
|
||||
if (params.temperature !== undefined && params.top_p !== undefined) {
|
||||
// When both are set, prefer temperature (more commonly configured by users)
|
||||
request.temperature = params.temperature;
|
||||
} else if (params.temperature !== undefined) {
|
||||
request.temperature = params.temperature;
|
||||
} else if (params.top_p !== undefined) {
|
||||
request.top_p = params.top_p;
|
||||
}
|
||||
if (params.stop) {
|
||||
request.stop_sequences = Array.isArray(params.stop) ? params.stop : [params.stop];
|
||||
}
|
||||
|
||||
// Convert tools if present
|
||||
if (params.tools && params.tools.length > 0) {
|
||||
request.tools = params.tools.map((tool) => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description || '',
|
||||
input_schema: (tool.function.parameters as Anthropic.Tool.InputSchema) || { type: 'object', properties: {} },
|
||||
}));
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Anthropic message response to OpenAI chat completion format
|
||||
*/
|
||||
convertResponse(anthropicResponse: AnthropicMessageResponse, requestedModel: string): OpenAIChatCompletionResponse {
|
||||
let content = '';
|
||||
const images: Array<{ type: 'image_url'; image_url: { url: string } }> = [];
|
||||
|
||||
// Process all content blocks in the response
|
||||
for (const block of anthropicResponse.content) {
|
||||
if (block.type === 'text') {
|
||||
content += block.text;
|
||||
}
|
||||
// Note: Anthropic doesn't return images in the same way as image generation models
|
||||
}
|
||||
|
||||
return {
|
||||
id: anthropicResponse.id,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: requestedModel,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: content || '',
|
||||
...(images.length > 0 ? { images } : {}),
|
||||
},
|
||||
finish_reason: this.mapStopReason(anthropicResponse.stop_reason),
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: anthropicResponse.usage.input_tokens,
|
||||
completion_tokens: anthropicResponse.usage.output_tokens,
|
||||
total_tokens: anthropicResponse.usage.input_tokens + anthropicResponse.usage.output_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from OpenAI message content
|
||||
*/
|
||||
private extractTextContent(
|
||||
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>
|
||||
): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content
|
||||
.filter((part) => part.type === 'text' && part.text)
|
||||
.map((part) => part.text!)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI message content to Anthropic content format
|
||||
*/
|
||||
private convertMessageContent(
|
||||
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>
|
||||
): Anthropic.ContentBlockParam[] | string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
const contentBlocks: Anthropic.ContentBlockParam[] = [];
|
||||
|
||||
for (const part of content) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
contentBlocks.push({
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
});
|
||||
} else if (part.type === 'image_url' && part.image_url?.url) {
|
||||
const imageUrl = part.image_url.url;
|
||||
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
// Handle base64 data URLs
|
||||
const [mimeInfo, base64Data] = imageUrl.split(',');
|
||||
const mimeType = mimeInfo.match(/data:(.*?);base64/)?.[1] || 'image/png';
|
||||
|
||||
contentBlocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
|
||||
data: base64Data,
|
||||
},
|
||||
});
|
||||
} else if (imageUrl.startsWith('http')) {
|
||||
// Anthropic supports URL-based images
|
||||
contentBlocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: imageUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contentBlocks.length === 1 && contentBlocks[0].type === 'text'
|
||||
? (contentBlocks[0] as Anthropic.TextBlockParam).text
|
||||
: contentBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure messages alternate between user and assistant
|
||||
* Anthropic requires the first message to be from user
|
||||
*/
|
||||
private ensureAlternatingMessages(messages: Anthropic.MessageParam[]): Anthropic.MessageParam[] {
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: Anthropic.MessageParam[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const lastRole = result.length > 0 ? result[result.length - 1].role : null;
|
||||
|
||||
// If same role as last message, merge content
|
||||
if (lastRole === msg.role) {
|
||||
const lastMsg = result[result.length - 1];
|
||||
const lastContent = lastMsg.content;
|
||||
const new_content = msg.content;
|
||||
|
||||
// Merge contents
|
||||
if (typeof lastContent === 'string' && typeof new_content === 'string') {
|
||||
lastMsg.content = lastContent + '\n' + new_content;
|
||||
} else {
|
||||
// Convert to array and merge
|
||||
const lastArray =
|
||||
typeof lastContent === 'string' ? [{ type: 'text' as const, text: lastContent }] : lastContent;
|
||||
const newArray =
|
||||
typeof new_content === 'string' ? [{ type: 'text' as const, text: new_content }] : new_content;
|
||||
lastMsg.content = [...lastArray, ...newArray];
|
||||
}
|
||||
} else {
|
||||
result.push({ ...msg });
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure first message is from user
|
||||
if (result.length > 0 && result[0].role !== 'user') {
|
||||
result.unshift({
|
||||
role: 'user',
|
||||
content: 'Continue the conversation.',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Anthropic stop reasons to OpenAI finish reasons
|
||||
*/
|
||||
private mapStopReason(stop_reason: string | null): string {
|
||||
switch (stop_reason) {
|
||||
case 'end_turn':
|
||||
return 'stop';
|
||||
case 'max_tokens':
|
||||
return 'length';
|
||||
case 'stop_sequence':
|
||||
return 'stop';
|
||||
case 'tool_use':
|
||||
return 'tool_calls';
|
||||
default:
|
||||
return 'stop';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { ProtocolConverter, ConverterConfig } from './ProtocolConverter';
|
||||
|
||||
export interface OpenAIChatCompletionParams {
|
||||
model: string;
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content:
|
||||
| string
|
||||
| Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: { url: string; detail?: string };
|
||||
}>;
|
||||
}>;
|
||||
tools?: Array<{
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
};
|
||||
}>;
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
}
|
||||
|
||||
export interface OpenAIChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string;
|
||||
images?: Array<{
|
||||
type: 'image_url';
|
||||
image_url: { url: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeminiRequest {
|
||||
model: string;
|
||||
contents: Array<{
|
||||
role?: string;
|
||||
parts: Array<{
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
tools?: Array<{
|
||||
functionDeclarations: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
}>;
|
||||
}>;
|
||||
generationConfig?: {
|
||||
responseModalities?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{
|
||||
finishReason?: string;
|
||||
content?: {
|
||||
parts?: Array<{
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
mimeType?: string;
|
||||
data?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeGeminiFunctionName(name: string): string {
|
||||
let sanitized = name.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
if (sanitized.length > 0 && /^[0-9]/.test(sanitized)) {
|
||||
sanitized = `_${sanitized}`;
|
||||
}
|
||||
return sanitized || '_unnamed';
|
||||
}
|
||||
|
||||
export class OpenAI2GeminiConverter implements ProtocolConverter<
|
||||
OpenAIChatCompletionParams,
|
||||
GeminiRequest,
|
||||
OpenAIChatCompletionResponse
|
||||
> {
|
||||
private readonly config: ConverterConfig;
|
||||
|
||||
constructor(config: ConverterConfig = {}) {
|
||||
this.config = {
|
||||
defaultModel: 'gemini-1.5-flash',
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
convertRequest(params: OpenAIChatCompletionParams): GeminiRequest {
|
||||
const message = params.messages[0];
|
||||
if (!message || !message.content) {
|
||||
throw new Error('Invalid message format for Gemini conversion');
|
||||
}
|
||||
|
||||
const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = [];
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
parts.push({ text: message.content });
|
||||
} else {
|
||||
for (const part of message.content) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
parts.push({ text: part.text });
|
||||
} else if (part.type === 'image_url' && part.image_url?.url) {
|
||||
const imageUrl = part.image_url.url;
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
const [mimeInfo, base64Data] = imageUrl.split(',');
|
||||
const mimeType = mimeInfo.match(/data:(.*?);base64/)?.[1] || 'image/png';
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType,
|
||||
data: base64Data,
|
||||
},
|
||||
});
|
||||
} else if (imageUrl.startsWith('http')) {
|
||||
throw new Error('HTTP image URLs are not supported in Gemini integration. Please use base64 data URLs.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isImageGeneration = parts.some((part) => {
|
||||
const text = part.text?.toLowerCase();
|
||||
return (
|
||||
text &&
|
||||
(text.includes('generate image') ||
|
||||
text.includes('create image') ||
|
||||
text.includes('draw') ||
|
||||
text.includes('make image'))
|
||||
);
|
||||
});
|
||||
|
||||
const request: GeminiRequest = {
|
||||
model: params.model || this.config.defaultModel || 'gemini-1.5-flash',
|
||||
contents: [{ role: 'user', parts }],
|
||||
};
|
||||
|
||||
if (isImageGeneration) {
|
||||
request.generationConfig = {
|
||||
responseModalities: ['IMAGE', 'TEXT'],
|
||||
};
|
||||
}
|
||||
|
||||
if (params.tools && params.tools.length > 0) {
|
||||
request.tools = [
|
||||
{
|
||||
functionDeclarations: params.tools.map((tool) => ({
|
||||
name: sanitizeGeminiFunctionName(tool.function.name),
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
convertResponse(geminiResponse: GeminiResponse, requestedModel: string): OpenAIChatCompletionResponse {
|
||||
const candidate = geminiResponse.candidates?.[0];
|
||||
|
||||
if (!candidate) {
|
||||
return {
|
||||
id: `gemini-${Date.now()}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: requestedModel,
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
let content = '';
|
||||
const images: Array<{ type: 'image_url'; image_url: { url: string } }> = [];
|
||||
|
||||
if (candidate.content?.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.text) {
|
||||
content += part.text;
|
||||
}
|
||||
if (part.inlineData) {
|
||||
images.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${part.inlineData.mimeType || 'image/png'};base64,${part.inlineData.data || ''}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `gemini-${Date.now()}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: requestedModel,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: content || 'Image generated successfully.',
|
||||
...(images.length > 0 ? { images } : {}),
|
||||
},
|
||||
finish_reason: this.mapFinishReason(candidate.finishReason),
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
|
||||
completion_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
|
||||
total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mapFinishReason(geminiReason?: string): string {
|
||||
switch (geminiReason) {
|
||||
case 'STOP':
|
||||
return 'stop';
|
||||
case 'MAX_TOKENS':
|
||||
return 'length';
|
||||
case 'SAFETY':
|
||||
case 'RECITATION':
|
||||
return 'content_filter';
|
||||
default:
|
||||
return 'stop';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import OpenAI from 'openai';
|
||||
import { AuthType } from '@/common/api/authType';
|
||||
import type { RotatingApiClientOptions } from './RotatingApiClient';
|
||||
import { RotatingApiClient } from './RotatingApiClient';
|
||||
|
||||
export interface OpenAIClientConfig {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
httpAgent?: unknown;
|
||||
}
|
||||
|
||||
export class OpenAIRotatingClient extends RotatingApiClient<OpenAI> {
|
||||
private readonly baseConfig: OpenAIClientConfig;
|
||||
|
||||
constructor(api_keys: string, config: OpenAIClientConfig = {}, options: RotatingApiClientOptions = {}) {
|
||||
const createClient = (api_key: string) => {
|
||||
const cleanedApiKey = api_key.replace(/[\s\r\n\t]/g, '').trim();
|
||||
const openaiConfig: any = {
|
||||
baseURL: config.baseURL,
|
||||
api_key: cleanedApiKey,
|
||||
defaultHeaders: config.defaultHeaders,
|
||||
};
|
||||
|
||||
if (config.httpAgent) {
|
||||
openaiConfig.httpAgent = config.httpAgent;
|
||||
}
|
||||
|
||||
return new OpenAI(openaiConfig);
|
||||
};
|
||||
|
||||
super(api_keys, AuthType.USE_OPENAI, createClient, options);
|
||||
this.baseConfig = config;
|
||||
}
|
||||
|
||||
protected getCurrentApiKey(): string | undefined {
|
||||
if (this.apiKeyManager?.hasMultipleKeys()) {
|
||||
// For OpenAI, try to get from environment first
|
||||
return process.env.OPENAI_API_KEY || this.apiKeyManager.getCurrentKey();
|
||||
}
|
||||
// Use base class method for single key
|
||||
return super.getCurrentApiKey();
|
||||
}
|
||||
|
||||
// Convenience methods for common OpenAI operations
|
||||
async createChatCompletion(
|
||||
params: OpenAI.Chat.Completions.ChatCompletionCreateParams,
|
||||
options?: OpenAI.RequestOptions
|
||||
): Promise<OpenAI.Chat.Completions.ChatCompletion> {
|
||||
return await this.executeWithRetry(async (client) => {
|
||||
const result = await client.chat.completions.create(params, options);
|
||||
return result as OpenAI.Chat.Completions.ChatCompletion;
|
||||
});
|
||||
}
|
||||
|
||||
async createImage(
|
||||
params: OpenAI.Images.ImageGenerateParams,
|
||||
options?: OpenAI.RequestOptions
|
||||
): Promise<OpenAI.Images.ImagesResponse> {
|
||||
return await this.executeWithRetry((client) => {
|
||||
return client.images.generate(params, options) as Promise<OpenAI.Images.ImagesResponse>;
|
||||
});
|
||||
}
|
||||
|
||||
async createEmbedding(
|
||||
params: OpenAI.Embeddings.EmbeddingCreateParams,
|
||||
options?: OpenAI.RequestOptions
|
||||
): Promise<OpenAI.Embeddings.CreateEmbeddingResponse> {
|
||||
return await this.executeWithRetry((client) => {
|
||||
return client.embeddings.create(params, options);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic protocol converter interface for transforming requests and responses
|
||||
* between different AI service protocols.
|
||||
*
|
||||
* @template TInput - Input request format (e.g., OpenAI ChatCompletionCreateParams)
|
||||
* @template TOutput - Output request format (e.g., Gemini GenerateContentRequest)
|
||||
* @template TResponse - Final response format (e.g., OpenAI ChatCompletion)
|
||||
*/
|
||||
export interface ProtocolConverter<TInput, TOutput, TResponse> {
|
||||
/**
|
||||
* Convert input request to target protocol format
|
||||
*/
|
||||
convertRequest(input: TInput): TOutput;
|
||||
|
||||
/**
|
||||
* Convert target protocol response back to standard format
|
||||
*/
|
||||
convertResponse(response: any, originalModel: string): TResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for protocol converters
|
||||
*/
|
||||
export interface ConverterConfig {
|
||||
/** Default model to use when not specified */
|
||||
defaultModel?: string;
|
||||
/** Custom model mapping rules */
|
||||
modelMapping?: Record<string, string>;
|
||||
/** Additional converter-specific options */
|
||||
options?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ApiKeyManager } from './ApiKeyManager';
|
||||
import type { AuthType } from '@/common/api/authType';
|
||||
|
||||
export interface UnifiedChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string;
|
||||
images?: Array<{
|
||||
type: 'image_url';
|
||||
image_url: { url: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RotatingApiClientOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
}
|
||||
|
||||
// Constants for better maintainability
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
const DEFAULT_RETRY_DELAY = 1000;
|
||||
const _RETRYABLE_STATUS_CODES = new Set([401, 429, 503]); // Reserved for future use
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status?: number;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
export abstract class RotatingApiClient<T> {
|
||||
protected apiKeyManager?: ApiKeyManager;
|
||||
protected client?: T;
|
||||
protected readonly createClientFn: (api_key: string) => T;
|
||||
protected readonly options: Required<RotatingApiClientOptions>;
|
||||
protected readonly originalApiKeys: string;
|
||||
|
||||
constructor(
|
||||
api_keys: string,
|
||||
authType: AuthType,
|
||||
createClientFn: (api_key: string) => T,
|
||||
options: RotatingApiClientOptions = {}
|
||||
) {
|
||||
this.originalApiKeys = api_keys;
|
||||
this.createClientFn = createClientFn;
|
||||
this.options = {
|
||||
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
|
||||
retryDelay: options.retryDelay ?? DEFAULT_RETRY_DELAY,
|
||||
};
|
||||
|
||||
if (api_keys && (api_keys.includes(',') || api_keys.includes('\n'))) {
|
||||
this.apiKeyManager = new ApiKeyManager(api_keys, authType);
|
||||
}
|
||||
|
||||
this.initializeClient();
|
||||
}
|
||||
|
||||
protected initializeClient(): void {
|
||||
const api_key = this.getCurrentApiKey();
|
||||
|
||||
if (api_key) {
|
||||
try {
|
||||
this.client = this.createClientFn(api_key);
|
||||
} catch (error) {
|
||||
console.error('[RotatingApiClient] Client initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected getCurrentApiKey(): string | undefined {
|
||||
if (this.apiKeyManager?.hasMultipleKeys()) {
|
||||
return this.apiKeyManager.getCurrentKey();
|
||||
}
|
||||
// For single key case, extract the first key
|
||||
return this.extractFirstKey();
|
||||
}
|
||||
|
||||
private extractFirstKey(): string | undefined {
|
||||
if (!this.originalApiKeys) return undefined;
|
||||
|
||||
if (this.isSingleKey()) {
|
||||
return this.originalApiKeys.trim() || undefined;
|
||||
}
|
||||
|
||||
const keys = this.parseMultipleKeys();
|
||||
return keys[0] || undefined;
|
||||
}
|
||||
|
||||
private isSingleKey(): boolean {
|
||||
return !this.originalApiKeys.includes(',') && !this.originalApiKeys.includes('\n');
|
||||
}
|
||||
|
||||
private parseMultipleKeys(): string[] {
|
||||
return this.originalApiKeys
|
||||
.split(/[,\n]/)
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key);
|
||||
}
|
||||
|
||||
protected isRetryableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
|
||||
const apiError = error as ApiError;
|
||||
// `status`/`code` are both optional; keep the original `||` precedence and
|
||||
// default the result to 0 (a non-retryable value that fails every check
|
||||
// below, matching the prior runtime behavior for an undefined status) so the
|
||||
// numeric range comparison is well-typed.
|
||||
const status = apiError.status || apiError.code || 0;
|
||||
|
||||
// Retry on 401 (unauthorized), 429 (rate limit), 503 (service unavailable), and 5xx errors
|
||||
return status === 401 || status === 429 || status === 503 || (status >= 500 && status < 600);
|
||||
}
|
||||
|
||||
protected delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async executeWithRetry<R>(operation: (client: T) => Promise<R>): Promise<R> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized - no valid API key provided');
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation(this.client);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
const isLastAttempt = attempt === this.options.maxRetries - 1;
|
||||
const canRotateKey = this.apiKeyManager?.hasMultipleKeys() && this.isRetryableError(error) && !isLastAttempt;
|
||||
|
||||
// `canRotateKey` is only truthy when `apiKeyManager` is present (see the
|
||||
// `?.hasMultipleKeys()` guard above), so the `?.` here resolves the same
|
||||
// value while staying type-safe; when it is falsy the `&&` short-circuits
|
||||
// before `rotateKey` is reached, exactly as before.
|
||||
if (canRotateKey && this.apiKeyManager?.rotateKey()) {
|
||||
this.initializeClient();
|
||||
await this.delay(this.options.retryDelay * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.isRetryableError(error) || isLastAttempt) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Regular retry with delay
|
||||
await this.delay(this.options.retryDelay * (attempt + 1));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
hasMultipleKeys(): boolean {
|
||||
return this.apiKeyManager?.hasMultipleKeys() ?? false;
|
||||
}
|
||||
|
||||
getKeyStatus() {
|
||||
return this.apiKeyManager?.getStatus() ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Authentication type for a model provider.
|
||||
*
|
||||
* String values are wire/serialized identifiers — they MUST stay stable, as
|
||||
* they are persisted and compared across the app and backend. (Previously
|
||||
* re-exported from a third-party package; inlined here to drop the dependency.)
|
||||
*/
|
||||
export enum AuthType {
|
||||
LOGIN_WITH_GOOGLE = 'oauth-personal',
|
||||
USE_GEMINI = 'gemini-api-key',
|
||||
USE_VERTEX_AI = 'vertex-ai',
|
||||
LEGACY_CLOUD_SHELL = 'cloud-shell',
|
||||
COMPUTE_ADC = 'compute-default-credentials',
|
||||
USE_OPENAI = 'openai',
|
||||
USE_ANTHROPIC = 'anthropic',
|
||||
USE_BEDROCK = 'bedrock',
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { AcpPermissionRequest, PlanUpdate, ToolCallUpdate } from '@/common/types/platform/acpTypes';
|
||||
import type { IResponseMessage } from '../adapter/ipcBridge';
|
||||
import { uuid } from '../utils';
|
||||
|
||||
/**
|
||||
* 安全的路径拼接函数,兼容Windows和Mac
|
||||
* @param basePath 基础路径
|
||||
* @param relativePath 相对路径
|
||||
* @returns 拼接后的绝对路径
|
||||
*/
|
||||
export const joinPath = (basePath: string, relativePath: string): string => {
|
||||
// 标准化路径分隔符为 /
|
||||
const normalizePath = (path: string) => path.replace(/\\/g, '/');
|
||||
|
||||
const base = normalizePath(basePath);
|
||||
const relative = normalizePath(relativePath);
|
||||
|
||||
// 去掉base路径末尾的斜杠
|
||||
const cleanBase = base.replace(/\/+$/, '');
|
||||
|
||||
// 处理相对路径中的 ./ 和 ../
|
||||
const parts = relative.split('/');
|
||||
const resultParts = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '.' || part === '') {
|
||||
continue; // 跳过 . 和空字符串
|
||||
} else if (part === '..') {
|
||||
// 处理上级目录
|
||||
if (resultParts.length > 0) {
|
||||
resultParts.pop(); // 移除最后一个部分
|
||||
}
|
||||
} else {
|
||||
resultParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接路径
|
||||
const result = cleanBase + '/' + resultParts.join('/');
|
||||
|
||||
// 确保路径格式正确
|
||||
return result.replace(/\/+/g, '/'); // 将多个连续的斜杠替换为单个
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 跟对话相关的消息类型申明 及相关处理
|
||||
*/
|
||||
|
||||
type TMessageType =
|
||||
| 'text'
|
||||
| 'tips'
|
||||
| 'tool_call'
|
||||
| 'tool_group'
|
||||
| 'agent_status'
|
||||
| 'permission'
|
||||
| 'acp_permission'
|
||||
| 'acp_tool_call'
|
||||
| 'plan'
|
||||
| 'thinking'
|
||||
| 'available_commands';
|
||||
|
||||
interface IMessage<T extends TMessageType, Content extends Record<string, any>> {
|
||||
/**
|
||||
* 唯一ID — frontend-local message key (uuid), NOT a backend entity id.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* 消息来源ID,— backend messages.id, stays TEXT (`msg_…`).
|
||||
*/
|
||||
msg_id?: string;
|
||||
|
||||
/** 消息会话ID — conversation primary key, INTEGER (numeric-id spec). */
|
||||
conversation_id: number;
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
type: T;
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
content: Content;
|
||||
/**
|
||||
* 消息创建时间
|
||||
*/
|
||||
created_at?: number;
|
||||
/**
|
||||
* 消息位置
|
||||
*/
|
||||
position?: 'left' | 'right' | 'center' | 'pop';
|
||||
/**
|
||||
* 消息状态
|
||||
*/
|
||||
status?: 'finish' | 'pending' | 'error' | 'work';
|
||||
/**
|
||||
* Hidden from UI display but persisted to DB and sent to agent.
|
||||
*/
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type CronMessageMeta = {
|
||||
source: 'cron';
|
||||
cron_job_id: string;
|
||||
cron_job_name: string;
|
||||
triggered_at: number;
|
||||
};
|
||||
|
||||
export type IMessageText = IMessage<
|
||||
'text',
|
||||
{
|
||||
content: string;
|
||||
/** Backend explicitly replaced the accumulated text for this msg_id. */
|
||||
replace?: boolean;
|
||||
cronMeta?: CronMessageMeta;
|
||||
teammateMessage?: boolean;
|
||||
senderName?: string;
|
||||
senderAgentType?: string;
|
||||
/** Sender teammate's conversation id — lets the renderer resolve preset avatars via their conversation extras. */
|
||||
senderConversationId?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
export type AgentErrorOwnership = 'nomifun' | 'user_agent' | 'user_llm_provider' | 'unknown_upstream';
|
||||
|
||||
export type AgentErrorResolutionKind =
|
||||
| 'retry'
|
||||
| 'wait_for_current_response'
|
||||
| 'start_new_session'
|
||||
| 'reconnect_agent'
|
||||
| 'check_agent_login'
|
||||
| 'check_agent_installation'
|
||||
| 'check_agent_version'
|
||||
| 'check_local_command'
|
||||
| 'check_provider_credentials'
|
||||
| 'check_provider_billing'
|
||||
| 'check_provider_base_url'
|
||||
| 'change_model'
|
||||
| 'reduce_context'
|
||||
| 'send_feedback';
|
||||
|
||||
export type AgentErrorResolutionTarget = 'provider_settings' | 'agent_settings' | 'new_conversation' | 'feedback';
|
||||
|
||||
export type AgentErrorResolution = {
|
||||
kind: AgentErrorResolutionKind;
|
||||
target?: AgentErrorResolutionTarget;
|
||||
};
|
||||
|
||||
export type AgentStreamErrorInfo = {
|
||||
message: string;
|
||||
code?: string;
|
||||
ownership?: AgentErrorOwnership;
|
||||
detail?: string;
|
||||
workspacePath?: string;
|
||||
retryable?: boolean;
|
||||
feedback_recommended?: boolean;
|
||||
resolution?: AgentErrorResolution;
|
||||
};
|
||||
|
||||
export type IMessageTips = IMessage<
|
||||
'tips',
|
||||
{
|
||||
content: string;
|
||||
type: 'error' | 'success' | 'warning';
|
||||
error?: AgentStreamErrorInfo;
|
||||
}
|
||||
>;
|
||||
|
||||
export type IMessageToolCall = IMessage<
|
||||
'tool_call',
|
||||
{
|
||||
call_id: string;
|
||||
name: string;
|
||||
args: Record<string, any>;
|
||||
error?: string;
|
||||
status?: 'running' | 'completed' | 'error';
|
||||
input?: Record<string, any>;
|
||||
output?: string;
|
||||
description?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type IMessageToolGroupConfirmationDetailsBase<Type, Extra extends Record<string, any>> = {
|
||||
type: Type;
|
||||
title: string;
|
||||
} & Extra;
|
||||
|
||||
export type IMessageToolGroup = IMessage<
|
||||
'tool_group',
|
||||
Array<{
|
||||
call_id: string;
|
||||
description: string;
|
||||
name: string;
|
||||
render_output_as_markdown: boolean;
|
||||
result_display?:
|
||||
| string
|
||||
| {
|
||||
file_diff: string;
|
||||
file_name: string;
|
||||
}
|
||||
| {
|
||||
img_url: string;
|
||||
relative_path: string;
|
||||
};
|
||||
status: 'Executing' | 'Success' | 'Error' | 'Canceled' | 'Pending' | 'Confirming';
|
||||
confirmationDetails?:
|
||||
| IMessageToolGroupConfirmationDetailsBase<
|
||||
'edit',
|
||||
{
|
||||
file_name: string;
|
||||
file_diff: string;
|
||||
isModifying?: boolean;
|
||||
}
|
||||
>
|
||||
| IMessageToolGroupConfirmationDetailsBase<
|
||||
'exec',
|
||||
{
|
||||
rootCommand: string;
|
||||
command: string;
|
||||
}
|
||||
>
|
||||
| IMessageToolGroupConfirmationDetailsBase<
|
||||
'info',
|
||||
{
|
||||
urls?: string[];
|
||||
prompt: string;
|
||||
}
|
||||
>
|
||||
| IMessageToolGroupConfirmationDetailsBase<
|
||||
'mcp',
|
||||
{
|
||||
tool_name: string;
|
||||
tool_display_name: string;
|
||||
server_name: string;
|
||||
}
|
||||
>;
|
||||
}>
|
||||
>;
|
||||
|
||||
// Unified agent status message type for all ACP-based agents (Claude, Qwen, Codex, etc.)
|
||||
export type IMessageAgentStatus = IMessage<
|
||||
'agent_status',
|
||||
{
|
||||
backend: string; // Agent identifier: 'claude', 'qwen', 'codex', 'remote', etc.
|
||||
status: 'connecting' | 'connected' | 'authenticated' | 'session_active' | 'error';
|
||||
/** Display name for the agent (e.g. extension-contributed adapter name) / Agent 显示名称 */
|
||||
agent_name?: string;
|
||||
// Optional legacy fields for backward compatibility
|
||||
session_id?: string;
|
||||
is_connected?: boolean;
|
||||
has_active_session?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export type IMessageAcpPermission = IMessage<'acp_permission', AcpPermissionRequest>;
|
||||
|
||||
export type IMessagePermission = IMessage<'permission', IConfirmation>;
|
||||
|
||||
export type IMessageAcpToolCall = IMessage<'acp_tool_call', ToolCallUpdate>;
|
||||
|
||||
export const mergeAcpToolCallContent = (
|
||||
existing: IMessageAcpToolCall['content'],
|
||||
incoming: IMessageAcpToolCall['content']
|
||||
): IMessageAcpToolCall['content'] => ({
|
||||
...existing,
|
||||
...incoming,
|
||||
update: {
|
||||
...existing.update,
|
||||
...incoming.update,
|
||||
},
|
||||
});
|
||||
|
||||
type ResponseTextData = {
|
||||
content: string;
|
||||
replace?: boolean;
|
||||
cronMeta?: CronMessageMeta;
|
||||
teammate_message?: boolean;
|
||||
sender_name?: string;
|
||||
sender_backend?: string;
|
||||
sender_conversation_id?: number;
|
||||
};
|
||||
|
||||
const isResponseTextData = (data: unknown): data is ResponseTextData =>
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'content' in data &&
|
||||
typeof (data as { content?: unknown }).content === 'string';
|
||||
|
||||
export const isTextContentReplacement = (content: IMessageText['content'] | undefined): boolean =>
|
||||
content?.replace === true;
|
||||
|
||||
export const mergeTextMessageContent = (
|
||||
existing: IMessageText['content'],
|
||||
incoming: IMessageText['content']
|
||||
): IMessageText['content'] => {
|
||||
const { replace: _existingReplace, ...existingRest } = existing;
|
||||
const { replace: incomingReplace, ...incomingRest } = incoming;
|
||||
|
||||
return {
|
||||
...existingRest,
|
||||
...incomingRest,
|
||||
content: incomingReplace ? incoming.content : existing.content + incoming.content,
|
||||
...(incomingReplace ? { replace: true } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const preferTextMessageVersion = (primary: IMessageText, secondary: IMessageText): IMessageText => {
|
||||
const primaryIsReplace = isTextContentReplacement(primary.content);
|
||||
const secondaryIsReplace = isTextContentReplacement(secondary.content);
|
||||
|
||||
if (primaryIsReplace !== secondaryIsReplace) {
|
||||
return primaryIsReplace ? primary : secondary;
|
||||
}
|
||||
|
||||
return secondary.content.content.length > primary.content.content.length ? secondary : primary;
|
||||
};
|
||||
|
||||
export type IMessagePlan = IMessage<
|
||||
'plan',
|
||||
{
|
||||
session_id: string;
|
||||
entries: PlanUpdate['update']['entries'];
|
||||
}
|
||||
>;
|
||||
|
||||
export type IMessageThinking = IMessage<
|
||||
'thinking',
|
||||
{
|
||||
content: string;
|
||||
subject?: string;
|
||||
duration?: number;
|
||||
status: 'thinking' | 'done';
|
||||
}
|
||||
>;
|
||||
|
||||
// Available commands from ACP agents (Claude, etc.)
|
||||
export type AvailableCommand = {
|
||||
name: string;
|
||||
description: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export type IMessageAvailableCommands = IMessage<
|
||||
'available_commands',
|
||||
{
|
||||
commands: AvailableCommand[];
|
||||
}
|
||||
>;
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
export type TMessage =
|
||||
| IMessageText
|
||||
| IMessageTips
|
||||
| IMessageToolCall
|
||||
| IMessageToolGroup
|
||||
| IMessageAgentStatus
|
||||
| IMessagePermission
|
||||
| IMessageAcpPermission
|
||||
| IMessageAcpToolCall
|
||||
| IMessagePlan
|
||||
| IMessageThinking
|
||||
| IMessageAvailableCommands;
|
||||
|
||||
// 统一所有需要用户交互的用户类型
|
||||
export interface IConfirmation<Option extends any = any> {
|
||||
title?: string;
|
||||
id: string;
|
||||
action?: string;
|
||||
description: string;
|
||||
call_id: string;
|
||||
options: Array<{
|
||||
label: string;
|
||||
value: Option;
|
||||
params?: Record<string, string>; // Translation interpolation parameters
|
||||
}>;
|
||||
/**
|
||||
* Command type for exec confirmations (e.g., 'curl', 'npm', 'git')
|
||||
* Used for "always allow" permission memory
|
||||
*/
|
||||
command_type?: string;
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const AGENT_ERROR_OWNERSHIPS = new Set<AgentErrorOwnership>([
|
||||
'nomifun',
|
||||
'user_agent',
|
||||
'user_llm_provider',
|
||||
'unknown_upstream',
|
||||
]);
|
||||
|
||||
const AGENT_ERROR_RESOLUTION_KINDS = new Set<AgentErrorResolutionKind>([
|
||||
'retry',
|
||||
'wait_for_current_response',
|
||||
'start_new_session',
|
||||
'reconnect_agent',
|
||||
'check_agent_login',
|
||||
'check_agent_installation',
|
||||
'check_agent_version',
|
||||
'check_local_command',
|
||||
'check_provider_credentials',
|
||||
'check_provider_billing',
|
||||
'check_provider_base_url',
|
||||
'change_model',
|
||||
'reduce_context',
|
||||
'send_feedback',
|
||||
]);
|
||||
|
||||
const AGENT_ERROR_RESOLUTION_TARGETS = new Set<AgentErrorResolutionTarget>([
|
||||
'provider_settings',
|
||||
'agent_settings',
|
||||
'new_conversation',
|
||||
'feedback',
|
||||
]);
|
||||
|
||||
export const normalizeAgentErrorResolution = (value: unknown): AgentErrorResolution | undefined => {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!AGENT_ERROR_RESOLUTION_KINDS.has(value.kind as AgentErrorResolutionKind)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const target =
|
||||
typeof value.target === 'string' && AGENT_ERROR_RESOLUTION_TARGETS.has(value.target as AgentErrorResolutionTarget)
|
||||
? (value.target as AgentErrorResolutionTarget)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
kind: value.kind as AgentErrorResolutionKind,
|
||||
...(target ? { target } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeAgentStreamError = (value: unknown): AgentStreamErrorInfo | undefined => {
|
||||
if (!isObject(value) || typeof value.message !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const code = typeof value.code === 'string' ? value.code : undefined;
|
||||
const ownership =
|
||||
typeof value.ownership === 'string' && AGENT_ERROR_OWNERSHIPS.has(value.ownership as AgentErrorOwnership)
|
||||
? (value.ownership as AgentErrorOwnership)
|
||||
: undefined;
|
||||
const detail = typeof value.detail === 'string' ? value.detail : undefined;
|
||||
const workspacePath = typeof value.workspacePath === 'string' ? value.workspacePath : undefined;
|
||||
const retryable = typeof value.retryable === 'boolean' ? value.retryable : undefined;
|
||||
const feedback_recommended = typeof value.feedback_recommended === 'boolean' ? value.feedback_recommended : undefined;
|
||||
const resolution = normalizeAgentErrorResolution(value.resolution);
|
||||
|
||||
if (
|
||||
!code &&
|
||||
!ownership &&
|
||||
!detail &&
|
||||
!workspacePath &&
|
||||
retryable === undefined &&
|
||||
feedback_recommended === undefined &&
|
||||
!resolution
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
message: value.message,
|
||||
...(code ? { code } : {}),
|
||||
...(ownership ? { ownership } : {}),
|
||||
...(detail ? { detail } : {}),
|
||||
...(workspacePath ? { workspacePath } : {}),
|
||||
...(retryable !== undefined ? { retryable } : {}),
|
||||
...(feedback_recommended !== undefined ? { feedback_recommended } : {}),
|
||||
...(resolution ? { resolution } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 将后端返回的消息转换为前端消息
|
||||
* */
|
||||
export const transformMessage = (message: IResponseMessage): TMessage | undefined => {
|
||||
const created_at = message.created_at ?? Date.now();
|
||||
switch (message.type) {
|
||||
case 'error': {
|
||||
const errorData = message.data;
|
||||
const structuredError = normalizeAgentStreamError(errorData);
|
||||
const errorText =
|
||||
typeof errorData === 'string'
|
||||
? errorData
|
||||
: ((errorData as { message?: string })?.message ?? JSON.stringify(errorData));
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'tips',
|
||||
msg_id: message.msg_id,
|
||||
position: 'center',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: {
|
||||
content: errorText,
|
||||
type: 'error',
|
||||
...(structuredError ? { error: structuredError } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'tips': {
|
||||
const data = message.data as {
|
||||
content: string;
|
||||
type?: 'error' | 'success' | 'warning';
|
||||
error?: unknown;
|
||||
};
|
||||
const tipType = data.type ?? 'warning';
|
||||
const structuredError =
|
||||
tipType === 'error'
|
||||
? (normalizeAgentStreamError(data.error) ?? normalizeAgentStreamError({ ...data, message: data.content }))
|
||||
: undefined;
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'tips',
|
||||
msg_id: message.msg_id,
|
||||
position: 'center',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: {
|
||||
content: data.content,
|
||||
type: tipType,
|
||||
...(structuredError ? { error: structuredError } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'text':
|
||||
case 'content':
|
||||
case 'user_content': {
|
||||
const data = message.data;
|
||||
const isRichData = isResponseTextData(data);
|
||||
const shouldReplace = message.replace === true || (isRichData && data.replace === true);
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'text',
|
||||
msg_id: message.msg_id,
|
||||
position: message.type === 'user_content' ? 'right' : 'left',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: isRichData
|
||||
? {
|
||||
content: data.content,
|
||||
cronMeta: data.cronMeta,
|
||||
...(shouldReplace ? { replace: true } : {}),
|
||||
...(data.teammate_message ? { teammateMessage: true } : {}),
|
||||
...(data.sender_name ? { senderName: data.sender_name } : {}),
|
||||
...(data.sender_backend ? { senderAgentType: data.sender_backend } : {}),
|
||||
...(data.sender_conversation_id != null ? { senderConversationId: data.sender_conversation_id } : {}),
|
||||
}
|
||||
: {
|
||||
content: data as string,
|
||||
...(shouldReplace ? { replace: true } : {}),
|
||||
},
|
||||
...(message.hidden && { hidden: true }),
|
||||
};
|
||||
}
|
||||
case 'tool_call': {
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'tool_call',
|
||||
msg_id: message.msg_id,
|
||||
conversation_id: message.conversation_id,
|
||||
position: 'left',
|
||||
created_at,
|
||||
content: message.data as any,
|
||||
};
|
||||
}
|
||||
case 'tool_group': {
|
||||
return {
|
||||
type: 'tool_group',
|
||||
id: uuid(),
|
||||
msg_id: message.msg_id,
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: message.data as any,
|
||||
};
|
||||
}
|
||||
case 'agent_status': {
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'agent_status',
|
||||
msg_id: message.msg_id,
|
||||
position: 'center',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: message.data as any,
|
||||
};
|
||||
}
|
||||
case 'permission': {
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'permission',
|
||||
msg_id: message.msg_id,
|
||||
position: 'left',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: message.data as any,
|
||||
};
|
||||
}
|
||||
case 'acp_permission': {
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'acp_permission',
|
||||
msg_id: message.msg_id,
|
||||
position: 'left',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: message.data as any,
|
||||
};
|
||||
}
|
||||
case 'acp_tool_call': {
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'acp_tool_call',
|
||||
msg_id: message.msg_id,
|
||||
position: 'left',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: message.data as any,
|
||||
};
|
||||
}
|
||||
case 'plan': {
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'plan',
|
||||
msg_id: message.msg_id,
|
||||
position: 'left',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: message.data as any,
|
||||
};
|
||||
}
|
||||
case 'thinking': {
|
||||
const data = message.data as {
|
||||
content: string;
|
||||
subject?: string;
|
||||
duration?: number;
|
||||
duration_ms?: number;
|
||||
status: 'thinking' | 'done';
|
||||
};
|
||||
return {
|
||||
id: uuid(),
|
||||
type: 'thinking',
|
||||
msg_id: message.msg_id,
|
||||
position: 'left',
|
||||
conversation_id: message.conversation_id,
|
||||
created_at,
|
||||
content: {
|
||||
content: data.content,
|
||||
subject: data.subject,
|
||||
duration: data.duration ?? data.duration_ms,
|
||||
status: data.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Disabled: available_commands messages are too noisy and distracting in the chat UI
|
||||
case 'available_commands':
|
||||
return undefined;
|
||||
case 'start':
|
||||
case 'finish':
|
||||
case 'thought':
|
||||
case 'skill_suggest':
|
||||
case 'cron_trigger':
|
||||
case 'info': // Stream retry notifications and similar transient agent updates
|
||||
case 'system': // Cron system responses, ignored
|
||||
case 'acp_model_info': // Model info updates, handled by AcpModelSelector
|
||||
case 'codex_model_info': // Legacy Codex model info updates
|
||||
case 'acp_context_usage': // Context usage updates, handled by AcpSendBox
|
||||
case 'request_trace': // Request trace events, logged to F12 console (not persisted)
|
||||
return undefined;
|
||||
default: {
|
||||
console.warn(
|
||||
`[transformMessage] Unsupported message type '${message.type}'. All non-standard message types should be pre-processed by respective AgentManagers.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 将消息合并到消息列表中
|
||||
* */
|
||||
export const composeMessage = (
|
||||
message: TMessage | undefined,
|
||||
list: TMessage[] | undefined,
|
||||
messageHandler: (type: 'update' | 'insert', message: TMessage) => void = () => {}
|
||||
): TMessage[] => {
|
||||
if (!message) return list || [];
|
||||
if (!list?.length) {
|
||||
messageHandler('insert', message);
|
||||
return [message];
|
||||
}
|
||||
const last = list[list.length - 1];
|
||||
|
||||
const updateMessage = (index: number, message: TMessage, change = true) => {
|
||||
message.id = list[index].id;
|
||||
list[index] = message;
|
||||
if (change) messageHandler('update', message);
|
||||
return list.slice();
|
||||
};
|
||||
const pushMessage = (message: TMessage) => {
|
||||
list.push(message);
|
||||
messageHandler('insert', message);
|
||||
return list.slice();
|
||||
};
|
||||
|
||||
if (message.type === 'tool_group') {
|
||||
const remainingToolsMap = new Map(message.content.map((t) => [t.call_id, t] as const));
|
||||
if (remainingToolsMap.size === 0) return list;
|
||||
|
||||
const updatesToReport: TMessage[] = [];
|
||||
|
||||
const updatedList = list.map((existingMessage) => {
|
||||
if (existingMessage.type !== 'tool_group') return existingMessage;
|
||||
if (!existingMessage.content.length) return existingMessage;
|
||||
|
||||
let didMergeIntoThisMessage = false;
|
||||
const new_content = existingMessage.content.map((tool) => {
|
||||
const newToolData = remainingToolsMap.get(tool.call_id);
|
||||
if (!newToolData) return tool;
|
||||
didMergeIntoThisMessage = true;
|
||||
remainingToolsMap.delete(tool.call_id);
|
||||
// Create new object instead of mutating original
|
||||
return { ...tool, ...newToolData };
|
||||
});
|
||||
|
||||
if (!didMergeIntoThisMessage) return existingMessage;
|
||||
const updatedMessage = { ...existingMessage, content: new_content } as TMessage;
|
||||
updatesToReport.push(updatedMessage);
|
||||
return updatedMessage;
|
||||
});
|
||||
|
||||
const didUpdateExisting = updatesToReport.length > 0;
|
||||
for (const updatedMessage of updatesToReport) {
|
||||
messageHandler('update', updatedMessage);
|
||||
}
|
||||
|
||||
const baseList = didUpdateExisting ? updatedList : list;
|
||||
|
||||
// If there are new tool calls, append them as a new tool_group message (without mutating inputs)
|
||||
if (remainingToolsMap.size > 0) {
|
||||
const newTools = Array.from(remainingToolsMap.values());
|
||||
const insertMessage = { ...message, content: newTools } as TMessage;
|
||||
messageHandler('insert', insertMessage);
|
||||
return baseList.concat(insertMessage);
|
||||
}
|
||||
// No new tools appended; return a new list only if something was updated
|
||||
return didUpdateExisting ? baseList : list;
|
||||
}
|
||||
|
||||
// Handle Gemini tool_call message merging
|
||||
if (message.type === 'tool_call') {
|
||||
for (let i = 0, len = list.length; i < len; i++) {
|
||||
const msg = list[i];
|
||||
if (msg.type === 'tool_call' && msg.content.call_id === message.content.call_id) {
|
||||
// Create new object instead of mutating original
|
||||
return updateMessage(i, { ...msg, content: { ...msg.content, ...message.content } });
|
||||
}
|
||||
}
|
||||
// If no existing tool call found, add new one
|
||||
return pushMessage(message);
|
||||
}
|
||||
|
||||
// Handle acp_tool_call message merging
|
||||
if (message.type === 'acp_tool_call') {
|
||||
for (let i = 0, len = list.length; i < len; i++) {
|
||||
const msg = list[i];
|
||||
if (msg.type === 'acp_tool_call' && msg.content.update?.tool_call_id === message.content.update?.tool_call_id) {
|
||||
// Create new object instead of mutating original
|
||||
const merged = mergeAcpToolCallContent(msg.content, message.content);
|
||||
return updateMessage(i, { ...msg, content: merged });
|
||||
}
|
||||
}
|
||||
// If no existing tool call found, add new one
|
||||
return pushMessage(message);
|
||||
}
|
||||
|
||||
if (message.type === 'plan') {
|
||||
for (let i = 0, len = list.length; i < len; i++) {
|
||||
const msg = list[i];
|
||||
if (msg.type === 'plan' && msg.content.session_id === message.content.session_id) {
|
||||
// Create new object instead of mutating original
|
||||
const merged = { ...msg.content, ...message.content };
|
||||
return updateMessage(i, { ...msg, content: merged });
|
||||
}
|
||||
}
|
||||
return pushMessage(message);
|
||||
// If no existing plan found, add new one
|
||||
}
|
||||
|
||||
// Handle thinking message merging — only merge contiguous streaming chunks
|
||||
if (message.type === 'thinking') {
|
||||
if (message.content.status === 'done') {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const msg = list[i];
|
||||
if (msg.type !== 'thinking' || msg.msg_id !== message.msg_id) continue;
|
||||
|
||||
const merged = {
|
||||
...msg.content,
|
||||
status: 'done' as const,
|
||||
duration: message.content.duration,
|
||||
subject: message.content.subject || msg.content.subject,
|
||||
};
|
||||
return updateMessage(i, { ...msg, content: merged });
|
||||
}
|
||||
}
|
||||
|
||||
if (last.type === 'thinking' && last.msg_id === message.msg_id) {
|
||||
// Otherwise append content
|
||||
const merged = {
|
||||
...last.content,
|
||||
content: last.content.content + message.content.content,
|
||||
subject: message.content.subject || last.content.subject,
|
||||
};
|
||||
return updateMessage(list.length - 1, { ...last, content: merged });
|
||||
}
|
||||
return pushMessage(message);
|
||||
}
|
||||
|
||||
if (last.msg_id !== message.msg_id || last.type !== message.type) {
|
||||
return pushMessage(message);
|
||||
}
|
||||
if (message.type === 'text' && last.type === 'text') {
|
||||
message.content = mergeTextMessageContent(last.content, message.content);
|
||||
}
|
||||
return updateMessage(list.length - 1, Object.assign({}, last, message));
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared image generation logic used by both:
|
||||
* - The built-in MCP server (imageGenServer.ts)
|
||||
* - The legacy Gemini-specific tool (img-gen.ts)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { jsonrepair } from 'jsonrepair';
|
||||
import type OpenAI from 'openai';
|
||||
import { ClientFactory, type RotatingClient } from '@/common/api/ClientFactory';
|
||||
import type { TProviderWithModel } from '@/common/config/storage';
|
||||
import type { UnifiedChatCompletionResponse } from '@/common/api/RotatingApiClient';
|
||||
import { IMAGE_EXTENSIONS, MIME_TYPE_MAP, MIME_TO_EXT_MAP, DEFAULT_IMAGE_EXTENSION } from '@/common/config/constants';
|
||||
|
||||
const API_TIMEOUT_MS = 120000; // 2 minutes for image generation API calls
|
||||
|
||||
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number];
|
||||
|
||||
// ===== Utility Functions =====
|
||||
|
||||
export function safeJsonParse<T = unknown>(jsonString: string, fallbackValue: T): T {
|
||||
if (!jsonString || typeof jsonString !== 'string') {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonString) as T;
|
||||
} catch (_error) {
|
||||
try {
|
||||
const repairedJson = jsonrepair(jsonString);
|
||||
return JSON.parse(repairedJson) as T;
|
||||
} catch (_repairError) {
|
||||
console.warn('[ImageGen] JSON parse failed:', jsonString.substring(0, 50));
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isImageFile(file_path: string): boolean {
|
||||
const ext = path.extname(file_path).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext as ImageExtension);
|
||||
}
|
||||
|
||||
export function isHttpUrl(str: string): boolean {
|
||||
return str.startsWith('http://') || str.startsWith('https://');
|
||||
}
|
||||
|
||||
export async function fileToBase64(file_path: string): Promise<string> {
|
||||
try {
|
||||
const fileBuffer = await fs.promises.readFile(file_path);
|
||||
return fileBuffer.toString('base64');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('ENOENT') || errorMessage.includes('no such file')) {
|
||||
throw new Error(`Image file not found: ${file_path}`, { cause: error });
|
||||
}
|
||||
throw new Error(`Failed to read image file: ${errorMessage}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageMimeType(file_path: string): string {
|
||||
const ext = path.extname(file_path).toLowerCase();
|
||||
return MIME_TYPE_MAP[ext] || MIME_TYPE_MAP[DEFAULT_IMAGE_EXTENSION];
|
||||
}
|
||||
|
||||
export function getFileExtensionFromDataUrl(dataUrl: string): string {
|
||||
const mimeTypeMatch = dataUrl.match(/^data:image\/([^;]+);base64,/);
|
||||
if (mimeTypeMatch && mimeTypeMatch[1]) {
|
||||
const mimeType = mimeTypeMatch[1].toLowerCase();
|
||||
return MIME_TO_EXT_MAP[mimeType] || DEFAULT_IMAGE_EXTENSION;
|
||||
}
|
||||
return DEFAULT_IMAGE_EXTENSION;
|
||||
}
|
||||
|
||||
export async function saveGeneratedImage(base64Data: string, workspaceDir: string): Promise<string> {
|
||||
const timestamp = Date.now();
|
||||
const fileExtension = getFileExtensionFromDataUrl(base64Data);
|
||||
const file_name = `img-${timestamp}${fileExtension}`;
|
||||
const file_path = path.join(workspaceDir, file_name);
|
||||
|
||||
const base64WithoutPrefix = base64Data.replace(/^data:image\/[^;]+;base64,/, '');
|
||||
const imageBuffer = Buffer.from(base64WithoutPrefix, 'base64');
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(file_path, imageBuffer);
|
||||
return file_path;
|
||||
} catch (error) {
|
||||
console.error('[ImageGen] Failed to save image file:', error);
|
||||
throw new Error(`Failed to save image: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Image Content Processing =====
|
||||
|
||||
interface ImageContent {
|
||||
type: 'image_url';
|
||||
image_url: {
|
||||
url: string;
|
||||
detail: 'auto' | 'low' | 'high';
|
||||
};
|
||||
}
|
||||
|
||||
export async function processImageUri(imageUri: string, workspaceDir: string): Promise<ImageContent | null> {
|
||||
if (isHttpUrl(imageUri)) {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: { url: imageUri, detail: 'auto' },
|
||||
};
|
||||
}
|
||||
|
||||
let processedUri = imageUri;
|
||||
if (imageUri.startsWith('@')) {
|
||||
processedUri = imageUri.substring(1);
|
||||
}
|
||||
|
||||
let fullPath = processedUri;
|
||||
if (!path.isAbsolute(processedUri)) {
|
||||
fullPath = path.join(workspaceDir, processedUri);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(fullPath, fs.constants.F_OK);
|
||||
|
||||
if (!isImageFile(fullPath)) {
|
||||
throw new Error(`File is not a supported image type: ${fullPath}`);
|
||||
}
|
||||
|
||||
const base64Data = await fileToBase64(fullPath);
|
||||
const mimeType = getImageMimeType(fullPath);
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${mimeType};base64,${base64Data}`, detail: 'auto' },
|
||||
};
|
||||
} catch (error) {
|
||||
const possiblePaths = [imageUri, path.join(workspaceDir, imageUri)].filter((p, i, arr) => arr.indexOf(p) === i);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (errorMessage.includes('Image file not found') || errorMessage.includes('not a supported image type')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Image file not found. Searched paths:\n${possiblePaths.map((p) => `- ${p}`).join('\n')}\n\nPlease ensure the image file exists and has a valid image extension (.jpg, .png, .gif, .webp, etc.)`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Core Execution =====
|
||||
|
||||
export interface ImageGenParams {
|
||||
prompt: string;
|
||||
image_uris?: string[] | string;
|
||||
}
|
||||
|
||||
export interface ImageGenResult {
|
||||
success: boolean;
|
||||
text: string;
|
||||
imagePath?: string;
|
||||
relativeImagePath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core image generation function shared between MCP server and Gemini tool.
|
||||
*/
|
||||
export async function executeImageGeneration(
|
||||
params: ImageGenParams,
|
||||
provider: TProviderWithModel,
|
||||
workspaceDir: string,
|
||||
proxy?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ImageGenResult> {
|
||||
if (signal?.aborted) {
|
||||
return { success: false, text: 'Image generation was cancelled.', error: 'cancelled' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse image URIs
|
||||
let imageUris: string[] = [];
|
||||
if (params.image_uris) {
|
||||
if (typeof params.image_uris === 'string') {
|
||||
const parsed = safeJsonParse<string[] | null>(params.image_uris, null);
|
||||
imageUris = Array.isArray(parsed) ? parsed : [params.image_uris];
|
||||
} else if (Array.isArray(params.image_uris)) {
|
||||
imageUris = params.image_uris;
|
||||
}
|
||||
}
|
||||
|
||||
const hasImages = imageUris.length > 0;
|
||||
let enhancedPrompt: string;
|
||||
if (hasImages) {
|
||||
enhancedPrompt = `Analyze/Edit image: ${params.prompt}`;
|
||||
} else {
|
||||
enhancedPrompt = `Generate image: ${params.prompt}`;
|
||||
}
|
||||
|
||||
const contentParts: OpenAI.Chat.Completions.ChatCompletionContentPart[] = [{ type: 'text', text: enhancedPrompt }];
|
||||
|
||||
// Process image URIs
|
||||
if (hasImages) {
|
||||
const imageResults = await Promise.allSettled(imageUris.map((uri) => processImageUri(uri, workspaceDir)));
|
||||
|
||||
const successful: ImageContent[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
imageResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
successful.push(result.value);
|
||||
} else {
|
||||
const error = result.status === 'rejected' ? result.reason : 'Unknown error';
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Image ${index + 1} (${imageUris[index]}): ${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
successful.forEach((imageContent) => contentParts.push(imageContent));
|
||||
|
||||
if (successful.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
text: `Error: Failed to process any images. Errors:\n${errors.join('\n')}`,
|
||||
error: errors.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [{ role: 'user', content: contentParts }];
|
||||
|
||||
// Create client and call API
|
||||
const rotatingClient: RotatingClient = await ClientFactory.createRotatingClient(provider, {
|
||||
proxy,
|
||||
rotatingOptions: { maxRetries: 3, retryDelay: 1000 },
|
||||
});
|
||||
|
||||
// `createChatCompletion` is typed as a union of the per-provider return
|
||||
// shapes; the OpenAI SDK's `ChatCompletion` differs only in that `content`
|
||||
// is `string | null` (handled by the `|| ...` fallback below) and `images`
|
||||
// is absent (handled by the `!images` guard below). The runtime object is
|
||||
// already consumed as the unified shape, so this assertion is type-only.
|
||||
const completion = (await rotatingClient.createChatCompletion(
|
||||
{ model: provider.use_model, messages: messages as any },
|
||||
{ signal, timeout: API_TIMEOUT_MS }
|
||||
)) as UnifiedChatCompletionResponse;
|
||||
|
||||
const choice = completion.choices[0];
|
||||
if (!choice) {
|
||||
return { success: false, text: 'No response from image generation API', error: 'No response' };
|
||||
}
|
||||
|
||||
const responseText = choice.message.content || 'Image generated successfully.';
|
||||
let images = choice.message.images;
|
||||
|
||||
// Extract images from markdown in content if not in images field
|
||||
if ((!images || images.length === 0) && responseText) {
|
||||
const dataUrlRegex = /!\[[^\]]*\]\((data:image\/[^;]+;base64,[^)]+)\)/g;
|
||||
const dataUrlMatches = [...responseText.matchAll(dataUrlRegex)];
|
||||
if (dataUrlMatches.length > 0) {
|
||||
images = dataUrlMatches.map((match) => ({
|
||||
type: 'image_url' as const,
|
||||
image_url: { url: match[1] },
|
||||
}));
|
||||
} else {
|
||||
const file_pathRegex = /!\[[^\]]*\]\(([^)]+\.(?:jpg|jpeg|png|gif|webp|bmp|tiff|svg))\)/gi;
|
||||
const file_pathMatches = [...responseText.matchAll(file_pathRegex)];
|
||||
if (file_pathMatches.length > 0) {
|
||||
const processedImages: Array<{ type: 'image_url'; image_url: { url: string } }> = [];
|
||||
for (const match of file_pathMatches) {
|
||||
const file_path = match[1];
|
||||
const fullPath = path.isAbsolute(file_path) ? file_path : path.join(workspaceDir, file_path);
|
||||
try {
|
||||
await fs.promises.access(fullPath);
|
||||
const base64Data = await fileToBase64(fullPath);
|
||||
const mimeType = getImageMimeType(fullPath);
|
||||
processedImages.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${mimeType};base64,${base64Data}` },
|
||||
});
|
||||
} catch (_fileError) {
|
||||
console.warn(`[ImageGen] Could not load image file: ${file_path}`);
|
||||
}
|
||||
}
|
||||
if (processedImages.length > 0) {
|
||||
images = processedImages;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
const warningMessage = `Image generation did not produce any images.\n\nModel response: ${responseText}\n\nTip: Make sure your image generation model supports this type of request. Current model: ${provider.use_model}`;
|
||||
return { success: true, text: warningMessage };
|
||||
}
|
||||
|
||||
const firstImage = images[0];
|
||||
if (firstImage.type === 'image_url' && firstImage.image_url?.url) {
|
||||
const imagePath = await saveGeneratedImage(firstImage.image_url.url, workspaceDir);
|
||||
const relativeImagePath = path.relative(workspaceDir, imagePath);
|
||||
|
||||
// Strip any inline base64 data URLs from the human-readable text before
|
||||
// returning. The image is already saved to disk and referenced by path,
|
||||
// so re-emitting hundreds of MB of base64 in the MCP tool response just
|
||||
// forces the parent process to ship that payload through framed TCP again
|
||||
// (which is where the 2026-04-14 commit-charge blow-up happened).
|
||||
const cleanText = responseText.replace(
|
||||
/!\[[^\]]*\]\(data:image\/[^;]+;base64,[^)]+\)/g,
|
||||
'[embedded image extracted]'
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: `${cleanText}\n\nGenerated image saved to: ${imagePath}`,
|
||||
imagePath,
|
||||
relativeImagePath,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, text: responseText };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
return { success: false, text: 'Image generation was cancelled.', error: 'cancelled' };
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ImageGen] API call failed:`, error);
|
||||
return { success: false, text: `Error generating image: ${errorMessage}`, error: errorMessage };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeToolCall } from './normalizeToolCall';
|
||||
|
||||
describe('normalizeToolCall', () => {
|
||||
it('ignores tool_call messages without call_id', () => {
|
||||
const result = normalizeToolCall({
|
||||
type: 'tool_call',
|
||||
content: {
|
||||
call_id: '',
|
||||
name: 'Glob',
|
||||
status: 'running',
|
||||
args: { pattern: '*.rs' },
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { IMessageAcpToolCall, IMessageToolCall, IMessageToolGroup } from './chatLib';
|
||||
|
||||
export type NormalizedToolStatus = 'pending' | 'running' | 'completed' | 'error' | 'canceled';
|
||||
|
||||
export interface NormalizedToolCall {
|
||||
key: string;
|
||||
name: string;
|
||||
status: NormalizedToolStatus;
|
||||
description?: string;
|
||||
input?: string;
|
||||
output?: string;
|
||||
truncated?: boolean;
|
||||
messageId?: string;
|
||||
conversationId?: number;
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== tool_group → NormalizedToolCall[] =====
|
||||
|
||||
function normalizeToolGroupStatus(status: string): NormalizedToolStatus {
|
||||
switch (status) {
|
||||
case 'Success':
|
||||
return 'completed';
|
||||
case 'Error':
|
||||
return 'error';
|
||||
case 'Canceled':
|
||||
return 'canceled';
|
||||
case 'Pending':
|
||||
return 'pending';
|
||||
case 'Executing':
|
||||
case 'Confirming':
|
||||
default:
|
||||
return 'running';
|
||||
}
|
||||
}
|
||||
|
||||
const getResultDisplayText = (
|
||||
result_display: IMessageToolGroup['content'][0]['result_display']
|
||||
): string | undefined => {
|
||||
if (!result_display) return undefined;
|
||||
if (typeof result_display === 'string') return result_display;
|
||||
if ('file_diff' in result_display) return result_display.file_diff;
|
||||
if ('img_url' in result_display) return result_display.relative_path || result_display.img_url;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function normalizeToolGroup(message: IMessageToolGroup): NormalizedToolCall[] {
|
||||
if (!Array.isArray(message.content)) return [];
|
||||
return message.content.map(({ name, call_id, description, confirmationDetails, status, result_display }) => {
|
||||
let desc = typeof description === 'string' ? description.slice(0, 100) : '';
|
||||
// Guard on `confirmationDetails` so the discriminant `type` narrows the
|
||||
// union directly off the object; previously `type` was aliased through
|
||||
// optional chaining, which left `confirmationDetails` possibly-undefined.
|
||||
// The branches only ran when it was present before, so behavior is unchanged.
|
||||
if (confirmationDetails) {
|
||||
const type = confirmationDetails.type;
|
||||
if (type === 'edit') desc = confirmationDetails.file_name;
|
||||
if (type === 'exec') desc = confirmationDetails.command;
|
||||
if (type === 'info') desc = confirmationDetails.urls?.join(';') || confirmationDetails.title;
|
||||
if (type === 'mcp') desc = confirmationDetails.server_name + ':' + confirmationDetails.tool_name;
|
||||
}
|
||||
|
||||
let input: string | undefined;
|
||||
if (confirmationDetails) {
|
||||
const { title: _title, type: _type, ...rest } = confirmationDetails;
|
||||
if (Object.keys(rest).length) input = formatValue(rest);
|
||||
} else if (description) {
|
||||
input = description;
|
||||
}
|
||||
|
||||
return {
|
||||
key: call_id,
|
||||
name,
|
||||
status: normalizeToolGroupStatus(status),
|
||||
description: desc,
|
||||
input,
|
||||
output: getResultDisplayText(result_display),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ===== acp_tool_call → NormalizedToolCall =====
|
||||
|
||||
function normalizeAcpStatus(status: string): NormalizedToolStatus {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
case 'in_progress':
|
||||
return 'running';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
const buildParamSummary = (kind: string, rawInput?: Record<string, unknown>): string | undefined => {
|
||||
if (!rawInput) return undefined;
|
||||
|
||||
if (kind === 'read' || kind === 'edit') {
|
||||
return (rawInput.file_path as string) || (rawInput.path as string) || (rawInput.file_name as string);
|
||||
}
|
||||
if (kind === 'execute') {
|
||||
return rawInput.command as string;
|
||||
}
|
||||
if (kind === 'search' || kind === 'grep') {
|
||||
const parts: string[] = [];
|
||||
if (rawInput.pattern) parts.push(`"${rawInput.pattern}"`);
|
||||
if (rawInput.path) parts.push(`in ${rawInput.path}`);
|
||||
else if (rawInput.glob) parts.push(`in ${rawInput.glob}`);
|
||||
return parts.length > 0 ? parts.join(' ') : undefined;
|
||||
}
|
||||
if (kind === 'glob') {
|
||||
const parts: string[] = [];
|
||||
if (rawInput.pattern) parts.push(`${rawInput.pattern}`);
|
||||
if (rawInput.path) parts.push(`in ${rawInput.path}`);
|
||||
return parts.length > 0 ? parts.join(' ') : undefined;
|
||||
}
|
||||
if (kind === 'write') {
|
||||
return (rawInput.file_path as string) || (rawInput.path as string);
|
||||
}
|
||||
|
||||
for (const key of ['file_path', 'command', 'path', 'pattern', 'query', 'url']) {
|
||||
if (rawInput[key] && typeof rawInput[key] === 'string') return rawInput[key] as string;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type AcpToolCallUpdateCompat = IMessageAcpToolCall['content']['update'] & {
|
||||
session_update?: string;
|
||||
raw_input?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AcpToolCallContentCompat = IMessageAcpToolCall['content'] & {
|
||||
_compact?: {
|
||||
truncated?: boolean;
|
||||
original_size?: number;
|
||||
preview_chars?: number;
|
||||
};
|
||||
update?: AcpToolCallUpdateCompat;
|
||||
};
|
||||
|
||||
export function normalizeAcpToolCall(message: IMessageAcpToolCall): NormalizedToolCall | undefined {
|
||||
const content = message.content as AcpToolCallContentCompat | undefined;
|
||||
const update = content?.update;
|
||||
if (!update) return undefined;
|
||||
|
||||
const rawInput = update.rawInput ?? update.raw_input;
|
||||
const input = rawInput ? formatValue(rawInput) : undefined;
|
||||
|
||||
let output: string | undefined;
|
||||
if (Array.isArray(update.content) && update.content.length) {
|
||||
output = update.content
|
||||
.map((item) => {
|
||||
if (item.type === 'content' && item.content?.text) return item.content.text;
|
||||
if (item.type === 'diff' && 'path' in item) return `[diff] ${item.path}`;
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const keyParam = buildParamSummary(update.kind, rawInput);
|
||||
|
||||
return {
|
||||
key: update.tool_call_id,
|
||||
name: update.title,
|
||||
status: normalizeAcpStatus(update.status),
|
||||
description: keyParam || (rawInput?.command as string) || update.kind,
|
||||
input,
|
||||
output,
|
||||
truncated: content?._compact?.truncated === true,
|
||||
messageId: message.id,
|
||||
conversationId: message.conversation_id,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== tool_call → NormalizedToolCall =====
|
||||
|
||||
function normalizeToolCallStatus(status?: string): NormalizedToolStatus {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'running':
|
||||
return 'running';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeToolCall(message: IMessageToolCall): NormalizedToolCall | undefined {
|
||||
const { call_id, name, status, input, output, args, description } = message.content;
|
||||
if (!call_id) return undefined;
|
||||
|
||||
const displayInput = input
|
||||
? formatValue(input)
|
||||
: args && Object.keys(args).length > 0
|
||||
? formatValue(args)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
key: call_id,
|
||||
name,
|
||||
status: normalizeToolCallStatus(status),
|
||||
description: description || undefined,
|
||||
input: displayInput,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Unified entry =====
|
||||
|
||||
export type ToolMessage = IMessageToolGroup | IMessageAcpToolCall | IMessageToolCall;
|
||||
|
||||
export function normalizeToolMessages(messages: ToolMessage[]): NormalizedToolCall[] {
|
||||
return messages
|
||||
.flatMap((m) => {
|
||||
if (m.type === 'tool_group') return normalizeToolGroup(m);
|
||||
if (m.type === 'acp_tool_call') return normalizeAcpToolCall(m);
|
||||
if (m.type === 'tool_call') return normalizeToolCall(m);
|
||||
return undefined;
|
||||
})
|
||||
.filter((item): item is NormalizedToolCall => item !== undefined);
|
||||
}
|
||||
|
||||
export function hasRunningToolMessages(messages: ToolMessage[]): boolean {
|
||||
return messages.some((m) => {
|
||||
if (m.type === 'tool_group') {
|
||||
return Array.isArray(m.content) && m.content.some((t) => normalizeToolGroupStatus(t.status) === 'running');
|
||||
}
|
||||
if (m.type === 'acp_tool_call') {
|
||||
return m.content?.update && normalizeAcpStatus(m.content.update.status) === 'running';
|
||||
}
|
||||
if (m.type === 'tool_call') {
|
||||
return normalizeToolCallStatus(m.content?.status) === 'running';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { TChatConversation } from '@/common/config/storage';
|
||||
|
||||
type SideQuestionConversationType = TChatConversation['type'];
|
||||
|
||||
export type SideQuestionEligibilityTarget = {
|
||||
backend?: string;
|
||||
type: SideQuestionConversationType;
|
||||
};
|
||||
|
||||
export function isSideQuestionSupported(target: SideQuestionEligibilityTarget): boolean {
|
||||
return target.type === 'acp' && target.backend === 'claude';
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Input parameters for determining slash command list availability.
|
||||
*/
|
||||
export interface SlashCommandListAvailabilityInput {
|
||||
/** Type of conversation (e.g., 'gemini', 'codex', 'acp') */
|
||||
conversation_type?: string;
|
||||
/** Current status for Codex conversations */
|
||||
codexStatus?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the slash command autocomplete list should be enabled.
|
||||
*
|
||||
* Slash commands are supported by ACP and nomi agent types. The backend's
|
||||
* `/slash-commands` endpoint returns an empty list for other agent types
|
||||
* (openclaw-gateway / nanobot / remote), so calling it from those is waste
|
||||
* (and additionally 404s when the agent has not been warmed up yet).
|
||||
*
|
||||
* Special case for Codex (an ACP vendor): commands are only available when the
|
||||
* session is fully active (`session_active`), because Codex CLI does not
|
||||
* support command queries during the connection phase.
|
||||
*
|
||||
* @param input - Conversation type and status information
|
||||
* @returns true if slash commands should be enabled
|
||||
*/
|
||||
export function isSlashCommandListEnabled(input: SlashCommandListAvailabilityInput): boolean {
|
||||
if (input.conversation_type === 'codex') {
|
||||
return input.codexStatus === 'session_active';
|
||||
}
|
||||
return input.conversation_type === 'acp' || input.conversation_type === 'nomi';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Defines how a slash command is executed.
|
||||
* - `template`: Expands into a prompt template text
|
||||
* - `builtin`: Executes a built-in application action (e.g., /open for file picker)
|
||||
*/
|
||||
export type SlashCommandKind = 'template' | 'builtin';
|
||||
|
||||
/**
|
||||
* Defines what happens when the user selects a slash command from the menu.
|
||||
* - `execute`: run the command immediately
|
||||
* - `insert`: insert `/<name> ` into the input
|
||||
*/
|
||||
export type SlashCommandSelectionBehavior = 'execute' | 'insert';
|
||||
|
||||
/**
|
||||
* Indicates where the slash command originates from.
|
||||
* - `acp`: Provided by the ACP agent (e.g., Claude)
|
||||
* - `builtin`: Built into the application
|
||||
*/
|
||||
export type SlashCommandSource = 'acp' | 'builtin';
|
||||
|
||||
/**
|
||||
* Represents a single slash command item in the autocomplete list.
|
||||
*/
|
||||
export interface SlashCommandItem {
|
||||
/** Command name without the leading slash (e.g., "open", "test") */
|
||||
name: string;
|
||||
/** Human-readable description shown in the dropdown */
|
||||
description: string;
|
||||
/** How the command is executed */
|
||||
kind: SlashCommandKind;
|
||||
/** Where the command comes from */
|
||||
source: SlashCommandSource;
|
||||
/** Optional keyboard hint (e.g., "⌘O") */
|
||||
hint?: string;
|
||||
/** Optional override for how selection behaves in the slash menu */
|
||||
selectionBehavior?: SlashCommandSelectionBehavior;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { getPlatformServices } from '@/common/platform';
|
||||
|
||||
/**
|
||||
* Returns baseName unchanged in release builds, or baseName + '-dev' in dev builds.
|
||||
* When NOMIFUN_MULTI_INSTANCE=1, appends '-2' to isolate the second dev instance.
|
||||
* Used to isolate symlink and directory names between environments.
|
||||
*
|
||||
* @example
|
||||
* getEnvAwareName('.nomifun') // release → '.nomifun', dev → '.nomifun-dev'
|
||||
* getEnvAwareName('.nomifun-config') // release → '.nomifun-config', dev → '.nomifun-config-dev'
|
||||
* // with NOMIFUN_MULTI_INSTANCE=1: dev → '.nomifun-dev-2'
|
||||
*/
|
||||
export function getEnvAwareName(baseName: string): string {
|
||||
if (getPlatformServices().paths.isPackaged() === true) return baseName;
|
||||
const suffix = process.env.NOMIFUN_MULTI_INSTANCE === '1' ? '-dev-2' : '-dev';
|
||||
return `${baseName}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { AcpInitializeResult, AcpSessionConfigOption, AcpSessionModes } from '@/common/types/platform/acpTypes';
|
||||
import type { SpeechToTextConfig } from '@/common/types/provider/speech';
|
||||
import type { ICssTheme, IMcpServer, TProviderWithModel } from '@/common/config/storage';
|
||||
|
||||
export type ConfigKeyMap = {
|
||||
'google.config': {
|
||||
proxy?: string;
|
||||
};
|
||||
'codex.config':
|
||||
| { cli_path?: string; yoloMode?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' }
|
||||
| undefined;
|
||||
'acp.config': {
|
||||
[backend: string]: {
|
||||
auth_methodId?: string;
|
||||
authToken?: string;
|
||||
lastAuthTime?: number;
|
||||
cli_path?: string;
|
||||
yoloMode?: boolean;
|
||||
preferredMode?: string;
|
||||
preferredModelId?: string;
|
||||
promptTimeout?: number;
|
||||
};
|
||||
};
|
||||
'acp.promptTimeout': number | undefined;
|
||||
'acp.agentIdleTimeout': number | undefined;
|
||||
'acp.cachedInitializeResult': Record<string, AcpInitializeResult> | undefined;
|
||||
'acp.cached_config_options': Record<string, AcpSessionConfigOption[]> | undefined;
|
||||
'acp.cachedModes': Record<string, AcpSessionModes> | undefined;
|
||||
'mcp.config': IMcpServer[];
|
||||
language: string;
|
||||
theme: string;
|
||||
colorScheme: string;
|
||||
'ui.zoomFactor': number | undefined;
|
||||
'window.bounds': { x?: number; y?: number; width: number; height: number } | undefined;
|
||||
'webui.desktop.enabled': boolean | undefined;
|
||||
'webui.desktop.allowRemote': boolean | undefined;
|
||||
'webui.desktop.port': number | undefined;
|
||||
customCss: string;
|
||||
'css.themes': ICssTheme[];
|
||||
'css.activeThemeId': string;
|
||||
'nomi.config': { preferredMode?: string } | undefined;
|
||||
'nomi.defaultModel': { id: string; use_model: string } | undefined;
|
||||
// Default provider+model for the knowledge-base AI description/overview
|
||||
// generators (autogen / description.generate / description.polish). Empty
|
||||
// value = let the backend fall back to its own default completer model.
|
||||
'knowledge.autogenModel': { provider_id: string; model: string } | undefined;
|
||||
'tools.imageGenerationModel': TProviderWithModel & { switch?: boolean };
|
||||
'tools.speechToText': SpeechToTextConfig | undefined;
|
||||
'workspace.pasteConfirm': boolean | undefined;
|
||||
'upload.saveToWorkspace': boolean | undefined;
|
||||
'guid.lastSelectedAgent': string | undefined;
|
||||
'system.notificationEnabled': boolean | undefined;
|
||||
'system.cronNotificationEnabled': boolean | undefined;
|
||||
'system.keepAwake': boolean | undefined;
|
||||
'system.autoPreviewOfficeFiles': boolean | undefined;
|
||||
// 发送键偏好:'enter'=Enter 发送/Shift+Enter 换行(默认);'mod-enter'=Ctrl/⌘+Enter 发送、Enter 换行
|
||||
'chat.sendKey': 'enter' | 'mod-enter' | undefined;
|
||||
// Desktop control (computer-use): gates the nomi engine's Computer tool
|
||||
// (observe/click/type/launch). Read by the backend agent factory per session.
|
||||
'agent.computerUse': boolean | undefined;
|
||||
// Browser control (browser-use): gates the nomi engine's built-in browser
|
||||
// tools (native CDP engine). Off by default; enabling it fetches Chrome on
|
||||
// first use. Read by the backend agent factory per session.
|
||||
'agent.browserUse': boolean | undefined;
|
||||
// Persistent login (browser-use sub-setting): keeps cookies/storage across
|
||||
// sessions in an encrypted vault. ON by default. When on, evaluate full-power
|
||||
// mode is blocked (security mutex). Read by the backend browser engine.
|
||||
'agent.browserUse.persistentLogin': boolean | undefined;
|
||||
// Full-power browser evaluate mode: unlocks arbitrary page-script evaluation.
|
||||
// OFF by default and mutually exclusive with persistent login on the backend.
|
||||
'agent.browserUse.fullPower': boolean | undefined;
|
||||
// Site memory (browser-use sub-setting): persists per-site interaction hints to
|
||||
// disk + injects them into the agent's context. OFF by default (opt-in,
|
||||
// privacy-relevant). Read by the backend browser factory.
|
||||
'agent.browserUse.siteMemory': boolean | undefined;
|
||||
// Human takeover / approval (browser-use sub-setting): irreversible browser
|
||||
// actions + gated cross-origin POSTs are held for the user's approval instead of
|
||||
// hard-blocked. OFF by default (opt-in). Read by the backend agent factory.
|
||||
'agent.browserUse.takeover': boolean | undefined;
|
||||
// Visual fallback (browser-use sub-setting): when DOM/aria anchoring fails, the
|
||||
// agent screenshots the page and asks the vision model to locate the target, then
|
||||
// clicks the mapped point. OFF by default (opt-in, vision-token cost). Read by the
|
||||
// backend agent factory.
|
||||
'agent.browserUse.visualFallback': boolean | undefined;
|
||||
'assistant.telegram.agent':
|
||||
| { agent_type: string; backend?: string; id?: string; custom_agent_id?: string; name?: string }
|
||||
| undefined;
|
||||
// Master-agent greeter companion per IM platform (mirror of the backend
|
||||
// client-preference written by POST /api/channel/settings/companion).
|
||||
// Empty/missing = no binding → no companion greets this platform's channel.
|
||||
'assistant.telegram.companionId': string | undefined;
|
||||
'assistant.lark.agent':
|
||||
| { agent_type: string; backend?: string; id?: string; custom_agent_id?: string; name?: string }
|
||||
| undefined;
|
||||
'assistant.lark.companionId': string | undefined;
|
||||
'assistant.dingtalk.agent':
|
||||
| { agent_type: string; backend?: string; id?: string; custom_agent_id?: string; name?: string }
|
||||
| undefined;
|
||||
'assistant.dingtalk.companionId': string | undefined;
|
||||
'assistant.weixin.agent':
|
||||
| { agent_type: string; backend?: string; id?: string; custom_agent_id?: string; name?: string }
|
||||
| undefined;
|
||||
'assistant.weixin.companionId': string | undefined;
|
||||
'assistant.wecom.agent':
|
||||
| { agent_type: string; backend?: string; id?: string; custom_agent_id?: string; name?: string }
|
||||
| undefined;
|
||||
'assistant.wecom.companionId': string | undefined;
|
||||
'skillsMarket.enabled': boolean | undefined;
|
||||
// One-shot completion flags for legacy → backend migrations. Kept in the
|
||||
// local config file (not the backend client-preferences bag) so a downgrade
|
||||
// to a pre-flag build still re-reads the legacy data unchanged. See
|
||||
// `migrateProviders` / `migrateAssistantsToBackend` (ELECTRON-1KT).
|
||||
'migration.providersMigrated_v1': boolean | undefined;
|
||||
'migration.assistantsMigrated_v1': boolean | undefined;
|
||||
};
|
||||
|
||||
export type ConfigKey = keyof ConfigKeyMap;
|
||||
@@ -0,0 +1,359 @@
|
||||
import { ipcBridge } from '@/common';
|
||||
import { httpRequest } from '@/common/adapter/httpBridge';
|
||||
import type { CreateProviderRequest } from '@/common/types/provider/providerApi';
|
||||
|
||||
import type { ConfigKey, ConfigKeyMap } from './configKeys';
|
||||
import type { IConfigStorageRefer, IMcpServer } from './storage';
|
||||
import { BUILTIN_IMAGE_GEN_ID, BUILTIN_IMAGE_GEN_LEGACY_NAMES, BUILTIN_IMAGE_GEN_NAME } from './storage';
|
||||
|
||||
export type ConfigFile = {
|
||||
get<K extends keyof IConfigStorageRefer>(key: K): Promise<IConfigStorageRefer[K]>;
|
||||
set<K extends keyof IConfigStorageRefer>(key: K, value: IConfigStorageRefer[K]): Promise<unknown>;
|
||||
};
|
||||
|
||||
const LEGACY_MCP_CONFIG_KEY = 'mcp.config' as const;
|
||||
|
||||
type LegacyMcpConfigFile = ConfigFile & {
|
||||
get(key: typeof LEGACY_MCP_CONFIG_KEY): Promise<unknown>;
|
||||
set(key: typeof LEGACY_MCP_CONFIG_KEY, value: unknown): Promise<unknown>;
|
||||
};
|
||||
|
||||
const ALL_LEGACY_KEYS: ConfigKey[] = [
|
||||
'codex.config',
|
||||
'acp.config',
|
||||
'acp.promptTimeout',
|
||||
'acp.agentIdleTimeout',
|
||||
'acp.cachedInitializeResult',
|
||||
'acp.cached_config_options',
|
||||
'acp.cachedModes',
|
||||
'language',
|
||||
'theme',
|
||||
'colorScheme',
|
||||
'ui.zoomFactor',
|
||||
'webui.desktop.enabled',
|
||||
'webui.desktop.allowRemote',
|
||||
'webui.desktop.port',
|
||||
'customCss',
|
||||
'css.themes',
|
||||
'css.activeThemeId',
|
||||
'nomi.config',
|
||||
'nomi.defaultModel',
|
||||
'tools.imageGenerationModel',
|
||||
'tools.speechToText',
|
||||
'workspace.pasteConfirm',
|
||||
'upload.saveToWorkspace',
|
||||
'guid.lastSelectedAgent',
|
||||
'skillsMarket.enabled',
|
||||
'system.notificationEnabled',
|
||||
'system.cronNotificationEnabled',
|
||||
'system.keepAwake',
|
||||
'system.autoPreviewOfficeFiles',
|
||||
'assistant.telegram.agent',
|
||||
'assistant.lark.agent',
|
||||
'assistant.dingtalk.agent',
|
||||
'assistant.weixin.agent',
|
||||
'assistant.wecom.agent',
|
||||
];
|
||||
|
||||
export async function migrateConfigStorage(configFile: ConfigFile): Promise<void> {
|
||||
const entries: Record<string, unknown> = {};
|
||||
|
||||
const legacyEntries = await Promise.all(
|
||||
ALL_LEGACY_KEYS.map(async (key) => {
|
||||
try {
|
||||
const value = await configFile.get(key as keyof IConfigStorageRefer);
|
||||
return [key, value] as const;
|
||||
} catch {
|
||||
return [key, undefined] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const [key, value] of legacyEntries) {
|
||||
if (value !== undefined && value !== null) {
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
console.info('[Migration] configStorage migration skipped — no legacy keys found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge strategy: only write keys that don't already exist in the backend DB.
|
||||
// This prevents overwriting user's runtime changes on repeated migrations.
|
||||
const existing = await fetchExistingClientKeys();
|
||||
const newEntries: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!(key in existing)) {
|
||||
newEntries[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newEntries).length > 0) {
|
||||
await setBackendClientPreferences(newEntries);
|
||||
console.info(
|
||||
'[Migration] configStorage migration completed, migrated %d/%d keys (skipped %d existing)',
|
||||
Object.keys(newEntries).length,
|
||||
Object.keys(entries).length,
|
||||
Object.keys(entries).length - Object.keys(newEntries).length
|
||||
);
|
||||
} else {
|
||||
console.info(
|
||||
'[Migration] configStorage migration skipped — all %d keys already exist in backend',
|
||||
Object.keys(entries).length
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateLegacyMcpConfigToDb(configFile: ConfigFile): Promise<void> {
|
||||
const legacyConfigFile = configFile as LegacyMcpConfigFile;
|
||||
const backendPrefs = await fetchExistingClientKeys();
|
||||
const backendLegacy = backendPrefs[LEGACY_MCP_CONFIG_KEY];
|
||||
const fileLegacy = await legacyConfigFile.get(LEGACY_MCP_CONFIG_KEY).catch((): undefined => undefined);
|
||||
const legacyServers = Array.isArray(backendLegacy) ? backendLegacy : Array.isArray(fileLegacy) ? fileLegacy : [];
|
||||
|
||||
if (legacyServers.length === 0) {
|
||||
console.info('[Migration] legacy MCP migration skipped — no legacy servers found');
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await ipcBridge.mcpService.listServers.invoke();
|
||||
const existingNames = new Set((existing ?? []).map((server) => server.name));
|
||||
const importableServers = legacyServers.filter(isImportableMcpServer).map(normalizeLegacyMcpServer);
|
||||
const missing = importableServers.filter((server) => !existingNames.has(server.name));
|
||||
|
||||
console.info(
|
||||
'[Migration] legacy MCP migration found %d servers, importing %d missing, skipping %d existing',
|
||||
legacyServers.length,
|
||||
missing.length,
|
||||
legacyServers.length - missing.length
|
||||
);
|
||||
|
||||
if (missing.length > 0) {
|
||||
await ipcBridge.mcpService.batchImportServers.invoke({ servers: missing });
|
||||
}
|
||||
|
||||
await setBackendClientPreferences({ [LEGACY_MCP_CONFIG_KEY]: null });
|
||||
await legacyConfigFile.set(LEGACY_MCP_CONFIG_KEY, []);
|
||||
}
|
||||
|
||||
function isImportableMcpServer(
|
||||
server: unknown
|
||||
): server is Partial<IMcpServer> & Pick<IMcpServer, 'name' | 'transport'> {
|
||||
if (!server || typeof server !== 'object') return false;
|
||||
const candidate = server as Partial<IMcpServer>;
|
||||
return typeof candidate.name === 'string' && candidate.name.length > 0 && Boolean(candidate.transport);
|
||||
}
|
||||
|
||||
function normalizeLegacyMcpServer(
|
||||
server: Partial<IMcpServer> & Pick<IMcpServer, 'name' | 'transport'>
|
||||
): Partial<IMcpServer> & Pick<IMcpServer, 'name' | 'transport'> {
|
||||
const isLegacyImageGen =
|
||||
server.builtin === true &&
|
||||
(String(server.id) === BUILTIN_IMAGE_GEN_ID ||
|
||||
server.name === BUILTIN_IMAGE_GEN_NAME ||
|
||||
BUILTIN_IMAGE_GEN_LEGACY_NAMES.includes(server.name as (typeof BUILTIN_IMAGE_GEN_LEGACY_NAMES)[number]));
|
||||
|
||||
if (!isLegacyImageGen) return server;
|
||||
|
||||
return {
|
||||
...server,
|
||||
name: BUILTIN_IMAGE_GEN_NAME,
|
||||
builtin: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider migration — reads legacy `model.config` from local config file
|
||||
// and writes each entry to the backend via `POST /api/providers`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LegacyModelHealth = Record<
|
||||
string,
|
||||
{
|
||||
status: 'unknown' | 'healthy' | 'unhealthy';
|
||||
lastCheck?: number;
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type LegacyBedrockConfig = {
|
||||
authMethod: 'accessKey' | 'profile';
|
||||
region: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
profile?: string;
|
||||
};
|
||||
|
||||
type LegacyProvider = {
|
||||
id: string;
|
||||
platform: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string[];
|
||||
enabled?: boolean;
|
||||
capabilities?: CreateProviderRequest['capabilities'];
|
||||
contextLimit?: number;
|
||||
modelProtocols?: Record<string, string>;
|
||||
modelEnabled?: Record<string, boolean>;
|
||||
modelHealth?: LegacyModelHealth;
|
||||
bedrockConfig?: LegacyBedrockConfig;
|
||||
};
|
||||
|
||||
function transformModelHealth(health: LegacyModelHealth): CreateProviderRequest['model_health'] {
|
||||
const result: NonNullable<CreateProviderRequest['model_health']> = {};
|
||||
for (const [key, value] of Object.entries(health)) {
|
||||
result[key] = {
|
||||
status: value.status,
|
||||
last_check: value.lastCheck,
|
||||
latency: value.latency,
|
||||
error: value.error,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local config file key that records "the legacy → backend provider migration
|
||||
* has already completed once on this machine". Once set, {@link migrateProviders}
|
||||
* is a no-op for the remaining lifetime of this install — even if the user
|
||||
* later deletes a provider through the UI (the deletion goes to the backend
|
||||
* DB; the legacy `model.config` on disk is left intact for downgrade safety
|
||||
* and must NOT be replayed). See ELECTRON-1KT.
|
||||
*/
|
||||
const PROVIDERS_MIGRATION_FLAG = 'migration.providersMigrated_v1' as const;
|
||||
|
||||
export async function migrateProviders(configFile: ConfigFile): Promise<void> {
|
||||
// Idempotency guard: once the flag is set, never replay legacy providers.
|
||||
// Without this, deletions made by the user post-migration would be silently
|
||||
// undone on every launch as the legacy `model.config` is still on disk
|
||||
// (kept on purpose so the user can downgrade to a pre-backend Electron build).
|
||||
let alreadyMigrated = false;
|
||||
try {
|
||||
alreadyMigrated = Boolean(await configFile.get(PROVIDERS_MIGRATION_FLAG));
|
||||
} catch {
|
||||
// Flag missing or read failed — proceed as if first run; we'll set the
|
||||
// flag at the end of a successful pass.
|
||||
}
|
||||
if (alreadyMigrated) {
|
||||
console.info('[Migration] providers migration skipped — completion flag already set');
|
||||
return;
|
||||
}
|
||||
|
||||
let legacyProviders: LegacyProvider[];
|
||||
try {
|
||||
legacyProviders = (await configFile.get(
|
||||
'model.config' as keyof IConfigStorageRefer
|
||||
)) as unknown as LegacyProvider[];
|
||||
} catch (err) {
|
||||
console.info('[Migration] providers migration skipped — no model.config in config file', err);
|
||||
// Nothing to migrate ever again on this machine — flag it so future launches
|
||||
// skip the read entirely and we don't risk a stray legacy file appearing later
|
||||
// (e.g. via a settings restore from backup) re-injecting deleted providers.
|
||||
await markProvidersMigrationDone(configFile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!legacyProviders || !Array.isArray(legacyProviders) || legacyProviders.length === 0) {
|
||||
console.info('[Migration] providers migration skipped — model.config is empty or invalid');
|
||||
await markProvidersMigrationDone(configFile);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await ipcBridge.mode.listProviders.invoke();
|
||||
const existingIds = new Set((existing ?? []).map((p: { id: string }) => p.id));
|
||||
|
||||
const newProviders = legacyProviders.filter((p) => !existingIds.has(p.id));
|
||||
if (newProviders.length === 0) {
|
||||
console.info(
|
||||
'[Migration] providers migration skipped — all %d legacy providers already exist in backend',
|
||||
legacyProviders.length
|
||||
);
|
||||
// Backend already has every legacy id — migration is effectively done.
|
||||
await markProvidersMigrationDone(configFile);
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
'[Migration] found %d new legacy providers to migrate (skipping %d existing)',
|
||||
newProviders.length,
|
||||
legacyProviders.length - newProviders.length
|
||||
);
|
||||
|
||||
const requests = newProviders.map((legacy) => ({
|
||||
legacy,
|
||||
req: {
|
||||
id: legacy.id,
|
||||
platform: legacy.platform,
|
||||
name: legacy.name,
|
||||
base_url: legacy.baseUrl,
|
||||
api_key: legacy.apiKey,
|
||||
models: legacy.model,
|
||||
enabled: legacy.enabled ?? true,
|
||||
capabilities: legacy.capabilities,
|
||||
context_limit: legacy.contextLimit,
|
||||
model_protocols: legacy.modelProtocols,
|
||||
model_enabled: legacy.modelEnabled,
|
||||
model_health: legacy.modelHealth ? transformModelHealth(legacy.modelHealth) : undefined,
|
||||
bedrock_config: legacy.bedrockConfig
|
||||
? {
|
||||
auth_method: legacy.bedrockConfig.authMethod,
|
||||
region: legacy.bedrockConfig.region,
|
||||
access_key_id: legacy.bedrockConfig.accessKeyId,
|
||||
secret_access_key: legacy.bedrockConfig.secretAccessKey,
|
||||
profile: legacy.bedrockConfig.profile,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies CreateProviderRequest,
|
||||
}));
|
||||
|
||||
const results = await Promise.allSettled(requests.map(({ req }) => ipcBridge.mode.createProvider.invoke(req)));
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
migrated += 1;
|
||||
return;
|
||||
}
|
||||
failed += 1;
|
||||
console.warn('[Migration] failed to create provider %s:', requests[index].legacy.id, result.reason);
|
||||
});
|
||||
|
||||
console.info('[Migration] providers migration completed, migrated %d/%d providers', migrated, newProviders.length);
|
||||
|
||||
// Only set the completion flag on a fully clean pass. A partial failure
|
||||
// (e.g. backend returned 5xx for one provider) leaves the flag unset so the
|
||||
// next launch retries just the still-missing rows; that retry is safe
|
||||
// because the existing-by-id filter above already skips any provider the
|
||||
// backend has accepted in the meantime.
|
||||
if (failed === 0) {
|
||||
await markProvidersMigrationDone(configFile);
|
||||
}
|
||||
}
|
||||
|
||||
async function markProvidersMigrationDone(configFile: ConfigFile): Promise<void> {
|
||||
try {
|
||||
await configFile.set(PROVIDERS_MIGRATION_FLAG, true);
|
||||
} catch (err) {
|
||||
// Failure to persist the flag is non-fatal — worst case the migration
|
||||
// re-runs next launch and the existing-by-id filter makes it a no-op.
|
||||
console.warn('[Migration] failed to persist providers migration flag', err);
|
||||
}
|
||||
}
|
||||
|
||||
type BackendClientPreferences = Partial<{ [K in ConfigKey]: ConfigKeyMap[K] | null }> & Record<string, unknown>;
|
||||
|
||||
async function fetchExistingClientKeys(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await httpRequest<Record<string, unknown>>('GET', '/api/settings/client')) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function setBackendClientPreferences(entries: BackendClientPreferences): Promise<void> {
|
||||
await httpRequest<void>('PUT', '/api/settings/client', entries);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { ConfigKey, ConfigKeyMap } from './configKeys';
|
||||
|
||||
type Subscriber = (value: unknown) => void;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__backendPort?: number;
|
||||
__nomiLocalTrust?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
// WebUI browser mode: no preload, fetch same-origin so web-host's
|
||||
// static-server reverse-proxies /api/* to the backend.
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined' && !(window as Window).__backendPort) {
|
||||
return '';
|
||||
}
|
||||
const port = typeof window !== 'undefined' ? (window as Window).__backendPort || 13400 : 13400;
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
/** Read the CSRF double-submit cookie (browser mode) for state-changing requests. */
|
||||
function readCsrfCookie(): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith('nomifun-csrf-token=')) {
|
||||
return decodeURIComponent(trimmed.slice('nomifun-csrf-token='.length));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${getBaseUrl()}${path}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
// Desktop shell: present the per-boot local-trust secret so the backend
|
||||
// (running under TrustLocalToken) recognizes this webview as the trusted
|
||||
// local client. Without it, mutating requests (PUT) are CSRF-rejected (403).
|
||||
// Must match `httpBridge.ts` since configService bypasses that chokepoint.
|
||||
const trustSecret = typeof window !== 'undefined' ? (window as Window).__nomiLocalTrust : undefined;
|
||||
if (trustSecret) {
|
||||
headers['x-nomi-local-trust'] = trustSecret;
|
||||
} else if (
|
||||
typeof document !== 'undefined' &&
|
||||
['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())
|
||||
) {
|
||||
// WebUI browser mode (no trust secret): echo the CSRF cookie.
|
||||
const csrf = readCsrfCookie();
|
||||
if (csrf) headers['x-csrf-token'] = csrf;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`ConfigService ${method} ${path} failed (${response.status}): ${errorBody}`);
|
||||
}
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (!contentType?.includes('application/json')) {
|
||||
return undefined as T;
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json && typeof json === 'object' && 'data' in json) {
|
||||
return json.data as T;
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
class ConfigServiceImpl {
|
||||
private cache = new Map<string, unknown>();
|
||||
private subscribers = new Map<string, Set<Subscriber>>();
|
||||
private initialized = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
// Idempotent: concurrent callers share the same in-flight promise, and a
|
||||
// resolved init returns immediately. Modules that need persisted settings on
|
||||
// module load (theme/colorScheme/language) await whenReady() before reading.
|
||||
//
|
||||
// IMPORTANT: this NEVER rejects. Before login (WebUI remote browser) the
|
||||
// backend returns 401/403 for /api/settings/client, and the network may be
|
||||
// unreachable. Those are expected pre-auth states, not fatal errors — the app
|
||||
// must still render (the login page!) with empty/default config. On failure we
|
||||
// resolve with an empty cache and leave `initialized = false` + clear the
|
||||
// in-flight promise so a later call (e.g. right after login via `reload()`)
|
||||
// re-fetches the authenticated settings.
|
||||
initialize(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
const data = await fetchJson<Record<string, unknown>>('GET', '/api/settings/client');
|
||||
this.cache.clear();
|
||||
if (data) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
}
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
console.warn('[configService] settings unavailable (pre-login or offline); using empty config:', error);
|
||||
this.cache.clear();
|
||||
this.initPromise = null;
|
||||
}
|
||||
})();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/** Force a re-fetch of settings — call right after login, when the backend
|
||||
* starts returning the authenticated client settings. */
|
||||
async reload(): Promise<void> {
|
||||
this.initPromise = null;
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
whenReady(): Promise<void> {
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
get<K extends ConfigKey>(key: K): ConfigKeyMap[K] | undefined {
|
||||
return this.cache.get(key) as ConfigKeyMap[K] | undefined;
|
||||
}
|
||||
|
||||
async set<K extends ConfigKey>(key: K, value: ConfigKeyMap[K]): Promise<void> {
|
||||
this.cache.set(key, value);
|
||||
this.notify(key, value);
|
||||
await fetchJson<void>('PUT', '/api/settings/client', { [key]: value });
|
||||
}
|
||||
|
||||
setLocal<K extends ConfigKey>(key: K, value: ConfigKeyMap[K]): void {
|
||||
this.cache.set(key, value);
|
||||
this.notify(key, value);
|
||||
}
|
||||
|
||||
async remove(key: ConfigKey): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
this.notify(key, undefined);
|
||||
await fetchJson<void>('PUT', '/api/settings/client', { [key]: null });
|
||||
}
|
||||
|
||||
async setBatch(entries: Partial<{ [K in ConfigKey]: ConfigKeyMap[K] }>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
this.cache.set(key, value);
|
||||
this.notify(key as ConfigKey, value);
|
||||
}
|
||||
await fetchJson<void>('PUT', '/api/settings/client', entries);
|
||||
}
|
||||
|
||||
subscribe(key: ConfigKey, callback: Subscriber): () => void {
|
||||
if (!this.subscribers.has(key)) {
|
||||
this.subscribers.set(key, new Set());
|
||||
}
|
||||
this.subscribers.get(key)!.add(callback);
|
||||
return () => {
|
||||
this.subscribers.get(key)?.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cache.clear();
|
||||
this.subscribers.clear();
|
||||
this.initialized = false;
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
private notify(key: ConfigKey, value: unknown): void {
|
||||
const subs = this.subscribers.get(key);
|
||||
if (subs) {
|
||||
for (const cb of subs) {
|
||||
cb(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const configService = new ConfigServiceImpl();
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Nomi应用程序共用常量
|
||||
*/
|
||||
|
||||
// ===== 文件处理相关常量 =====
|
||||
|
||||
/** 用于匹配和清理时间戳后缀的正则表达式 */
|
||||
export const NOMIFUN_TIMESTAMP_REGEX = /_nomifun_\d{13}(\.\w+)?$/;
|
||||
export const NOMIFUN_FILES_MARKER = '[[NOMI_FILES]]';
|
||||
|
||||
// ===== 媒体类型相关常量 =====
|
||||
|
||||
/** 支持的图片文件扩展名 */
|
||||
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg'] as const;
|
||||
|
||||
/** 文件扩展名到MIME类型的映射 */
|
||||
export const MIME_TYPE_MAP: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.tiff': 'image/tiff',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
/** MIME类型到文件扩展名的映射 */
|
||||
export const MIME_TO_EXT_MAP: Record<string, string> = {
|
||||
jpeg: '.jpg',
|
||||
jpg: '.jpg',
|
||||
png: '.png',
|
||||
gif: '.gif',
|
||||
webp: '.webp',
|
||||
bmp: '.bmp',
|
||||
tiff: '.tiff',
|
||||
'svg+xml': '.svg',
|
||||
};
|
||||
|
||||
/** 默认图片文件扩展名 */
|
||||
export const DEFAULT_IMAGE_EXTENSION = '.png';
|
||||
|
||||
// ===== WebUI 相关常量 =====
|
||||
|
||||
/** WebUI default port: 25808 for production, 25809 for development, 25810 for multi-instance dev */
|
||||
export const WEBUI_DEFAULT_PORT = (() => {
|
||||
if (process.env.NODE_ENV === 'production') return 25808;
|
||||
if (process.env.NOMIFUN_MULTI_INSTANCE === '1') return 25810;
|
||||
return 25809;
|
||||
})();
|
||||
|
||||
// ===== AI Provider 相关常量 =====
|
||||
|
||||
// Stable ID for the Google Auth virtual provider.
|
||||
// Shared between frontend (useModelProviderList) and backend (SystemActions).
|
||||
export const GOOGLE_AUTH_PROVIDER_ID = 'google-auth-gemini';
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"referenceLanguage": "en-US",
|
||||
"fallbackLanguage": "en-US",
|
||||
"supportedLanguages": ["zh-CN", "en-US"],
|
||||
"modules": [
|
||||
"common",
|
||||
"agentMode",
|
||||
"update",
|
||||
"login",
|
||||
"fileSelection",
|
||||
"preview",
|
||||
"conversation",
|
||||
"settings",
|
||||
"messages",
|
||||
"mcp",
|
||||
"acp",
|
||||
"codex",
|
||||
"tools",
|
||||
"google",
|
||||
"cron",
|
||||
"requirements",
|
||||
"idmm",
|
||||
"starOffice",
|
||||
"guid",
|
||||
"agent",
|
||||
"team",
|
||||
"terminal",
|
||||
"webhook",
|
||||
"autowork",
|
||||
"nomi",
|
||||
"knowledge"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, normalizeLanguageCode } from './i18n';
|
||||
|
||||
describe('i18n language support', () => {
|
||||
test('only exposes simplified Chinese and English as supported app languages', () => {
|
||||
expect(SUPPORTED_LANGUAGES).toEqual(['zh-CN', 'en-US']);
|
||||
expect(DEFAULT_LANGUAGE).toBe('en-US');
|
||||
});
|
||||
|
||||
test('normalizes removed locales away from their old language codes', () => {
|
||||
expect(normalizeLanguageCode('zh-TW')).toBe('zh-CN');
|
||||
expect(normalizeLanguageCode('ja-JP')).toBe(DEFAULT_LANGUAGE);
|
||||
expect(normalizeLanguageCode('ko-KR')).toBe(DEFAULT_LANGUAGE);
|
||||
expect(normalizeLanguageCode('tr-TR')).toBe(DEFAULT_LANGUAGE);
|
||||
expect(normalizeLanguageCode('ru-RU')).toBe(DEFAULT_LANGUAGE);
|
||||
expect(normalizeLanguageCode('uk-UA')).toBe(DEFAULT_LANGUAGE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared i18n utility functions used by both main process and renderer.
|
||||
*/
|
||||
|
||||
import i18nConfig from '@/common/config/i18n-config.json';
|
||||
|
||||
export const SUPPORTED_LANGUAGES = i18nConfig.supportedLanguages;
|
||||
export const DEFAULT_LANGUAGE = i18nConfig.fallbackLanguage;
|
||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
/**
|
||||
* Normalize a language code to a supported BCP 47 tag.
|
||||
* e.g. 'zh' → 'zh-CN', unsupported locales → fallback language.
|
||||
*/
|
||||
export function normalizeLanguageCode(language: string): SupportedLanguage {
|
||||
const normalized = language.replace(/_/g, '-');
|
||||
|
||||
if (SUPPORTED_LANGUAGES.includes(normalized as SupportedLanguage)) {
|
||||
return normalized as SupportedLanguage;
|
||||
}
|
||||
|
||||
const langOnly = normalized.toLowerCase().split('-')[0];
|
||||
switch (langOnly) {
|
||||
case 'zh':
|
||||
return 'zh-CN';
|
||||
default:
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge `target` into `fallback`, so that any key missing in `target`
|
||||
* falls back to the value in `fallback`.
|
||||
*/
|
||||
export function mergeWithFallback(
|
||||
fallback: Record<string, unknown>,
|
||||
target: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...fallback };
|
||||
|
||||
for (const [key, value] of Object.entries(target)) {
|
||||
const fallbackValue = merged[key];
|
||||
if (isPlainObject(fallbackValue) && isPlainObject(value)) {
|
||||
merged[key] = mergeWithFallback(fallbackValue, value);
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export type LocaleData = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Ensure a resource bundle is loaded, then switch i18next to the given language.
|
||||
* Deduplicates the "load-if-missing + changeLanguage" pattern.
|
||||
*/
|
||||
export async function ensureAndSwitch(
|
||||
i18n: {
|
||||
hasResourceBundle: (lng: string, ns: string) => boolean;
|
||||
addResourceBundle: (lng: string, ns: string, resources: unknown, deep?: boolean, overwrite?: boolean) => unknown;
|
||||
changeLanguage: (lng: string) => Promise<unknown> | unknown;
|
||||
},
|
||||
lang: string,
|
||||
getTranslation: (locale: string) => Record<string, unknown> | Promise<Record<string, unknown>>
|
||||
): Promise<void> {
|
||||
const normalizedLang = normalizeLanguageCode(lang);
|
||||
if (!i18n.hasResourceBundle(normalizedLang, 'translation')) {
|
||||
const translation = await getTranslation(normalizedLang);
|
||||
i18n.addResourceBundle(normalizedLang, 'translation', translation, true, true);
|
||||
}
|
||||
await i18n.changeLanguage(normalizedLang);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { ConfigKeyMap } from './configKeys';
|
||||
import type { IProvider } from './storage';
|
||||
|
||||
export const IMAGE_GEN_ENV_KEYS = {
|
||||
providerId: 'NOMIFUN_IMG_PROVIDER_ID',
|
||||
platform: 'NOMIFUN_IMG_PLATFORM',
|
||||
baseUrl: 'NOMIFUN_IMG_BASE_URL',
|
||||
apiKey: 'NOMIFUN_IMG_API_KEY',
|
||||
model: 'NOMIFUN_IMG_MODEL',
|
||||
} as const;
|
||||
|
||||
type ImageGenerationSelection = Partial<ConfigKeyMap['tools.imageGenerationModel']>;
|
||||
|
||||
export type ImageGenerationMcpEnvResolveSource = 'provider-id' | 'field-match';
|
||||
|
||||
export type ImageGenerationMcpEnvResolveResult =
|
||||
| {
|
||||
ok: true;
|
||||
source: ImageGenerationMcpEnvResolveSource;
|
||||
provider: IProvider;
|
||||
model: string;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason:
|
||||
| 'missing-selection'
|
||||
| 'provider-not-found'
|
||||
| 'model-not-found'
|
||||
| 'ambiguous-provider'
|
||||
| 'no-provider-match';
|
||||
message: string;
|
||||
candidates?: string[];
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(value?: string): string {
|
||||
return (value || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getLegacyField(
|
||||
selection: ImageGenerationSelection | undefined,
|
||||
existingEnv: Record<string, string> | undefined
|
||||
) {
|
||||
return {
|
||||
providerId: selection?.id || existingEnv?.[IMAGE_GEN_ENV_KEYS.providerId],
|
||||
platform: selection?.platform || existingEnv?.[IMAGE_GEN_ENV_KEYS.platform],
|
||||
baseUrl: selection?.base_url || existingEnv?.[IMAGE_GEN_ENV_KEYS.baseUrl],
|
||||
model: selection?.use_model || existingEnv?.[IMAGE_GEN_ENV_KEYS.model],
|
||||
};
|
||||
}
|
||||
|
||||
function providerHasModel(provider: IProvider, model: string): boolean {
|
||||
return Array.isArray(provider.models) && provider.models.includes(model);
|
||||
}
|
||||
|
||||
function buildEnv(provider: IProvider, model: string): Record<string, string> {
|
||||
return {
|
||||
[IMAGE_GEN_ENV_KEYS.providerId]: provider.id,
|
||||
[IMAGE_GEN_ENV_KEYS.platform]: provider.platform,
|
||||
[IMAGE_GEN_ENV_KEYS.baseUrl]: provider.base_url,
|
||||
[IMAGE_GEN_ENV_KEYS.apiKey]: provider.api_key,
|
||||
[IMAGE_GEN_ENV_KEYS.model]: model,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveImageGenerationMcpEnv(
|
||||
selection: ImageGenerationSelection | undefined,
|
||||
providers: IProvider[],
|
||||
existingEnv?: Record<string, string>
|
||||
): ImageGenerationMcpEnvResolveResult {
|
||||
const { providerId, platform, baseUrl, model } = getLegacyField(selection, existingEnv);
|
||||
|
||||
if (!providerId && !platform && !baseUrl && !model) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-selection',
|
||||
message: 'Image generation provider selection is missing.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-selection',
|
||||
message: 'Image generation model selection is missing.',
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId) {
|
||||
const provider = providers.find((item) => item.id === providerId);
|
||||
if (!provider) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'provider-not-found',
|
||||
message: `Image generation provider was not found: ${providerId}`,
|
||||
};
|
||||
}
|
||||
if (!providerHasModel(provider, model)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'model-not-found',
|
||||
message: `Image generation model "${model}" was not found on provider "${provider.id}".`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
source: 'provider-id',
|
||||
provider,
|
||||
model,
|
||||
env: buildEnv(provider, model),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const platformLower = platform?.toLowerCase();
|
||||
const matches = providers.filter((provider) => {
|
||||
if (platformLower && provider.platform.toLowerCase() !== platformLower) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedBaseUrl && normalizeBaseUrl(provider.base_url) !== normalizedBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
return providerHasModel(provider, model);
|
||||
});
|
||||
|
||||
if (matches.length === 1) {
|
||||
const provider = matches[0];
|
||||
return {
|
||||
ok: true,
|
||||
source: 'field-match',
|
||||
provider,
|
||||
model,
|
||||
env: buildEnv(provider, model),
|
||||
};
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'ambiguous-provider',
|
||||
message: `Image generation provider is ambiguous for model "${model}".`,
|
||||
candidates: matches.map((provider) => provider.id),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-provider-match',
|
||||
message: `No provider matches image generation model "${model}".`,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeImageGenerationEnvKeys(env: Record<string, string>): Record<string, string> {
|
||||
const next = { ...env };
|
||||
Object.values(IMAGE_GEN_ENV_KEYS).forEach((key) => {
|
||||
delete next[key];
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { SpeechToTextConfig } from '@/common/types/provider/speech';
|
||||
import type { TMultiAgentConfig } from '@/renderer/pages/conversation/components/multiAgent/multiAgentConfig';
|
||||
import { storage } from '@/platform';
|
||||
|
||||
// 系统配置存储
|
||||
export const ConfigStorage = storage.buildStorage<IConfigStorageRefer>('agent.config');
|
||||
|
||||
// 系统环境变量存储
|
||||
export const EnvStorage = storage.buildStorage<IEnvStorageRefer>('agent.env');
|
||||
|
||||
export interface IConfigStorageRefer {
|
||||
'google.config': {
|
||||
/** Proxy URL for Google OAuth endpoint reachability / Google OAuth 端点代理 */
|
||||
proxy?: string;
|
||||
};
|
||||
'codex.config'?: {
|
||||
cli_path?: string;
|
||||
yoloMode?: boolean;
|
||||
sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
};
|
||||
'acp.config': {
|
||||
[backend: string]: {
|
||||
auth_methodId?: string;
|
||||
authToken?: string;
|
||||
lastAuthTime?: number;
|
||||
cli_path?: string;
|
||||
yoloMode?: boolean;
|
||||
/** Preferred session mode for new conversations / 新会话的默认模式 */
|
||||
preferredMode?: string;
|
||||
/** Preferred model ID for new conversations / 新会话的默认模型 */
|
||||
preferredModelId?: string;
|
||||
/** LLM prompt timeout in seconds (default: 300) / LLM 请求超时时间(秒,默认 300) */
|
||||
promptTimeout?: number;
|
||||
};
|
||||
};
|
||||
/** Global LLM prompt timeout in seconds (default: 300). Per-backend promptTimeout overrides this. */
|
||||
'acp.promptTimeout'?: number;
|
||||
/** Idle timeout in minutes before an ACP agent process is killed to reclaim memory (default: 5). */
|
||||
'acp.agentIdleTimeout'?: number;
|
||||
// Cached initialize results per ACP backend (persisted across sessions)
|
||||
'acp.cachedInitializeResult'?: Record<string, import('@/common/types/platform/acpTypes').AcpInitializeResult>;
|
||||
// Cached config options per ACP backend for Guid page pre-selection
|
||||
'acp.cached_config_options'?: Record<string, import('@/common/types/platform/acpTypes').AcpSessionConfigOption[]>;
|
||||
// Cached modes per ACP backend for Guid page / AgentModeSelector
|
||||
'acp.cachedModes'?: Record<string, import('@/common/types/platform/acpTypes').AcpSessionModes>;
|
||||
'mcp.config'?: IMcpServer[];
|
||||
language: string;
|
||||
theme: string;
|
||||
colorScheme: string;
|
||||
/** Persisted app-wide UI zoom factor for Display settings */
|
||||
'ui.zoomFactor'?: number;
|
||||
/** Last-known main window size and position, restored on next launch */
|
||||
'window.bounds'?: { x?: number; y?: number; width: number; height: number };
|
||||
/** 桌面模式下是否自动启用 WebUI / Auto-enable WebUI in desktop mode */
|
||||
'webui.desktop.enabled'?: boolean;
|
||||
/** 桌面模式下是否允许远程访问 / Allow remote access in desktop mode */
|
||||
'webui.desktop.allowRemote'?: boolean;
|
||||
/** 桌面模式下 WebUI 端口 / WebUI port in desktop mode */
|
||||
'webui.desktop.port'?: number;
|
||||
customCss: string; // 自定义 CSS 样式
|
||||
'css.themes': ICssTheme[]; // 自定义 CSS 主题列表 / Custom CSS themes list
|
||||
'css.activeThemeId': string; // 当前激活的主题 ID / Currently active theme ID
|
||||
'nomi.config'?: {
|
||||
/** Preferred session mode for new conversations / 新会话的默认模式 */
|
||||
preferredMode?: string;
|
||||
};
|
||||
'nomi.defaultModel'?: { id: string; use_model: string };
|
||||
'tools.imageGenerationModel': TProviderWithModel & {
|
||||
/** @deprecated Image generation is now controlled via built-in MCP server toggle */
|
||||
switch?: boolean;
|
||||
};
|
||||
'tools.speechToText'?: SpeechToTextConfig;
|
||||
// 是否在粘贴文件到工作区时询问确认(true = 不再询问)
|
||||
'workspace.pasteConfirm'?: boolean;
|
||||
// 上传的文件是否保存到工作区目录(true = 保存到工作区,false = 保存到缓存目录)
|
||||
'upload.saveToWorkspace'?: boolean;
|
||||
// guid 页面上次选择的 agent 类型 / Last selected agent type on guid page
|
||||
'guid.lastSelectedAgent'?: string;
|
||||
// 任务完成时显示系统通知 / Show system notification when task completes
|
||||
'system.notificationEnabled'?: boolean;
|
||||
// 定时任务完成时显示系统通知 / Show system notification when scheduled task completes
|
||||
'system.cronNotificationEnabled'?: boolean;
|
||||
// 阻止系统休眠以保证定时任务执行 / Prevent system sleep to ensure scheduled tasks run
|
||||
'system.keepAwake'?: boolean;
|
||||
// Automatically preview newly created Office files in the current workspace
|
||||
'system.autoPreviewOfficeFiles'?: boolean;
|
||||
// Telegram assistant agent selection / Telegram 助手所使用的 Agent
|
||||
'assistant.telegram.agent'?: {
|
||||
backend: string;
|
||||
custom_agent_id?: string;
|
||||
name?: string;
|
||||
};
|
||||
// Lark assistant agent selection / Lark 助手所使用的 Agent
|
||||
'assistant.lark.agent'?: {
|
||||
backend: string;
|
||||
custom_agent_id?: string;
|
||||
name?: string;
|
||||
};
|
||||
// DingTalk assistant agent selection / DingTalk 助手所使用的 Agent
|
||||
'assistant.dingtalk.agent'?: {
|
||||
backend: string;
|
||||
custom_agent_id?: string;
|
||||
name?: string;
|
||||
};
|
||||
// WeChat assistant agent selection / WeChat 助手所使用的 Agent
|
||||
'assistant.weixin.agent'?: {
|
||||
backend: string;
|
||||
custom_agent_id?: string;
|
||||
name?: string;
|
||||
};
|
||||
// WeCom assistant agent selection / 企业微信助手所使用的 Agent
|
||||
'assistant.wecom.agent'?: {
|
||||
backend: string;
|
||||
custom_agent_id?: string;
|
||||
name?: string;
|
||||
};
|
||||
// Skills Market: whether the nomifun-skills builtin skill is enabled
|
||||
'skillsMarket.enabled'?: boolean;
|
||||
/**
|
||||
* One-shot completion flag for the legacy `model.config` → backend providers
|
||||
* migration in {@link migrateProviders}. Once `true`, the migration is
|
||||
* short-circuited on subsequent launches so user-deleted providers don't
|
||||
* resurface from the still-on-disk legacy `model.config` (ELECTRON-1KT).
|
||||
* Stored in the local config file (not the backend) so a downgrade to the
|
||||
* pre-flag build still re-reads the legacy data unchanged.
|
||||
*/
|
||||
'migration.providersMigrated_v1'?: boolean;
|
||||
/**
|
||||
* One-shot completion flag for the legacy `assistants` → backend assistants
|
||||
* migration in {@link migrateAssistantsToBackend}. Same rationale as
|
||||
* `migration.providersMigrated_v1` — without it, an assistant the user
|
||||
* deletes after migration would be re-imported on the next launch from the
|
||||
* still-on-disk legacy field.
|
||||
*/
|
||||
'migration.assistantsMigrated_v1'?: boolean;
|
||||
}
|
||||
|
||||
export interface IEnvStorageRefer {
|
||||
'nomifun.dir': {
|
||||
workDir: string;
|
||||
cacheDir: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation source type - identifies where the conversation was created
|
||||
* 会话来源类型 - 标识会话创建的来源
|
||||
*/
|
||||
export type ConversationSource = 'nomifun' | 'telegram' | 'lark' | 'dingtalk' | 'weixin' | 'wecom' | (string & {});
|
||||
|
||||
export type TChatConversationStatus = 'pending' | 'running' | 'finished';
|
||||
export type TConversationRuntimeStateKind = 'idle' | 'starting' | 'running' | 'waiting_confirmation';
|
||||
|
||||
export type TConversationRuntimeSummary = {
|
||||
state: TConversationRuntimeStateKind;
|
||||
can_send_message: boolean;
|
||||
has_task: boolean;
|
||||
task_status?: TChatConversationStatus;
|
||||
is_processing: boolean;
|
||||
pending_confirmations: number;
|
||||
/** Epoch ms when the currently-running turn started, present while
|
||||
* is_processing. Anchors the elapsed-time indicator so it survives view
|
||||
* unmount/remount (tab/session switch) instead of restarting from zero. */
|
||||
processing_started_at?: number;
|
||||
};
|
||||
|
||||
interface IChatConversation<T, Extra> {
|
||||
created_at: number;
|
||||
modified_at: number;
|
||||
name: string;
|
||||
desc?: string;
|
||||
/** Conversation primary key — backend-minted INTEGER AUTOINCREMENT
|
||||
* (numeric-id spec). Rendered as `#N`; never minted on the frontend. */
|
||||
id: number;
|
||||
type: T;
|
||||
extra: Extra;
|
||||
model: TProviderWithModel;
|
||||
status?: TChatConversationStatus | undefined;
|
||||
runtime?: TConversationRuntimeSummary;
|
||||
/** 会话来源,默认为 nomifun / Conversation source, defaults to nomifun */
|
||||
source?: ConversationSource;
|
||||
/** Channel chat isolation ID (e.g. user:xxx, group:xxx) */
|
||||
channel_chat_id?: string;
|
||||
/** Cron job that spawned this conversation (promoted from extra.cronJobId to a
|
||||
* top-level conversations column; mirrored into extra by the API mapper so
|
||||
* existing extra-readers keep working). */
|
||||
cron_job_id?: string;
|
||||
}
|
||||
|
||||
// Token 使用统计数据类型
|
||||
export interface TokenUsageData {
|
||||
total_tokens: number;
|
||||
/** Cumulative input tokens reported by the Nomi session usage payload. */
|
||||
input_tokens?: number;
|
||||
/** Cumulative output tokens reported by the Nomi session usage payload. */
|
||||
output_tokens?: number;
|
||||
/** Tokens written into the provider prompt cache. */
|
||||
cache_creation_tokens?: number;
|
||||
/** Tokens read back from the provider prompt cache. */
|
||||
cache_read_tokens?: number;
|
||||
/** Wall-clock duration of the last turn in milliseconds (optional; nomi
|
||||
* turn_completed carries it). Absent on legacy persisted payloads. */
|
||||
elapsed_ms?: number;
|
||||
/** Current context occupancy (gauge numerator). */
|
||||
context_tokens?: number;
|
||||
/** Effective context budget (gauge denominator). */
|
||||
context_window?: number;
|
||||
}
|
||||
|
||||
export type TChatConversation =
|
||||
| Omit<
|
||||
IChatConversation<
|
||||
'acp',
|
||||
{
|
||||
workspace?: string;
|
||||
backend: string;
|
||||
cli_path?: string;
|
||||
custom_workspace?: boolean;
|
||||
agent_name?: string;
|
||||
custom_agent_id?: string; // UUID for identifying specific custom agent
|
||||
preset_context?: string; // 智能助手的预设规则/提示词 / Preset context from smart assistant
|
||||
/** Skills snapshot for this conversation — authoritative list, written
|
||||
* once at creation. Join with `GET /api/skills` for descriptions. */
|
||||
skills?: string[];
|
||||
/** MCP server id snapshot chosen when the conversation was created. */
|
||||
mcp_server_ids?: number[];
|
||||
/** MCP server name snapshot chosen when the conversation was created. */
|
||||
mcp_servers?: string[];
|
||||
/** Conversation-scoped MCP status snapshot shown in the sendbox menu. */
|
||||
mcp_statuses?: IConversationMcpStatus[];
|
||||
/** Session-only MCP server snapshot persisted at creation time. */
|
||||
session_mcp_servers?: ISessionMcpServer[];
|
||||
/** 预设助手 ID,用于在会话面板显示助手名称和头像 / Preset assistant ID for displaying name and avatar in conversation panel */
|
||||
preset_assistant_id?: string;
|
||||
/** 是否置顶会话 / Whether this conversation is pinned */
|
||||
pinned?: boolean;
|
||||
/** 置顶时间戳(毫秒)/ Pin timestamp in milliseconds */
|
||||
pinned_at?: number;
|
||||
/** ACP 后端的 session UUID,用于会话恢复 / ACP backend session UUID for session resume */
|
||||
acp_session_id?: string;
|
||||
/** Conversation ID that owns the ACP session / 拥有该 ACP session 的会话 ID */
|
||||
acp_session_conversation_id?: number;
|
||||
/** ACP session 最后更新时间 / Last update time of ACP session */
|
||||
acp_session_updated_at?: number;
|
||||
/** Last context usage from usage_update */
|
||||
last_token_usage?: TokenUsageData;
|
||||
/** Context window capacity from usage_update */
|
||||
last_context_limit?: number;
|
||||
/** Persisted session mode for resume support / 持久化的会话模式,用于恢复 */
|
||||
session_mode?: string;
|
||||
/** Persisted model ID for resume support / 持久化的模型 ID,用于恢复 */
|
||||
current_model_id?: string;
|
||||
/** Cached config options from ACP backend / 缓存的 ACP 配置选项 */
|
||||
cached_config_options?: import('@/common/types/platform/acpTypes').AcpSessionConfigOption[];
|
||||
/** Pending config option selections from Guid page / Guid 页面待应用的配置选项 */
|
||||
pending_config_options?: Record<string, string>;
|
||||
/** Legacy marker for pre-provider-probe health-check conversations */
|
||||
is_health_check?: boolean;
|
||||
/** Cron job ID that spawned this conversation */
|
||||
cron_job_id?: string;
|
||||
/** Session-level multi-agent collaboration config (spec §6). Config
|
||||
* only — the runtime team-ensure is gated elsewhere. */
|
||||
multi_agent?: TMultiAgentConfig;
|
||||
}
|
||||
>,
|
||||
'model'
|
||||
>
|
||||
| Omit<
|
||||
IChatConversation<
|
||||
'codex',
|
||||
{
|
||||
workspace?: string;
|
||||
cli_path?: string;
|
||||
custom_workspace?: boolean;
|
||||
sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access'; // Codex sandbox permission mode
|
||||
preset_context?: string; // 智能助手的预设规则/提示词 / Preset context from smart assistant
|
||||
/** Skills snapshot for this conversation — authoritative list, written
|
||||
* once at creation. Join with `GET /api/skills` for descriptions. */
|
||||
skills?: string[];
|
||||
/** 预设助手 ID,用于在会话面板显示助手名称和头像 / Preset assistant ID for displaying name and avatar in conversation panel */
|
||||
preset_assistant_id?: string;
|
||||
/** 是否置顶会话 / Whether this conversation is pinned */
|
||||
pinned?: boolean;
|
||||
/** 置顶时间戳(毫秒)/ Pin timestamp in milliseconds */
|
||||
pinned_at?: number;
|
||||
/** Persisted session mode for resume support / 持久化的会话模式,用于恢复 */
|
||||
session_mode?: string;
|
||||
/** User-selected Codex model from Guid page / 用户在引导页选择的 Codex 模型 */
|
||||
codexModel?: string;
|
||||
/** Legacy marker for pre-provider-probe health-check conversations */
|
||||
is_health_check?: boolean;
|
||||
/** Cron job ID that spawned this conversation */
|
||||
cron_job_id?: string;
|
||||
/** Session-level multi-agent collaboration config (spec §6). */
|
||||
multi_agent?: TMultiAgentConfig;
|
||||
}
|
||||
>,
|
||||
'model'
|
||||
>
|
||||
| Omit<
|
||||
IChatConversation<
|
||||
'openclaw-gateway',
|
||||
{
|
||||
workspace?: string;
|
||||
backend?: string;
|
||||
agent_name?: string;
|
||||
custom_workspace?: boolean;
|
||||
/** Gateway configuration */
|
||||
gateway?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
password?: string;
|
||||
useExternalGateway?: boolean;
|
||||
cli_path?: string;
|
||||
};
|
||||
/** Session key for resume */
|
||||
sessionKey?: string;
|
||||
/** Runtime validation snapshot used for post-switch strong checks */
|
||||
runtimeValidation?: {
|
||||
expectedWorkspace?: string;
|
||||
expectedBackend?: string;
|
||||
expectedAgentName?: string;
|
||||
expectedCliPath?: string;
|
||||
expectedModel?: string;
|
||||
expectedIdentityHash?: string | null;
|
||||
switchedAt?: number;
|
||||
};
|
||||
/** Skills snapshot for this conversation — authoritative list, written
|
||||
* once at creation. Join with `GET /api/skills` for descriptions. */
|
||||
skills?: string[];
|
||||
/** 预设助手 ID / Preset assistant ID */
|
||||
preset_assistant_id?: string;
|
||||
/** 是否置顶会话 / Whether this conversation is pinned */
|
||||
pinned?: boolean;
|
||||
/** 置顶时间戳(毫秒)/ Pin timestamp in milliseconds */
|
||||
pinned_at?: number;
|
||||
/** Legacy marker for pre-provider-probe health-check conversations */
|
||||
is_health_check?: boolean;
|
||||
/** Cron job ID that spawned this conversation */
|
||||
cron_job_id?: string;
|
||||
/** Session-level multi-agent collaboration config (spec §6). */
|
||||
multi_agent?: TMultiAgentConfig;
|
||||
}
|
||||
>,
|
||||
'model'
|
||||
>
|
||||
// Legacy Gemini conversations. Kept solely so that the renderer can
|
||||
// open historical rows with type='gemini' (message history is served
|
||||
// by the shared messages table). The backend factory rejects any
|
||||
// attempt to resume this conversation — see
|
||||
// Nomicore/crates/nomifun-common/src/enums.rs and factory.rs.
|
||||
// Every field is optional because legacy rows shape-varies across
|
||||
// several older Gemini-runtime versions.
|
||||
| Omit<
|
||||
IChatConversation<
|
||||
'gemini',
|
||||
{
|
||||
workspace?: string;
|
||||
custom_workspace?: boolean;
|
||||
agent_name?: string;
|
||||
preset_assistant_id?: string;
|
||||
pinned?: boolean;
|
||||
pinned_at?: number;
|
||||
/** Legacy marker for pre-provider-probe health-check conversations */
|
||||
is_health_check?: boolean;
|
||||
cron_job_id?: string;
|
||||
// Other legacy-only keys (session_mode, preset_rules, etc.)
|
||||
// deliberately omitted — they're not read by the renderer.
|
||||
}
|
||||
>,
|
||||
'model'
|
||||
>
|
||||
| Omit<
|
||||
IChatConversation<
|
||||
'nanobot',
|
||||
{
|
||||
workspace?: string;
|
||||
custom_workspace?: boolean;
|
||||
/** Skills snapshot for this conversation — authoritative list, written
|
||||
* once at creation. Join with `GET /api/skills` for descriptions. */
|
||||
skills?: string[];
|
||||
/** 预设助手 ID / Preset assistant ID */
|
||||
preset_assistant_id?: string;
|
||||
/** 是否置顶会话 / Whether this conversation is pinned */
|
||||
pinned?: boolean;
|
||||
/** 置顶时间戳(毫秒)/ Pin timestamp in milliseconds */
|
||||
pinned_at?: number;
|
||||
/** Legacy marker for pre-provider-probe health-check conversations */
|
||||
is_health_check?: boolean;
|
||||
/** Cron job ID that spawned this conversation */
|
||||
cron_job_id?: string;
|
||||
/** Session-level multi-agent collaboration config (spec §6). */
|
||||
multi_agent?: TMultiAgentConfig;
|
||||
}
|
||||
>,
|
||||
'model'
|
||||
>
|
||||
| Omit<
|
||||
IChatConversation<
|
||||
'remote',
|
||||
{
|
||||
workspace?: string;
|
||||
custom_workspace?: boolean;
|
||||
/** Remote agent config ID (FK to remote_agents table) */
|
||||
remoteAgentId: number;
|
||||
/** Remote session key for resume */
|
||||
sessionKey?: string;
|
||||
/** Skills snapshot for this conversation — authoritative list, written
|
||||
* once at creation. Join with `GET /api/skills` for descriptions. */
|
||||
skills?: string[];
|
||||
/** Preset assistant ID */
|
||||
preset_assistant_id?: string;
|
||||
/** Whether this conversation is pinned */
|
||||
pinned?: boolean;
|
||||
/** Pin timestamp in milliseconds */
|
||||
pinned_at?: number;
|
||||
/** Legacy marker for pre-provider-probe health-check conversations */
|
||||
is_health_check?: boolean;
|
||||
/** Cron job ID that spawned this conversation */
|
||||
cron_job_id?: string;
|
||||
/** Session-level multi-agent collaboration config (spec §6). */
|
||||
multi_agent?: TMultiAgentConfig;
|
||||
}
|
||||
>,
|
||||
'model'
|
||||
>
|
||||
| IChatConversation<
|
||||
'nomi',
|
||||
{
|
||||
workspace: string;
|
||||
custom_workspace?: boolean;
|
||||
proxy?: string;
|
||||
/** System rules injected at initialization */
|
||||
preset_rules?: string;
|
||||
/** Skills snapshot for this conversation — authoritative list, written
|
||||
* once at creation. Join with `GET /api/skills` for descriptions. */
|
||||
skills?: string[];
|
||||
/** MCP server id snapshot chosen when the conversation was created. */
|
||||
mcp_server_ids?: number[];
|
||||
/** MCP server name snapshot chosen when the conversation was created. */
|
||||
mcp_servers?: string[];
|
||||
/** Conversation-scoped MCP status snapshot shown in the sendbox menu. */
|
||||
mcp_statuses?: IConversationMcpStatus[];
|
||||
/** Session-only MCP server snapshot persisted at creation time. */
|
||||
session_mcp_servers?: ISessionMcpServer[];
|
||||
/** Preset assistant ID */
|
||||
preset_assistant_id?: string;
|
||||
/** Whether this conversation is pinned */
|
||||
pinned?: boolean;
|
||||
/** Pin timestamp in milliseconds */
|
||||
pinned_at?: number;
|
||||
/** Max tokens per response */
|
||||
maxTokens?: number;
|
||||
/** Max agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Persisted session mode for resume support */
|
||||
session_mode?: string;
|
||||
/** Legacy marker for pre-provider-probe health-check conversations */
|
||||
is_health_check?: boolean;
|
||||
/** Last token usage stats */
|
||||
last_token_usage?: TokenUsageData;
|
||||
/** Cron job ID that spawned this conversation */
|
||||
cron_job_id?: string;
|
||||
/** Session-level multi-agent collaboration config (spec §6). */
|
||||
multi_agent?: TMultiAgentConfig;
|
||||
}
|
||||
>;
|
||||
|
||||
export type IChatConversationRefer = {
|
||||
'chat.history': TChatConversation[];
|
||||
};
|
||||
|
||||
export type ModelType =
|
||||
| 'text' // 文本对话
|
||||
| 'vision' // 视觉理解
|
||||
| 'function_calling' // 工具调用
|
||||
| 'image_generation' // 图像生成
|
||||
| 'web_search' // 网络搜索
|
||||
| 'reasoning' // 推理模型
|
||||
| 'embedding' // 嵌入模型
|
||||
| 'rerank' // 重排序模型
|
||||
| 'excludeFromPrimary'; // 排除:不适合作为主力模型
|
||||
|
||||
export type ModelCapability = {
|
||||
type: ModelType;
|
||||
/**
|
||||
* 是否为用户手动选择,如果为true,则表示用户手动选择了该类型,否则表示用户手动禁止了该模型;如果为undefined,则表示使用默认值。
|
||||
* 后端按 snake_case 序列化为 `is_user_selected`(见 crates/backend/nomifun-api-types/src/provider.rs 的 ModelCapability,无 rename),此处须与线名一致。
|
||||
*/
|
||||
is_user_selected?: boolean;
|
||||
};
|
||||
|
||||
export interface IProvider {
|
||||
id: string;
|
||||
platform: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: string[];
|
||||
/**
|
||||
* 模型能力标签列表。打了标签就是支持,没打就是不支持
|
||||
*/
|
||||
capabilities?: ModelCapability[];
|
||||
/**
|
||||
* 上下文token限制,可选字段,只在明确知道时填写
|
||||
*/
|
||||
context_limit?: number;
|
||||
/**
|
||||
* 每个模型的协议覆盖配置。映射模型名称到协议字符串。
|
||||
* 仅在 platform 为 'new-api' 时使用。
|
||||
* Per-model protocol overrides. Maps model name to protocol string.
|
||||
* Only used when platform is 'new-api'.
|
||||
* e.g. { "gemini-2.5-pro": "gemini", "claude-sonnet-4": "anthropic", "gpt-4o": "openai" }
|
||||
*/
|
||||
model_protocols?: Record<string, string>;
|
||||
/**
|
||||
* AWS Bedrock specific configuration
|
||||
* Only used when platform is 'bedrock'
|
||||
*/
|
||||
bedrock_config?: {
|
||||
auth_method: 'accessKey' | 'profile';
|
||||
region: string;
|
||||
// For access key method
|
||||
access_key_id?: string;
|
||||
secret_access_key?: string;
|
||||
// For profile method
|
||||
profile?: string;
|
||||
};
|
||||
/**
|
||||
* 供应商启用状态,默认为 true
|
||||
* Provider enabled state, defaults to true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* 各个模型的启用状态,默认全部为 true
|
||||
* Individual model enabled states, defaults to all true
|
||||
*/
|
||||
model_enabled?: Record<string, boolean>;
|
||||
/**
|
||||
* 各个模型的健康检测结果(仅用于 UI 显示,不影响启用状态)
|
||||
* Model health check results (for UI display only, does not affect enabled state)
|
||||
*/
|
||||
model_health?: Record<
|
||||
string,
|
||||
{
|
||||
status: 'unknown' | 'healthy' | 'unhealthy';
|
||||
last_check?: number; // 时间戳 / timestamp
|
||||
latency?: number; // 延迟时间(毫秒)/ latency in milliseconds
|
||||
error?: string; // 错误信息 / error message
|
||||
}
|
||||
>;
|
||||
is_full_url?: boolean;
|
||||
}
|
||||
|
||||
export type TProviderWithModel = Omit<IProvider, 'models'> & {
|
||||
use_model: string;
|
||||
};
|
||||
|
||||
// MCP Server Configuration Types
|
||||
export interface IMcpServerTransportStdio {
|
||||
type: 'stdio';
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IMcpServerTransportSSE {
|
||||
type: 'sse';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IMcpServerTransportHTTP {
|
||||
type: 'http';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IMcpServerTransportStreamableHTTP {
|
||||
type: 'streamable_http';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type IMcpServerTransport =
|
||||
| IMcpServerTransportStdio
|
||||
| IMcpServerTransportSSE
|
||||
| IMcpServerTransportHTTP
|
||||
| IMcpServerTransportStreamableHTTP;
|
||||
|
||||
export interface IMcpServer {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean; // 是否默认启用(新会话默认勾选)
|
||||
transport: IMcpServerTransport;
|
||||
tools?: IMcpTool[];
|
||||
last_test_status?: 'connected' | 'disconnected' | 'error' | 'testing'; // 最近一次检测结果
|
||||
last_connected?: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
original_json: string; // 存储原始JSON配置,用于编辑时的准确显示
|
||||
/** Built-in MCP server managed by Nomi (hide edit/delete in UI) */
|
||||
builtin?: boolean;
|
||||
}
|
||||
|
||||
export type ISessionMcpServer = Pick<IMcpServer, 'id' | 'name' | 'transport'>;
|
||||
|
||||
export type IConversationMcpStatusKind = 'loaded' | 'failed' | 'unsupported';
|
||||
|
||||
export interface IConversationMcpStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
status: IConversationMcpStatusKind;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Stable ID for the built-in image generation MCP server */
|
||||
export const BUILTIN_IMAGE_GEN_ID = 'builtin-image-gen';
|
||||
export const BUILTIN_IMAGE_GEN_NAME = 'nomifun-image-generation';
|
||||
export const BUILTIN_IMAGE_GEN_LEGACY_NAMES = ['Nomifun Image Generation', BUILTIN_IMAGE_GEN_ID] as const;
|
||||
|
||||
export interface IMcpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
input_schema?: unknown;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 主题配置接口 / CSS Theme configuration interface
|
||||
* 用于存储用户自定义的 CSS 皮肤 / Used to store user-defined CSS skins
|
||||
*/
|
||||
export interface ICssTheme {
|
||||
id: string; // 唯一标识 / Unique identifier
|
||||
name: string; // 主题名称 / Theme name
|
||||
cover?: string; // 封面图片 base64 或 URL / Cover image base64 or URL
|
||||
css: string; // CSS 样式代码 / CSS style code
|
||||
is_preset?: boolean; // 是否为预设主题 / Whether it's a preset theme
|
||||
created_at: number; // 创建时间 / Creation time
|
||||
updated_at: number; // 更新时间 / Update time
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Centralized localStorage keys for the application
|
||||
* 应用程序的集中式 localStorage 键管理
|
||||
*
|
||||
* All localStorage keys should be defined here to:
|
||||
* - Avoid key conflicts
|
||||
* - Make it easy to find and manage all persisted states
|
||||
* - Provide a single source of truth for storage key names
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
/** Workspace tree collapse state / 工作空间目录树折叠状态 */
|
||||
WORKSPACE_TREE_COLLAPSE: 'nomifun_workspace_collapse_state',
|
||||
|
||||
/** Sidebar collapse state / 侧边栏折叠状态 */
|
||||
SIDEBAR_COLLAPSE: 'nomifun_sider_collapsed',
|
||||
|
||||
/** Theme preference / 主题偏好 */
|
||||
THEME: 'nomifun_theme',
|
||||
|
||||
/** Language preference / 语言偏好 */
|
||||
LANGUAGE: 'nomifun_language',
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export * as ipcBridge from './adapter/ipcBridge';
|
||||
export { conversation } from './adapter/ipcBridge';
|
||||
@@ -0,0 +1,115 @@
|
||||
// src/common/platform/IPlatformServices.ts
|
||||
|
||||
/**
|
||||
* Path resolution and app metadata.
|
||||
* Replaces all app.getPath() / app.getAppPath() / app.getName() / app.getVersion() calls.
|
||||
*/
|
||||
export interface IPlatformPaths {
|
||||
/** Persistent user data directory. Equivalent to app.getPath('userData'). */
|
||||
getDataDir(): string;
|
||||
/** OS temp directory. */
|
||||
getTempDir(): string;
|
||||
/** User home directory. */
|
||||
getHomeDir(): string;
|
||||
/**
|
||||
* Application log directory.
|
||||
* In non-Electron mode respects LOGS_DIR env var, falls back to <tmpdir>/nomifun-logs.
|
||||
*/
|
||||
getLogsDir(): string;
|
||||
/**
|
||||
* Root path of the application bundle.
|
||||
* Returns null in non-Electron mode (no bundle concept).
|
||||
*/
|
||||
getAppPath(): string | null;
|
||||
/**
|
||||
* True when running from a packaged Electron build.
|
||||
* In non-Electron mode controlled by IS_PACKAGED env var (default false).
|
||||
*/
|
||||
isPackaged(): boolean;
|
||||
/**
|
||||
* Well-known system paths (desktop, home, downloads).
|
||||
* Returns null in non-Electron mode.
|
||||
*/
|
||||
getSystemPath(name: 'desktop' | 'home' | 'downloads'): string | null;
|
||||
/** Application name used for MCP client identification. */
|
||||
getName(): string;
|
||||
/** Application version string used for MCP client identification. */
|
||||
getVersion(): string;
|
||||
/**
|
||||
* Whether CLI-safe symlinks should be created in the home directory.
|
||||
* True only for Electron on macOS, where userData lives under "Application Support" (contains spaces).
|
||||
* False for non-Electron mode, where data dir has no spaces.
|
||||
*/
|
||||
needsCliSafeSymlinks(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A running worker child process.
|
||||
*
|
||||
* Covers the subset of Electron.UtilityProcess / Node.js ChildProcess APIs
|
||||
* used by ForkTask. When migrating ForkTask, change fcp field type from
|
||||
* UtilityProcess to IWorkerProcess.
|
||||
*/
|
||||
export interface IWorkerProcess {
|
||||
postMessage(message: unknown): void;
|
||||
on(event: string, handler: (...args: unknown[]) => void): this;
|
||||
kill(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker process factory.
|
||||
* Replaces utilityProcess.fork() in Electron and child_process.fork() in Node.js.
|
||||
*/
|
||||
export interface IWorkerProcessFactory {
|
||||
fork(modulePath: string, args: string[], options: { cwd?: string; env?: Record<string, string> }): IWorkerProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* System sleep/suspension control. Replaces powerSaveBlocker.
|
||||
*
|
||||
* Callers MUST guard against null before calling allowSleep:
|
||||
* const id = power.preventSleep()
|
||||
* if (id !== null) power.allowSleep(id)
|
||||
*/
|
||||
export interface IPowerManager {
|
||||
/** Returns a handle ID, or null if not supported (non-Electron mode). */
|
||||
preventSleep(): number | null;
|
||||
/** id may be null (returned by non-Electron preventSleep); safe no-op in that case. */
|
||||
allowSleep(id: number | null): void;
|
||||
/**
|
||||
* Prevent the display (and system) from sleeping.
|
||||
* Uses 'prevent-display-sleep' mode — stronger than preventSleep().
|
||||
* Returns a handle ID, or null if not supported.
|
||||
*/
|
||||
preventDisplaySleep(): number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* System notification. Replaces Electron Notification class.
|
||||
*
|
||||
* In non-Electron mode: silent no-op (intentional degradation).
|
||||
* Notification lifecycle events (click, failed, close) are Electron-only
|
||||
* and are NOT modelled here.
|
||||
*/
|
||||
export interface INotificationService {
|
||||
send(options: { title: string; body: string; icon?: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network primitives that vary by runtime.
|
||||
*
|
||||
* Electron should use `net.fetch()` to preserve Chromium networking behavior.
|
||||
* Non-Electron mode should use the runtime's global `fetch()`.
|
||||
*/
|
||||
export interface INetworkService {
|
||||
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
||||
}
|
||||
|
||||
/** Top-level aggregate injected at process startup. */
|
||||
export interface IPlatformServices {
|
||||
paths: IPlatformPaths;
|
||||
worker: IWorkerProcessFactory;
|
||||
power: IPowerManager;
|
||||
notification: INotificationService;
|
||||
network: INetworkService;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fork as cpFork, type ChildProcess } from 'child_process';
|
||||
import { readFileSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type { IPlatformServices, IWorkerProcess } from './IPlatformServices';
|
||||
|
||||
class NodeWorkerProcess implements IWorkerProcess {
|
||||
constructor(private readonly cp: ChildProcess) {}
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.cp.send(message as Parameters<ChildProcess['send']>[0]);
|
||||
}
|
||||
|
||||
on(event: string, handler: (...args: unknown[]) => void): this {
|
||||
this.cp.on(event, handler as (...args: unknown[]) => void);
|
||||
return this;
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
this.cp.kill();
|
||||
}
|
||||
}
|
||||
|
||||
// Read name + version from package.json once at module load.
|
||||
const _pkg = (() => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
} catch {
|
||||
return { name: 'nomifun', version: '0.0.0' };
|
||||
}
|
||||
})();
|
||||
|
||||
export class NodePlatformServices implements IPlatformServices {
|
||||
paths = {
|
||||
getDataDir: () => process.env.DATA_DIR ?? path.join(os.homedir(), '.nomifun-server'),
|
||||
getTempDir: () => os.tmpdir(),
|
||||
getHomeDir: () => os.homedir(),
|
||||
getLogsDir: () => process.env.LOGS_DIR ?? path.join(os.homedir(), '.nomifun-server', 'logs'),
|
||||
getAppPath: (): string | null => process.cwd(),
|
||||
isPackaged: () => process.env.IS_PACKAGED === 'true',
|
||||
getSystemPath: (_name: 'desktop' | 'home' | 'downloads'): string | null => null,
|
||||
getName: () => _pkg.name ?? 'nomifun',
|
||||
getVersion: () => _pkg.version ?? '0.0.0',
|
||||
needsCliSafeSymlinks: () => false,
|
||||
};
|
||||
|
||||
worker = {
|
||||
fork: (modulePath: string, args: string[], opts: { cwd?: string; env?: Record<string, string> }): IWorkerProcess =>
|
||||
new NodeWorkerProcess(
|
||||
cpFork(modulePath, args, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
// Enables V8 structured clone (supports Buffer, Map, Set).
|
||||
// ArrayBuffer ownership transfer is not supported — acceptable
|
||||
// because current IForkData messages contain no Transferables.
|
||||
serialization: 'advanced',
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
power = {
|
||||
preventSleep: (): number | null => null,
|
||||
allowSleep: (_id: number | null): void => {},
|
||||
preventDisplaySleep: (): number | null => null,
|
||||
};
|
||||
|
||||
notification = {
|
||||
send: (_opts: { title: string; body: string; icon?: string }): void => {},
|
||||
};
|
||||
|
||||
network = {
|
||||
fetch: (input: string | URL | Request, init?: RequestInit): Promise<Response> => fetch(input, init),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { IPlatformServices } from './IPlatformServices';
|
||||
|
||||
let _services: IPlatformServices | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the dev-mode app name for environment isolation.
|
||||
* Centralised so that every call-site stays in sync.
|
||||
*/
|
||||
export function getDevAppName(): string {
|
||||
const isMultiInstance = process.env.NOMIFUN_MULTI_INSTANCE === '1';
|
||||
return isMultiInstance ? 'NomiFun-Dev-2' : 'NomiFun-Dev';
|
||||
}
|
||||
|
||||
export function registerPlatformServices(services: IPlatformServices): void {
|
||||
_services = services;
|
||||
}
|
||||
|
||||
export function getPlatformServices(): IPlatformServices {
|
||||
if (!_services) {
|
||||
// Electron auto-registration was removed with the Electron shell. Platform
|
||||
// services are Node-only and must be registered explicitly via
|
||||
// registerPlatformServices(); the Tauri renderer never reaches this module.
|
||||
throw new Error(
|
||||
'[Platform] Services not registered. Call registerPlatformServices() before using platform APIs.'
|
||||
);
|
||||
}
|
||||
return _services;
|
||||
}
|
||||
|
||||
export type {
|
||||
IPlatformServices,
|
||||
IPlatformPaths,
|
||||
IWorkerProcess,
|
||||
IWorkerProcessFactory,
|
||||
IPowerManager,
|
||||
INotificationService,
|
||||
INetworkService,
|
||||
} from './IPlatformServices';
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TurnStopReason } from "./TurnStopReason";
|
||||
|
||||
/**
|
||||
* Data for the `Finish` event.
|
||||
*/
|
||||
export type FinishEventData = { session_id: string | null,
|
||||
/**
|
||||
* Why the turn ended. `None` = the backend did not report (treated as
|
||||
* success for back-compat). `EndTurn` = normal completion; `MaxTokens` /
|
||||
* `MaxTurnRequests` / `Refusal` / `Cancelled` = the turn did NOT accomplish
|
||||
* its goal. AutoWork consults this instead of treating any Finish as done.
|
||||
*/
|
||||
stop_reason: TurnStopReason | null, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Data for the `SessionAssigned` event.
|
||||
*/
|
||||
export type SessionAssignedEventData = { session_id: string, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Data for the `Start` event.
|
||||
*/
|
||||
export type StartEventData = { session_id: string | null, };
|
||||
@@ -0,0 +1,31 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TurnStopReason } from "./TurnStopReason";
|
||||
|
||||
/**
|
||||
* Data for the `TurnCompleted` event — aggregate metrics for one turn.
|
||||
*/
|
||||
export type TurnCompletedEventData = {
|
||||
/**
|
||||
* Wall-clock duration of the turn in milliseconds.
|
||||
*/
|
||||
elapsed_ms: number, input_tokens: number, output_tokens: number,
|
||||
/**
|
||||
* Tokens written into the provider prompt cache.
|
||||
*/
|
||||
cache_creation_tokens: number,
|
||||
/**
|
||||
* Tokens read back from the provider prompt cache.
|
||||
*/
|
||||
cache_read_tokens: number,
|
||||
/**
|
||||
* Current context occupancy (last request's prompt tokens). Gauge numerator.
|
||||
*/
|
||||
context_tokens: number,
|
||||
/**
|
||||
* Effective context budget (engine compaction window). Gauge denominator.
|
||||
*/
|
||||
context_window: number,
|
||||
/**
|
||||
* Why the turn ended (mirrors Finish), for a single self-contained record.
|
||||
*/
|
||||
stop_reason: TurnStopReason | null, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Cross-backend normalized "why did the turn end" reason. Deliberately NOT the
|
||||
* ACP SDK's `StopReason` so the shared event type does not couple to ACP
|
||||
* (nomi / openclaw / remote are not ACP); each backend maps its own outcome.
|
||||
*/
|
||||
export type TurnStopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { CODEX_MODE_NATIVE_FULL_ACCESS } from '@/common/types/codex/codexModes';
|
||||
|
||||
/**
|
||||
* Full-auto (YOLO) mode ID per backend.
|
||||
* Shared by renderer (cron task creation) and process (SessionLifecycle).
|
||||
*/
|
||||
const FULL_AUTO_MODE: Record<string, string> = {
|
||||
claude: 'bypassPermissions',
|
||||
qwen: 'yolo',
|
||||
opencode: 'build',
|
||||
gemini: 'yolo',
|
||||
nomi: 'yolo',
|
||||
codex: CODEX_MODE_NATIVE_FULL_ACCESS,
|
||||
snow: 'yolo',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the full-auto mode value for a given backend.
|
||||
* Falls back to 'yolo' for unknown backends.
|
||||
*/
|
||||
export function getFullAutoMode(backend: string | undefined): string {
|
||||
if (!backend) return 'yolo';
|
||||
return FULL_AUTO_MODE[backend] || 'yolo';
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
// Mirror of nomifun-api-types/src/assistant.rs.
|
||||
// Any shape change on either side requires a same-PR update on the other.
|
||||
|
||||
export type AssistantSource = 'builtin' | 'user' | 'extension';
|
||||
|
||||
export interface Assistant {
|
||||
id: string;
|
||||
source: AssistantSource;
|
||||
name: string;
|
||||
name_i18n: Record<string, string>;
|
||||
description?: string;
|
||||
description_i18n: Record<string, string>;
|
||||
avatar?: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
preset_agent_type: string;
|
||||
enabled_skills: string[];
|
||||
custom_skill_names: string[];
|
||||
disabled_builtin_skills: string[];
|
||||
context?: string;
|
||||
context_i18n: Record<string, string>;
|
||||
prompts: string[];
|
||||
prompts_i18n: Record<string, string[]>;
|
||||
models: string[];
|
||||
audience_tags: string[];
|
||||
scenario_tags: string[];
|
||||
last_used_at?: number;
|
||||
}
|
||||
|
||||
export interface CreateAssistantRequest {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
preset_agent_type?: string;
|
||||
enabled_skills?: string[];
|
||||
custom_skill_names?: string[];
|
||||
disabled_builtin_skills?: string[];
|
||||
prompts?: string[];
|
||||
models?: string[];
|
||||
audience_tags?: string[];
|
||||
scenario_tags?: string[];
|
||||
name_i18n?: Record<string, string>;
|
||||
description_i18n?: Record<string, string>;
|
||||
prompts_i18n?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export type UpdateAssistantRequest = Partial<Omit<CreateAssistantRequest, 'id'>> & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface SetAssistantStateRequest {
|
||||
id: string;
|
||||
enabled?: boolean;
|
||||
sort_order?: number;
|
||||
last_used_at?: number;
|
||||
}
|
||||
|
||||
export interface ImportAssistantsRequest {
|
||||
assistants: CreateAssistantRequest[];
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ImportAssistantsResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: ImportError[];
|
||||
}
|
||||
|
||||
export type AssistantTagDimension = 'audience' | 'scenario';
|
||||
|
||||
export interface AssistantTag {
|
||||
key: string;
|
||||
dimension: AssistantTagDimension;
|
||||
label: string;
|
||||
label_i18n: Record<string, string>;
|
||||
sort_order: number;
|
||||
builtin: boolean;
|
||||
}
|
||||
|
||||
export interface CreateAssistantTagRequest {
|
||||
dimension: AssistantTagDimension;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface UpdateAssistantTagRequest {
|
||||
key: string;
|
||||
label?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detection layer types — represents available execution engines in the system.
|
||||
*
|
||||
* Each `kind` corresponds to a distinct execution engine / communication protocol.
|
||||
* Assistants (user-configured presets with skills, prompts, etc.) are a configuration
|
||||
* layer that *references* these execution engines — they are NOT detected agents.
|
||||
*/
|
||||
|
||||
/** Remote agent communication protocol */
|
||||
export type RemoteAgentProtocol = 'openclaw' | 'zeroclaw' | 'acp';
|
||||
|
||||
/** Remote agent authentication method */
|
||||
export type RemoteAgentAuthType = 'bearer' | 'password' | 'none';
|
||||
|
||||
/** Execution engine kinds — each uses a different protocol or runtime */
|
||||
export type DetectedAgentKind = 'acp' | 'remote' | 'nomi' | 'openclaw-gateway' | 'nanobot';
|
||||
@@ -0,0 +1,51 @@
|
||||
export type HubExtensionStatus =
|
||||
| 'not_installed'
|
||||
| 'installing'
|
||||
| 'installed'
|
||||
| 'install_failed'
|
||||
| 'update_available'
|
||||
| 'uninstalling';
|
||||
|
||||
/**
|
||||
* Declarative contributes in hub index.
|
||||
* Each key mirrors ExtContributesSchemaBase but values are string ID arrays
|
||||
* indicating what capabilities the extension provides.
|
||||
*/
|
||||
export type HubContributes = {
|
||||
acpAdapters?: string[];
|
||||
mcpServers?: string[];
|
||||
assistants?: string[];
|
||||
agents?: string[];
|
||||
skills?: string[];
|
||||
channelPlugins?: string[];
|
||||
webui?: string[];
|
||||
themes?: string[];
|
||||
settingsTabs?: string[];
|
||||
modelProviders?: string[];
|
||||
};
|
||||
|
||||
export interface IHubExtension {
|
||||
name: string; // Extension unique ID
|
||||
display_name: string; // UI display name
|
||||
version?: string;
|
||||
description: string;
|
||||
author: string;
|
||||
icon?: string; // Path relative to extension root
|
||||
dist: {
|
||||
tarball: string; // Relative path e.g. extensions/ext-claude-code.tgz
|
||||
integrity: string; // SHA-512 SRI Hash
|
||||
unpackedSize: number;
|
||||
};
|
||||
engines: {
|
||||
nomifun: string; // Minimum APP version requirement
|
||||
};
|
||||
hubs: string[]; // Hub categories e.g. ["acpAdapters"]
|
||||
contributes?: HubContributes;
|
||||
tags?: string[];
|
||||
bundled?: boolean; // Set at runtime by HubIndexManager for local bundled extensions
|
||||
}
|
||||
|
||||
export interface IHubAgentItem extends IHubExtension {
|
||||
status: HubExtensionStatus;
|
||||
installError?: string; // Error message if install failed
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
// Canonical definitions live in common/types/agent/detectedAgent.ts
|
||||
import type { RemoteAgentProtocol, RemoteAgentAuthType } from '@/common/types/agent/detectedAgent';
|
||||
export type { RemoteAgentProtocol, RemoteAgentAuthType } from '@/common/types/agent/detectedAgent';
|
||||
|
||||
/** Last known connection status (cached for UI display) */
|
||||
export type RemoteAgentStatus = 'unknown' | 'connected' | 'pending' | 'error';
|
||||
|
||||
/** Remote Agent instance configuration (corresponds to remote_agents DB table) */
|
||||
export type RemoteAgentConfig = {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: RemoteAgentProtocol;
|
||||
url: string;
|
||||
auth_type: RemoteAgentAuthType;
|
||||
auth_token?: string;
|
||||
/** Skip TLS certificate verification (for self-signed certificates) */
|
||||
allow_insecure?: boolean;
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
/** Ed25519 public key SHA256 fingerprint (OpenClaw protocol only, per-agent) */
|
||||
device_id?: string;
|
||||
/** Ed25519 public key PEM (OpenClaw protocol only) */
|
||||
device_public_key?: string;
|
||||
/** Ed25519 private key PEM (OpenClaw protocol only) */
|
||||
device_private_key?: string;
|
||||
/** Device token issued by Gateway after hello-ok (OpenClaw protocol only) */
|
||||
device_token?: string;
|
||||
status?: RemoteAgentStatus;
|
||||
last_connected_at?: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
/** Parameters for creating/updating a remote agent config */
|
||||
export type RemoteAgentInput = {
|
||||
name: string;
|
||||
protocol: RemoteAgentProtocol;
|
||||
url: string;
|
||||
auth_type: RemoteAgentAuthType;
|
||||
auth_token?: string;
|
||||
/** Skip TLS certificate verification (for self-signed certificates) */
|
||||
allow_insecure?: boolean;
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
declare module 'bun:test' {
|
||||
type TestFn = () => void | Promise<void>;
|
||||
|
||||
interface Matchers {
|
||||
not: Matchers;
|
||||
toBe(expected: unknown): void;
|
||||
toEqual(expected: unknown): void;
|
||||
toHaveLength(expected: number): void;
|
||||
toBeCloseTo(expected: number, precision?: number): void;
|
||||
toBeDefined(): void;
|
||||
toBeUndefined(): void;
|
||||
toBeTruthy(): void;
|
||||
toBeFalsy(): void;
|
||||
toBeNull(): void;
|
||||
toMatchObject(expected: unknown): void;
|
||||
toBeGreaterThan(expected: number): void;
|
||||
toBeGreaterThanOrEqual(expected: number): void;
|
||||
toBeLessThan(expected: number): void;
|
||||
toBeLessThanOrEqual(expected: number): void;
|
||||
}
|
||||
|
||||
interface Test {
|
||||
(name: string, fn: TestFn): void;
|
||||
each<T>(cases: readonly T[]): (name: string, fn: (caseValue: T) => void | Promise<void>) => void;
|
||||
each<T extends readonly unknown[]>(
|
||||
cases: readonly T[]
|
||||
): (name: string, fn: (...caseValues: T) => void | Promise<void>) => void;
|
||||
}
|
||||
|
||||
export function describe(name: string, fn: TestFn): void;
|
||||
export const test: Test;
|
||||
export function expect(actual: unknown): Matchers;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface IChannelPluginStatus {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
status?: string;
|
||||
last_connected?: number;
|
||||
error?: string;
|
||||
activeUsers: number;
|
||||
botUsername?: string;
|
||||
hasToken?: boolean;
|
||||
/** 绑定的伙伴(每机器人一宠;UNIQUE(type,bot_key) 保证同一机器人不绑多宠)。 */
|
||||
companionId?: string;
|
||||
/** 平台级机器人身份(lark app_id / telegram bot id / ...)。 */
|
||||
botKey?: string;
|
||||
isExtension?: boolean;
|
||||
extensionMeta?: {
|
||||
credentialFields?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'select' | 'number' | 'boolean';
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
default?: string | number | boolean;
|
||||
}>;
|
||||
configFields?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'select' | 'number' | 'boolean';
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
default?: string | number | boolean;
|
||||
}>;
|
||||
description?: string;
|
||||
extensionName?: string;
|
||||
icon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IChannelPairingRequest {
|
||||
code: string;
|
||||
platformUserId: string;
|
||||
platformType: string;
|
||||
display_name?: string;
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
/** 发起/归属的机器人渠道行 id;旧库未回填时可能缺省。 */
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface IChannelUser {
|
||||
id: string;
|
||||
platformUserId: string;
|
||||
platformType: string;
|
||||
display_name?: string;
|
||||
authorizedAt: number;
|
||||
lastActive?: number;
|
||||
session_id?: string;
|
||||
/** 发起/归属的机器人渠道行 id;旧库未回填时可能缺省。 */
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface IChannelSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
agent_type: string;
|
||||
conversation_id?: string;
|
||||
workspace?: string;
|
||||
chatId?: string;
|
||||
created_at: number;
|
||||
lastActivity: number;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default Codex model list maintained by Nomi.
|
||||
* These are known models that Codex CLI supports.
|
||||
* Validation is done by Codex CLI itself — Nomi only passes the model name.
|
||||
*
|
||||
* The first entry is used as the default when the user hasn't made a selection.
|
||||
*/
|
||||
export const DEFAULT_CODEX_MODELS: Array<{ id: string; label: string; description: string }> = [
|
||||
{ id: 'gpt-5.3-codex', label: 'gpt-5.3-codex', description: 'Latest frontier agentic coding model' },
|
||||
{ id: 'gpt-5.4', label: 'gpt-5.4', description: 'Latest frontier agentic coding model' },
|
||||
{ id: 'gpt-5.2-codex', label: 'gpt-5.2-codex', description: 'Frontier agentic coding model' },
|
||||
{
|
||||
id: 'gpt-5.1-codex-max',
|
||||
label: 'gpt-5.1-codex-max',
|
||||
description: 'Codex-optimized flagship for deep and fast reasoning',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
label: 'gpt-5.2',
|
||||
description: 'Latest frontier model with improvements across knowledge, reasoning and coding',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
label: 'gpt-5.1-codex-mini',
|
||||
description: 'Optimized for codex. Cheaper, faster, but less capable',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export const CODEX_MODE_READ_ONLY = 'read-only';
|
||||
export const CODEX_MODE_NATIVE_DEFAULT = 'auto';
|
||||
export const CODEX_MODE_NATIVE_FULL_ACCESS = 'full-access';
|
||||
|
||||
// Legacy Nomi values kept for backward compatibility with persisted config.
|
||||
// Only consumed internally by normalizeCodexMode, no external callers.
|
||||
const CODEX_MODE_AUTO_EDIT = 'autoEdit';
|
||||
const CODEX_MODE_FULL_AUTO = 'yolo';
|
||||
const CODEX_MODE_FULL_AUTO_NO_SANDBOX = 'yoloNoSandbox';
|
||||
|
||||
export function normalizeCodexMode(mode?: string | null): string | undefined {
|
||||
if (!mode) return undefined;
|
||||
|
||||
switch (mode) {
|
||||
case 'default':
|
||||
case CODEX_MODE_AUTO_EDIT:
|
||||
case CODEX_MODE_NATIVE_DEFAULT:
|
||||
return CODEX_MODE_NATIVE_DEFAULT;
|
||||
case CODEX_MODE_FULL_AUTO:
|
||||
case CODEX_MODE_FULL_AUTO_NO_SANDBOX:
|
||||
case CODEX_MODE_NATIVE_FULL_ACCESS:
|
||||
return CODEX_MODE_NATIVE_FULL_ACCESS;
|
||||
case CODEX_MODE_READ_ONLY:
|
||||
return CODEX_MODE_READ_ONLY;
|
||||
default:
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export interface ConversionResult<T> {
|
||||
success: boolean; // 是否成功 / Whether successful
|
||||
data?: T; // 转换结果数据 / Conversion result data
|
||||
error?: string; // 错误信息 / Error message
|
||||
}
|
||||
|
||||
// Excel 中间格式 (JSON) / Excel Intermediate Format (JSON)
|
||||
export interface ExcelSheetImage {
|
||||
row: number; // 图片所在行(从 0 开始)/ Image row index (0-based)
|
||||
col: number; // 图片所在列(从 0 开始)/ Image column index (0-based)
|
||||
src: string; // 图片数据(通常为 data URL)/ Image data (typically data URL)
|
||||
width?: number; // 预估宽度(像素)/ Estimated width (px)
|
||||
height?: number; // 预估高度(像素)/ Estimated height (px)
|
||||
alt?: string; // 可选描述 / Optional description
|
||||
}
|
||||
|
||||
export interface ExcelSheetData {
|
||||
name: string; // 工作表名称 / Sheet name
|
||||
data: any[][]; // 单元格数据二维数组 / 2D array of cell values
|
||||
merges?: { s: { r: number; c: number }; e: { r: number; c: number } }[]; // 合并单元格范围 / Merge ranges
|
||||
images?: ExcelSheetImage[]; // 单元格图片信息 / Embedded images info
|
||||
}
|
||||
|
||||
export interface ExcelWorkbookData {
|
||||
sheets: ExcelSheetData[]; // 工作表列表 / List of sheets
|
||||
}
|
||||
|
||||
// PowerPoint 中间格式 (PPTX JSON 结构) / PowerPoint Intermediate Format (PPTX JSON structure)
|
||||
export interface PPTSlideData {
|
||||
slideNumber: number;
|
||||
content: any; // PPTX JSON 结构 / PPTX JSON structure
|
||||
}
|
||||
|
||||
export interface PPTJsonData {
|
||||
slides: PPTSlideData[];
|
||||
raw?: any; // 原始 PPTX JSON(可选,通常不需要传递给前端)/ Raw PPTX JSON (optional, usually not needed in frontend)
|
||||
}
|
||||
|
||||
// 文档转换目标格式 / Supported document conversion targets
|
||||
export type DocumentConversionTarget = 'markdown' | 'excel-json' | 'ppt-json';
|
||||
|
||||
// 统一的文档转换请求参数 / Unified document conversion request payload
|
||||
export interface DocumentConversionRequest {
|
||||
file_path: string; // 待转换文件的绝对路径 / Absolute file path to convert
|
||||
to: DocumentConversionTarget; // 目标格式 / Desired target format
|
||||
workspace?: string; // 工作区根目录(可选)/ Optional workspace root
|
||||
}
|
||||
|
||||
// 根据目标格式返回不同的数据类型 / Result payload differs per target format
|
||||
export type DocumentConversionResponse =
|
||||
| { to: 'markdown'; result: ConversionResult<string> }
|
||||
| { to: 'excel-json'; result: ConversionResult<ExcelWorkbookData> }
|
||||
| { to: 'ppt-json'; result: ConversionResult<PPTJsonData> };
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export type PreviewContentType =
|
||||
| 'markdown'
|
||||
| 'diff'
|
||||
| 'code'
|
||||
| 'html'
|
||||
| 'pdf'
|
||||
| 'ppt'
|
||||
| 'word'
|
||||
| 'excel'
|
||||
| 'image'
|
||||
| 'url';
|
||||
|
||||
export interface PreviewHistoryTarget {
|
||||
contentType: PreviewContentType;
|
||||
file_path?: string;
|
||||
workspace?: string;
|
||||
file_name?: string;
|
||||
title?: string;
|
||||
language?: string;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
export interface PreviewSnapshotInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
created_at: number;
|
||||
size: number;
|
||||
contentType: PreviewContentType;
|
||||
file_name?: string;
|
||||
file_path?: string;
|
||||
}
|
||||
|
||||
export interface RemoteImageFetchRequest {
|
||||
url: string;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Advanced overrides exposed through the JSON panel of the custom agent
|
||||
* editor. These map directly onto backend `AgentMetadata` columns that
|
||||
* are not covered by the 5 form fields (name / avatar / command / args
|
||||
* / env). Snake_case keys match the backend wire format.
|
||||
*/
|
||||
export interface CustomAgentAdvancedOverrides {
|
||||
yolo_id?: string;
|
||||
native_skills_dirs?: string[];
|
||||
behavior_policy?: { supports_side_question?: boolean };
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Initialize response types (from ACP spec) ──────────────────────────
|
||||
|
||||
/**
|
||||
* Prompt content types the agent can accept.
|
||||
* Per ACP spec, omitted fields default to false.
|
||||
*/
|
||||
export type AcpPromptCapabilities = {
|
||||
image: boolean;
|
||||
audio: boolean;
|
||||
embeddedContext: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* MCP transport types the agent supports.
|
||||
* stdio is mandatory per ACP spec IF the agent declares mcpCapabilities at all.
|
||||
* If mcpCapabilities is absent from the initialize response, all transports are false.
|
||||
*/
|
||||
export type AcpMcpCapabilities = {
|
||||
stdio: boolean;
|
||||
http: boolean;
|
||||
sse: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session operations the agent supports.
|
||||
* Per ACP spec, key presence (e.g. `{ fork: {} }`) indicates support;
|
||||
* values are `{}` reserved for future extension.
|
||||
* null = unsupported (key was omitted in the response).
|
||||
*/
|
||||
export type AcpSessionCapabilities = {
|
||||
fork: Record<string, unknown> | null;
|
||||
resume: Record<string, unknown> | null;
|
||||
list: Record<string, unknown> | null;
|
||||
close: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parsed agent capabilities from the initialize response.
|
||||
* Field names match the ACP protocol wire format to avoid confusion.
|
||||
* All fields have safe defaults — no undefined checks needed by callers.
|
||||
*/
|
||||
export type AcpAgentCapabilities = {
|
||||
loadSession: boolean;
|
||||
promptCapabilities: AcpPromptCapabilities;
|
||||
mcpCapabilities: AcpMcpCapabilities;
|
||||
sessionCapabilities: AcpSessionCapabilities;
|
||||
/** Backend-specific metadata (_meta from agentCapabilities) */
|
||||
_meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Agent identity info from initialize response. */
|
||||
export type AcpAgentInfo = {
|
||||
name: string;
|
||||
version: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authentication method descriptor from initialize response.
|
||||
* Backends may extend this with extra fields (e.g. `type`, `vars`).
|
||||
*/
|
||||
export type AcpAuthMethod = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Extended fields — e.g. Codex uses `type: "env_var"` and `vars` */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully parsed initialize response (the `result` from JSON-RPC).
|
||||
* Consolidates all top-level fields per ACP initialization spec.
|
||||
*/
|
||||
export type AcpInitializeResult = {
|
||||
protocolVersion: number;
|
||||
capabilities: AcpAgentCapabilities;
|
||||
agentInfo: AcpAgentInfo | null;
|
||||
auth_methods: AcpAuthMethod[];
|
||||
};
|
||||
|
||||
// ── Session update payloads retained for chatLib message shapes ────────
|
||||
|
||||
/** Shared base — every session update notification carries a session id. */
|
||||
export interface BaseSessionUpdate {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
/** Tool call 内容项类型 / Tool call content item type */
|
||||
export interface ToolCallContentItem {
|
||||
type: 'content' | 'diff';
|
||||
content?: {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
path?: string;
|
||||
old_text?: string | null;
|
||||
new_text?: string;
|
||||
}
|
||||
|
||||
/** Tool call 位置项类型 / Tool call location item type */
|
||||
export interface ToolCallLocationItem {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Tool call session update */
|
||||
export interface ToolCallUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'tool_call';
|
||||
tool_call_id: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
title: string;
|
||||
kind: 'read' | 'edit' | 'execute';
|
||||
rawInput?: Record<string, unknown>;
|
||||
content?: ToolCallContentItem[];
|
||||
locations?: ToolCallLocationItem[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Plan session update */
|
||||
export interface PlanUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'plan';
|
||||
entries: Array<{
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== ACP ConfigOption types (stable API) =====
|
||||
|
||||
/** A single select option within a config option */
|
||||
export interface AcpConfigSelectOption {
|
||||
value: string;
|
||||
name?: string;
|
||||
label?: string; // Some agents may use label instead of name
|
||||
}
|
||||
|
||||
/** A configuration option returned by session/new */
|
||||
export interface AcpSessionConfigOption {
|
||||
id: string;
|
||||
name?: string;
|
||||
label?: string; // Some agents may use label instead of name
|
||||
description?: string;
|
||||
category?: string;
|
||||
type: 'select' | 'boolean' | 'string';
|
||||
current_value?: string;
|
||||
selected_value?: string; // Some agents may use selected_value instead of current_value
|
||||
options?: AcpConfigSelectOption[];
|
||||
}
|
||||
|
||||
// ===== ACP Mode / Model types (unstable API) =====
|
||||
|
||||
/** Mode entry in the top-level `modes` object of session/new response */
|
||||
export interface AcpAvailableMode {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Modes info returned by session/new (used by qoder, opencode, etc.) */
|
||||
export interface AcpSessionModes {
|
||||
current_mode_id?: string;
|
||||
available_modes?: AcpAvailableMode[];
|
||||
}
|
||||
|
||||
// ===== Unified model info for UI =====
|
||||
|
||||
export interface AcpModelInfo {
|
||||
/** Currently active model ID */
|
||||
current_model_id: string | null;
|
||||
/** Display label for the current model */
|
||||
current_model_label: string | null;
|
||||
/** Available models for switching */
|
||||
available_models: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
// ===== Permission request (session/request_permission) =====
|
||||
|
||||
export interface AcpPermissionOption {
|
||||
option_id: string;
|
||||
name: string;
|
||||
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
|
||||
}
|
||||
|
||||
export interface AcpPermissionRequest {
|
||||
session_id: string;
|
||||
options: Array<AcpPermissionOption>;
|
||||
tool_call: {
|
||||
tool_call_id: string;
|
||||
raw_input?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
status?: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
content?: ToolCallContentItem[];
|
||||
locations?: ToolCallLocationItem[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export type FileChangeOperation = 'create' | 'modify' | 'delete';
|
||||
|
||||
/** A single file's change status */
|
||||
export type FileChangeInfo = {
|
||||
file_path: string;
|
||||
relativePath: string;
|
||||
operation: FileChangeOperation;
|
||||
};
|
||||
|
||||
/** Comparison result with staged/unstaged separation (git-repo mode) */
|
||||
export type CompareResult = {
|
||||
staged: FileChangeInfo[];
|
||||
unstaged: FileChangeInfo[];
|
||||
};
|
||||
|
||||
/** Snapshot metadata returned by init and getInfo */
|
||||
export type SnapshotInfo = {
|
||||
mode: 'git-repo' | 'snapshot' | 'disabled';
|
||||
branch: string | null;
|
||||
/**
|
||||
* Present only for `mode === 'disabled'`: why snapshot tracking was refused
|
||||
* (drive root, well-known system dir, or too large to safely snapshot).
|
||||
* `null`/absent for the active `git-repo` / `snapshot` modes.
|
||||
*/
|
||||
reason?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Type declarations for pptx2json
|
||||
*/
|
||||
declare module 'pptx2json' {
|
||||
export default class PPTX2Json {
|
||||
constructor();
|
||||
toJson(file_path: string): Promise<any>;
|
||||
toPPTX(json: any, options?: { file?: string }): Promise<Buffer>;
|
||||
getMaxSlideIds(json: any): { id: number; rid: number };
|
||||
getSlideLayoutTypeHash(json: any): Record<string, string>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wire-contract types for `/api/providers/*`.
|
||||
*
|
||||
* Direct mirror of the Rust types in
|
||||
* `crates/nomifun-api-types/src/provider.rs`. Keep in sync with the
|
||||
* backend spec.
|
||||
*/
|
||||
|
||||
import type { IProvider, ModelCapability } from '@/common/config/storage';
|
||||
|
||||
export interface CreateProviderRequest {
|
||||
/**
|
||||
* Optional caller-supplied id. When omitted, the server generates one.
|
||||
* Validated leniently (any non-empty string) to accept the frontend's
|
||||
* `prefixedId('prov')` output (`prov_{uuidv7}`); legacy 8-char `uuid()`
|
||||
* ids remain accepted.
|
||||
*/
|
||||
id?: string;
|
||||
platform: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models?: string[];
|
||||
enabled?: boolean;
|
||||
capabilities?: ModelCapability[];
|
||||
context_limit?: number;
|
||||
model_protocols?: Record<string, string>;
|
||||
model_enabled?: Record<string, boolean>;
|
||||
model_health?: IProvider['model_health'];
|
||||
bedrock_config?: IProvider['bedrock_config'];
|
||||
is_full_url?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial-update shape for `PUT /api/providers/:id`.
|
||||
* Every field is optional — only fields sent are updated.
|
||||
*/
|
||||
export interface UpdateProviderRequest {
|
||||
platform?: string;
|
||||
name?: string;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
models?: string[];
|
||||
enabled?: boolean;
|
||||
capabilities?: ModelCapability[];
|
||||
context_limit?: number;
|
||||
model_protocols?: Record<string, string>;
|
||||
model_enabled?: Record<string, boolean>;
|
||||
model_health?: IProvider['model_health'];
|
||||
bedrock_config?: IProvider['bedrock_config'];
|
||||
is_full_url?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for `POST /api/providers/:id/models` and
|
||||
* `POST /api/providers/fetch-models`.
|
||||
*/
|
||||
export interface FetchModelsResponse {
|
||||
/** Mixed-shape array: bare id strings or `{ id, name }` pairs. */
|
||||
models: Array<string | { id: string; name: string }>;
|
||||
/** Present when backend auto-corrected the provider's base_url. */
|
||||
fixed_base_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymous fetch-models request used by the pre-create form flow.
|
||||
* No provider row needs to exist yet — credentials travel in the body.
|
||||
*/
|
||||
export interface FetchModelsAnonymousRequest {
|
||||
platform: string;
|
||||
base_url?: string;
|
||||
api_key: string;
|
||||
bedrock_config?: IProvider['bedrock_config'];
|
||||
try_fix?: boolean;
|
||||
}
|
||||
|
||||
export type ProviderHealthCheckErrorKind =
|
||||
| 'timeout'
|
||||
| 'invalid_authorization_header'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'not_found'
|
||||
| 'insufficient_quota'
|
||||
| 'aws_credentials'
|
||||
| 'invalid_request'
|
||||
| 'rate_limited'
|
||||
| 'connection_error'
|
||||
| 'api_error'
|
||||
| 'unknown';
|
||||
|
||||
export interface ProviderHealthCheckRequest {
|
||||
provider_id: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ProviderHealthCheckResponse {
|
||||
provider_id: string;
|
||||
platform: string;
|
||||
model: string;
|
||||
status: 'unknown' | 'healthy' | 'unhealthy';
|
||||
elapsed_ms: number;
|
||||
message?: string;
|
||||
error_kind?: ProviderHealthCheckErrorKind;
|
||||
http_status?: number;
|
||||
timeout_stage?: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export type SpeechToTextProvider = 'openai' | 'deepgram';
|
||||
|
||||
export type OpenAISpeechToTextConfig = {
|
||||
api_key: string;
|
||||
base_url?: string;
|
||||
language?: string;
|
||||
model: string;
|
||||
prompt?: string;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
export type DeepgramSpeechToTextConfig = {
|
||||
api_key: string;
|
||||
base_url?: string;
|
||||
detectLanguage?: boolean;
|
||||
language?: string;
|
||||
model: string;
|
||||
punctuate?: boolean;
|
||||
smartFormat?: boolean;
|
||||
};
|
||||
|
||||
export type SpeechToTextConfig = {
|
||||
autoSend?: boolean;
|
||||
enabled: boolean;
|
||||
provider: SpeechToTextProvider;
|
||||
deepgram?: DeepgramSpeechToTextConfig;
|
||||
openai?: OpenAISpeechToTextConfig;
|
||||
};
|
||||
|
||||
export type SpeechToTextAudioBuffer = Uint8Array | number[] | Record<string, number>;
|
||||
|
||||
export type SpeechToTextRequest = {
|
||||
audioBuffer: SpeechToTextAudioBuffer;
|
||||
file_name: string;
|
||||
languageHint?: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type SpeechToTextResult = {
|
||||
language?: string;
|
||||
model: string;
|
||||
provider: SpeechToTextProvider;
|
||||
text: string;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Ambient module shims for `react-syntax-highlighter`, which ships no bundled
|
||||
* type declarations (and `@types/react-syntax-highlighter` is not installed).
|
||||
* Without these, every importer trips TS7016 ("could not find a declaration
|
||||
* file ... implicitly has an 'any' type").
|
||||
*
|
||||
* The library is used only for its default highlighter component and a style
|
||||
* preset object, so loose typings are sufficient here.
|
||||
*/
|
||||
declare module 'react-syntax-highlighter' {
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
export interface SyntaxHighlighterProps {
|
||||
language?: string;
|
||||
style?: Record<string, unknown>;
|
||||
children?: ReactNode;
|
||||
customStyle?: Record<string, unknown>;
|
||||
codeTagProps?: Record<string, unknown>;
|
||||
PreTag?: keyof JSX.IntrinsicElements | ComponentType<unknown>;
|
||||
CodeTag?: keyof JSX.IntrinsicElements | ComponentType<unknown>;
|
||||
wrapLines?: boolean;
|
||||
wrapLongLines?: boolean;
|
||||
showLineNumbers?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const Prism: ComponentType<SyntaxHighlighterProps>;
|
||||
export const Light: ComponentType<SyntaxHighlighterProps>;
|
||||
export const LightAsync: ComponentType<SyntaxHighlighterProps>;
|
||||
export const PrismAsync: ComponentType<SyntaxHighlighterProps>;
|
||||
export const PrismAsyncLight: ComponentType<SyntaxHighlighterProps>;
|
||||
|
||||
const SyntaxHighlighter: ComponentType<SyntaxHighlighterProps>;
|
||||
export default SyntaxHighlighter;
|
||||
}
|
||||
|
||||
declare module 'react-syntax-highlighter/dist/esm/styles/hljs' {
|
||||
/** Style preset objects (only the ones imported across the app are named). */
|
||||
export const vs: Record<string, Record<string, unknown>>;
|
||||
export const vs2015: Record<string, Record<string, unknown>>;
|
||||
const styles: Record<string, Record<string, unknown>>;
|
||||
export default styles;
|
||||
}
|
||||
|
||||
declare module 'react-syntax-highlighter/dist/esm/styles/prism' {
|
||||
const styles: Record<string, Record<string, unknown>>;
|
||||
export default styles;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TMessage } from '../../chat/chatLib';
|
||||
import type { TChatConversation } from '../../config/storage';
|
||||
|
||||
export interface IMessageSearchItem {
|
||||
conversation: TChatConversation;
|
||||
message_id: string;
|
||||
message_type: TMessage['type'];
|
||||
message_created_at: number;
|
||||
preview_text: string;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// src/common/types/teamTypes.ts
|
||||
// Shared team types used by both main process and renderer.
|
||||
// Renderer code should import from here instead of @process/team/types.
|
||||
|
||||
/** Role of a teammate within a team */
|
||||
export type TeammateRole = 'leader' | 'teammate';
|
||||
|
||||
// Backend statuses: idle|working|thinking|tool_use|completed|error → mapped via teamMapper.toStatus()
|
||||
/** Lifecycle status of a teammate agent */
|
||||
export type TeammateStatus = 'pending' | 'idle' | 'active' | 'completed' | 'failed';
|
||||
|
||||
/** Workspace sharing strategy for the team */
|
||||
export type WorkspaceMode = 'shared' | 'isolated';
|
||||
|
||||
/** Persisted agent configuration within a team */
|
||||
export type TeamAgent = {
|
||||
slot_id: string;
|
||||
conversation_id: string;
|
||||
role: TeammateRole;
|
||||
agent_type: string;
|
||||
icon?: string;
|
||||
agent_name: string;
|
||||
conversation_type: string;
|
||||
status: TeammateStatus;
|
||||
cli_path?: string;
|
||||
custom_agent_id?: string;
|
||||
model?: string;
|
||||
pending_confirmations?: number;
|
||||
};
|
||||
|
||||
/** Persisted team record (stored in SQLite `teams` table) */
|
||||
export type TTeam = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
workspace: string;
|
||||
workspace_mode: WorkspaceMode;
|
||||
leader_agent_id: string;
|
||||
agents: TeamAgent[];
|
||||
/** Current session permission mode (e.g. 'plan', 'auto'). Persisted so newly spawned agents inherit it. */
|
||||
session_mode?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
/** IPC event pushed to renderer when agent status changes */
|
||||
export type ITeamAgentStatusEvent = {
|
||||
team_id: string;
|
||||
slot_id: string;
|
||||
status: TeammateStatus;
|
||||
last_message?: string;
|
||||
};
|
||||
|
||||
/** IPC event pushed to renderer when a new agent is spawned at runtime */
|
||||
export type ITeamAgentSpawnedEvent = {
|
||||
team_id: string;
|
||||
agent: TeamAgent;
|
||||
};
|
||||
|
||||
/** IPC event pushed to renderer when an agent is removed from the team */
|
||||
export type ITeamAgentRemovedEvent = {
|
||||
team_id: string;
|
||||
slot_id: string;
|
||||
};
|
||||
|
||||
/** IPC event pushed to renderer when an agent is renamed */
|
||||
export type ITeamAgentRenamedEvent = {
|
||||
team_id: string;
|
||||
slot_id: string;
|
||||
old_name: string;
|
||||
new_name: string;
|
||||
};
|
||||
|
||||
/** IPC event pushed to renderer when the team list changes (created/removed/agent changes) */
|
||||
export type ITeamListChangedEvent = {
|
||||
team_id: string;
|
||||
action: 'created' | 'removed' | 'agent_added' | 'agent_removed';
|
||||
};
|
||||
|
||||
/** IPC event pushed when a new team is created (backend `team.created` WS event) */
|
||||
export type ITeamCreatedEvent = {
|
||||
team_id: string;
|
||||
team_name: string;
|
||||
};
|
||||
|
||||
/** IPC event for real-time teammate-to-teammate messages (`team.teammate.message` WS event) */
|
||||
export type ITeamTeammateMessageEvent = {
|
||||
conversation_id: string;
|
||||
content: string;
|
||||
from_slot_id: string;
|
||||
from_name: string;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
declare module 'turndown-plugin-gfm' {
|
||||
import type TurndownService from 'turndown';
|
||||
|
||||
export function gfm(service: TurndownService): void;
|
||||
export function strikethrough(service: TurndownService): void;
|
||||
export function tables(service: TurndownService): void;
|
||||
export function taskListItems(service: TurndownService): void;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ambient shim for `vitest`. A handful of `*.test.ts` files import from
|
||||
* `vitest`, but the package is not installed (the project's runnable tests use
|
||||
* `bun:test`; see `bun-test.d.ts`). Without this shim every such import trips
|
||||
* TS2307 and inflates the typecheck baseline. The shim mirrors the small subset
|
||||
* of the vitest API these tests actually use.
|
||||
*/
|
||||
declare module 'vitest' {
|
||||
type TestFn = () => void | Promise<void>;
|
||||
|
||||
interface Matchers {
|
||||
not: Matchers;
|
||||
toBe(expected: unknown): void;
|
||||
toEqual(expected: unknown): void;
|
||||
toStrictEqual(expected: unknown): void;
|
||||
toHaveLength(expected: number): void;
|
||||
toContain(expected: unknown): void;
|
||||
toBeCloseTo(expected: number, precision?: number): void;
|
||||
toBeDefined(): void;
|
||||
toBeUndefined(): void;
|
||||
toBeTruthy(): void;
|
||||
toBeFalsy(): void;
|
||||
toBeNull(): void;
|
||||
toMatchObject(expected: unknown): void;
|
||||
toThrow(expected?: unknown): void;
|
||||
toBeGreaterThan(expected: number): void;
|
||||
toBeGreaterThanOrEqual(expected: number): void;
|
||||
toBeLessThan(expected: number): void;
|
||||
toBeLessThanOrEqual(expected: number): void;
|
||||
}
|
||||
|
||||
export function describe(name: string, fn: TestFn): void;
|
||||
export function it(name: string, fn: TestFn): void;
|
||||
export function test(name: string, fn: TestFn): void;
|
||||
export function expect(actual: unknown): Matchers;
|
||||
export function beforeEach(fn: TestFn): void;
|
||||
export function afterEach(fn: TestFn): void;
|
||||
export function beforeAll(fn: TestFn): void;
|
||||
export function afterAll(fn: TestFn): void;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
/** Primary download URL — rewritten to CDN for faster download. */
|
||||
url: string;
|
||||
/** Original GitHub download URL — used as fallback when CDN fails. */
|
||||
fallbackUrl?: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface UpdateReleaseInfo {
|
||||
tagName: string;
|
||||
version: string;
|
||||
name?: string;
|
||||
body?: string;
|
||||
htmlUrl: string;
|
||||
publishedAt?: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
assets: GitHubReleaseAsset[];
|
||||
recommendedAsset?: GitHubReleaseAsset;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
currentVersion: string;
|
||||
updateAvailable: boolean;
|
||||
latest?: UpdateReleaseInfo;
|
||||
}
|
||||
|
||||
export interface UpdateCheckRequest {
|
||||
includePrerelease?: boolean;
|
||||
/** Defaults to nomifun/nomifun-app when omitted */
|
||||
repo?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDownloadRequest {
|
||||
url: string;
|
||||
/** Fallback URL tried when the primary URL fails (e.g. CDN down). */
|
||||
fallbackUrl?: string;
|
||||
file_name?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDownloadResult {
|
||||
downloadId: string;
|
||||
file_path: string;
|
||||
}
|
||||
|
||||
export type UpdateDownloadStatus = 'starting' | 'downloading' | 'completed' | 'error' | 'cancelled';
|
||||
|
||||
export interface UpdateDownloadProgressEvent {
|
||||
downloadId: string;
|
||||
status: UpdateDownloadStatus;
|
||||
receivedBytes: number;
|
||||
totalBytes?: number;
|
||||
percent?: number;
|
||||
bytesPerSecond?: number;
|
||||
file_path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Auto-updater status types (electron-updater)
|
||||
export type AutoUpdateStatusType =
|
||||
| 'checking'
|
||||
| 'available'
|
||||
| 'not-available'
|
||||
| 'downloading'
|
||||
| 'downloaded'
|
||||
| 'error'
|
||||
| 'cancelled';
|
||||
|
||||
export interface AutoUpdateProgress {
|
||||
bytesPerSecond: number;
|
||||
percent: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AutoUpdateStatus {
|
||||
status: AutoUpdateStatusType;
|
||||
version?: string;
|
||||
releaseDate?: string;
|
||||
releaseNotes?: string;
|
||||
progress?: AutoUpdateProgress;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { ICreateConversationParams } from '@/common/adapter/ipcBridge';
|
||||
import type { TProviderWithModel } from '@/common/config/storage';
|
||||
|
||||
export type BuildAgentConversationPresetResources = {
|
||||
rules?: string;
|
||||
enabled_skills?: string[];
|
||||
exclude_auto_inject_skills?: string[];
|
||||
};
|
||||
|
||||
export type BuildAgentConversationInput = {
|
||||
backend: string;
|
||||
name: string;
|
||||
agent_id?: string;
|
||||
agent_name?: string;
|
||||
preset_assistant_id?: string;
|
||||
workspace: string;
|
||||
model: TProviderWithModel;
|
||||
cli_path?: string;
|
||||
custom_agent_id?: string;
|
||||
custom_workspace?: boolean;
|
||||
is_preset?: boolean;
|
||||
preset_agent_type?: string;
|
||||
preset_resources?: BuildAgentConversationPresetResources;
|
||||
session_mode?: string;
|
||||
current_model_id?: string;
|
||||
extra?: Partial<ICreateConversationParams['extra']>;
|
||||
};
|
||||
|
||||
export function getConversationTypeForBackend(backend: string): ICreateConversationParams['type'] {
|
||||
switch (backend) {
|
||||
case 'nomi':
|
||||
return 'nomi';
|
||||
case 'openclaw-gateway':
|
||||
case 'openclaw':
|
||||
return 'openclaw-gateway';
|
||||
case 'nanobot':
|
||||
return 'nanobot';
|
||||
case 'remote':
|
||||
return 'remote';
|
||||
default:
|
||||
return 'acp';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentConversationParams(input: BuildAgentConversationInput): ICreateConversationParams {
|
||||
const {
|
||||
backend,
|
||||
name,
|
||||
agent_id,
|
||||
agent_name,
|
||||
preset_assistant_id,
|
||||
workspace,
|
||||
model,
|
||||
cli_path,
|
||||
custom_agent_id,
|
||||
custom_workspace = true,
|
||||
is_preset = false,
|
||||
preset_agent_type,
|
||||
preset_resources,
|
||||
session_mode,
|
||||
current_model_id,
|
||||
extra: extraOverrides,
|
||||
} = input;
|
||||
|
||||
const effectivePresetType = preset_agent_type || backend;
|
||||
const effectivePresetAssistantId = preset_assistant_id || custom_agent_id;
|
||||
const type = getConversationTypeForBackend(is_preset ? effectivePresetType : backend);
|
||||
const extra: ICreateConversationParams['extra'] = {
|
||||
workspace,
|
||||
custom_workspace,
|
||||
...extraOverrides,
|
||||
};
|
||||
|
||||
if (is_preset) {
|
||||
// Transient create-request fields: backend's create handler consumes
|
||||
// them to compute extra.skills, then strips before persistence.
|
||||
if (preset_resources?.enabled_skills?.length) {
|
||||
extra.preset_enabled_skills = preset_resources.enabled_skills;
|
||||
}
|
||||
if (preset_resources?.exclude_auto_inject_skills?.length) {
|
||||
extra.exclude_auto_inject_skills = preset_resources.exclude_auto_inject_skills;
|
||||
}
|
||||
extra.preset_assistant_id = effectivePresetAssistantId;
|
||||
extra.preset_context = preset_resources?.rules;
|
||||
if (type === 'acp') {
|
||||
extra.backend = effectivePresetType as string;
|
||||
}
|
||||
} else if (type === 'remote') {
|
||||
// custom_agent_id carries the remote_agents row id stringified by the
|
||||
// agent-selection layer; parse it back to the integer FK the backend wants.
|
||||
extra.remote_agent_id = custom_agent_id != null ? Number(custom_agent_id) : undefined;
|
||||
} else if (type === 'openclaw-gateway') {
|
||||
extra.agent_name = agent_name || name;
|
||||
extra.gateway = {
|
||||
cli_path,
|
||||
};
|
||||
if (custom_agent_id) {
|
||||
extra.custom_agent_id = custom_agent_id;
|
||||
}
|
||||
} else if (type === 'acp') {
|
||||
extra.backend = backend as string;
|
||||
extra.agent_name = agent_name || name;
|
||||
if (agent_id) extra.agent_id = agent_id;
|
||||
if (cli_path) extra.cli_path = cli_path;
|
||||
if (custom_agent_id) {
|
||||
extra.custom_agent_id = custom_agent_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (session_mode) extra.session_mode = session_mode;
|
||||
if (current_model_id) extra.current_model_id = current_model_id;
|
||||
|
||||
return {
|
||||
type,
|
||||
model,
|
||||
name,
|
||||
extra,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Allowlist for built-in image generation tool.
|
||||
*
|
||||
* The tool currently only supports "form B" — OpenAI chat completions multimodal
|
||||
* output (model returns images via `message.images` or markdown). It does NOT
|
||||
* support "form A" (`/v1/images/generations` endpoint) or async/polling APIs.
|
||||
*
|
||||
* Model selection therefore must be a platform+model allowlist of providers
|
||||
* known to work, rather than a coarse name-substring match. Otherwise users
|
||||
* see options like `gpt-image-1` / `dall-e-3` / `sd-3.5` in the dropdown that
|
||||
* are guaranteed to fail at runtime.
|
||||
*
|
||||
* Rules below mirror `useConfigModelListWithImage.ts` — the same providers we
|
||||
* auto-supplement with default image models. When #6 lands a form-A adapter,
|
||||
* extend this list accordingly.
|
||||
*/
|
||||
|
||||
type ProviderShape = {
|
||||
platform?: string;
|
||||
base_url?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const IMAGE_NAME_PATTERN = /(image|banana|imagine)/i;
|
||||
|
||||
const RULES: Array<{
|
||||
id: string;
|
||||
match: (provider: ProviderShape) => boolean;
|
||||
}> = [
|
||||
{
|
||||
id: 'gemini',
|
||||
match: (p) => p.platform === 'gemini' || p.platform === 'gemini-vertex-ai',
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
match: (p) => !!p.base_url?.includes('openrouter.ai'),
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
match: (p) => !!p.name?.toLowerCase().includes('antigravity'),
|
||||
},
|
||||
];
|
||||
|
||||
export const isImageGenSupported = (provider: ProviderShape, modelName: string): boolean => {
|
||||
if (!IMAGE_NAME_PATTERN.test(modelName)) return false;
|
||||
return RULES.some((rule) => rule.match(provider));
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
export { uuid, parseError, resolveLocaleKey } from './utils';
|
||||
export { shortId, prefixedId } from './prefixedId';
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import type { IProvider, ModelType } from '@/common/config/storage';
|
||||
|
||||
/**
|
||||
* Capability matching regex patterns
|
||||
*/
|
||||
export const CAPABILITY_PATTERNS: Record<ModelType, RegExp> = {
|
||||
text: /gpt|claude|gemini|qwen|llama|mistral|deepseek/i,
|
||||
vision: /4o|claude-3|gemini-.*-pro|gemini-.*-flash|gemini-2\.0|qwen-vl|llava|vision/i,
|
||||
function_calling: /gpt-4|claude-3|gemini|qwen|deepseek/i,
|
||||
image_generation: /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|imagen/i,
|
||||
web_search: /search|perplexity/i,
|
||||
reasoning: /o1-|reasoning|think/i,
|
||||
embedding: /(?:^text-|embed|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings|voyage-)/i,
|
||||
rerank: /(?:rerank|re-rank|re-ranker|re-ranking|retrieval|retriever)/i,
|
||||
excludeFromPrimary: /dall-e|flux|stable-diffusion|midjourney|flash-image|image|embed|rerank/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit exclusion lists (blacklist) for capabilities
|
||||
*/
|
||||
export const CAPABILITY_EXCLUSIONS: Record<ModelType, RegExp[]> = {
|
||||
text: [],
|
||||
vision: [/embed|rerank|dall-e|flux|stable-diffusion/i],
|
||||
function_calling: [
|
||||
/aqa(?:-[\w-]+)?/i,
|
||||
/imagen(?:-[\w-]+)?/i,
|
||||
/o1-mini/i,
|
||||
/o1-preview/i,
|
||||
/gemini-1(?:\\.[\w-]+)?/i,
|
||||
/dall-e/i,
|
||||
/embed/i,
|
||||
/rerank/i,
|
||||
],
|
||||
image_generation: [],
|
||||
web_search: [],
|
||||
reasoning: [],
|
||||
embedding: [],
|
||||
rerank: [],
|
||||
excludeFromPrimary: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the lowercase, normalized base model name for matching.
|
||||
*/
|
||||
export const getBaseModelName = (modelName: string): string => {
|
||||
return modelName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9./-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a specific model within a provider has a given capability.
|
||||
* Returns true (supported), false (excluded), or undefined (unknown).
|
||||
*/
|
||||
export const hasSpecificModelCapability = (
|
||||
_platformModel: IProvider,
|
||||
modelName: string,
|
||||
type: ModelType
|
||||
): boolean | undefined => {
|
||||
const baseModelName = getBaseModelName(modelName);
|
||||
const exclusions = CAPABILITY_EXCLUSIONS[type];
|
||||
const pattern = CAPABILITY_PATTERNS[type];
|
||||
|
||||
const isExcluded = exclusions.some((excludePattern) => excludePattern.test(baseModelName));
|
||||
if (isExcluded) return false;
|
||||
|
||||
return pattern.test(baseModelName) ? true : undefined;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { AuthType } from '@/common/api/authType';
|
||||
import { isNewApiPlatform } from './platformConstants';
|
||||
|
||||
/**
|
||||
* 根据平台名称获取对应的认证类型
|
||||
* @param platform 平台名称
|
||||
* @returns 对应的AuthType
|
||||
*/
|
||||
export function getAuthTypeFromPlatform(platform: string): AuthType {
|
||||
const platformLower = platform?.toLowerCase() || '';
|
||||
|
||||
// Google OAuth (kept for the Google auth provider path, not the removed
|
||||
// Gemini CLI LLM flavor)
|
||||
if (platformLower.includes('gemini-with-google-auth')) {
|
||||
return AuthType.LOGIN_WITH_GOOGLE;
|
||||
}
|
||||
|
||||
if (platformLower.includes('gemini-vertex-ai')) {
|
||||
return AuthType.USE_VERTEX_AI;
|
||||
}
|
||||
|
||||
if (platformLower.includes('gemini')) {
|
||||
return AuthType.USE_GEMINI;
|
||||
}
|
||||
|
||||
// Anthropic/Claude 相关平台
|
||||
if (platformLower.includes('anthropic') || platformLower.includes('claude')) {
|
||||
return AuthType.USE_ANTHROPIC;
|
||||
}
|
||||
|
||||
// AWS Bedrock 平台
|
||||
if (platformLower.includes('bedrock')) {
|
||||
return AuthType.USE_BEDROCK;
|
||||
}
|
||||
|
||||
// New API 网关默认使用 OpenAI 兼容协议(per-model 协议由 getProviderAuthType 处理)
|
||||
// New API gateway defaults to OpenAI compatible (per-model protocol handled by getProviderAuthType)
|
||||
// 其他所有平台默认使用OpenAI兼容协议
|
||||
// 包括:OpenRouter, OpenAI, DeepSeek, new-api, 等
|
||||
return AuthType.USE_OPENAI;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取provider的认证类型,优先使用明确指定的authType,否则根据platform推断
|
||||
* 对于 new-api 平台,支持基于模型名称的协议覆盖
|
||||
* Get provider auth type, prefer explicit authType, otherwise infer from platform
|
||||
* For new-api platform, supports per-model protocol overrides
|
||||
* @param provider 包含platform和可选authType的provider配置
|
||||
* @returns 认证类型
|
||||
*/
|
||||
export function getProviderAuthType(provider: {
|
||||
platform: string;
|
||||
auth_type?: AuthType;
|
||||
model_protocols?: Record<string, string>;
|
||||
use_model?: string;
|
||||
}): AuthType {
|
||||
// If auth_type is explicitly specified, use it directly
|
||||
if (provider.auth_type) {
|
||||
return provider.auth_type;
|
||||
}
|
||||
|
||||
// new-api 平台:根据模型名称查找协议覆盖
|
||||
// new-api platform: look up per-model protocol override
|
||||
if (isNewApiPlatform(provider.platform) && provider.use_model && provider.model_protocols) {
|
||||
const protocol = provider.model_protocols[provider.use_model];
|
||||
if (protocol) {
|
||||
return getAuthTypeFromPlatform(protocol);
|
||||
}
|
||||
}
|
||||
|
||||
// 否则根据platform推断
|
||||
return getAuthTypeFromPlatform(provider.platform);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* New API 网关平台标识
|
||||
* New API gateway platform identifier
|
||||
*/
|
||||
export const NEW_API_PLATFORM_ID = 'new-api';
|
||||
|
||||
/**
|
||||
* 检查平台是否为 New API 网关类型
|
||||
* Check if platform is New API gateway type
|
||||
*/
|
||||
export const isNewApiPlatform = (platform: string): boolean => {
|
||||
return platform === NEW_API_PLATFORM_ID;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lowercase Crockford-style base32 alphabet (drops i/l/o/u for legibility),
|
||||
* in ascending ASCII order so a fixed-width big-endian encoding sorts
|
||||
* lexicographically by the integer it encodes. MUST match
|
||||
* `SHORT_ID_ALPHABET` in the Rust `nomifun-common::id` module.
|
||||
*/
|
||||
const SHORT_ID_ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
|
||||
|
||||
/** Base32 char counts for the 45-bit timestamp and 35-bit random components. */
|
||||
const TIME_CHARS = 9;
|
||||
const RAND_CHARS = 7;
|
||||
|
||||
/**
|
||||
* Encode the low `chars * 5` bits of `value` as `chars` base32 characters,
|
||||
* most-significant character first (big-endian), so the text sorts like the
|
||||
* value. Mirrors `encode_base32` on the Rust side.
|
||||
*/
|
||||
const encodeBase32 = (value: bigint, chars: number): string => {
|
||||
let v = value;
|
||||
const out = new Array<string>(chars);
|
||||
for (let i = chars - 1; i >= 0; i--) {
|
||||
out[i] = SHORT_ID_ALPHABET[Number(v & 31n)];
|
||||
v >>= 5n;
|
||||
}
|
||||
return out.join('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Self-contained sortable short-id generator: a 45-bit unix-millisecond
|
||||
* timestamp (9 base32 chars) followed by 35 random bits (7 base32 chars),
|
||||
* for a 16-char body. Mirrors the Rust side
|
||||
* (`nomifun-common::generate_prefixed_id`), so ids minted by either side
|
||||
* interleave and sort identically — they are lexicographically time-ordered
|
||||
* and globally unique, but roughly half the length of the former UUIDv7 tail.
|
||||
*
|
||||
* Uses crypto.getRandomValues when available; falls back to Math.random
|
||||
* (format preserved, randomness not cryptographically secure).
|
||||
*/
|
||||
export const shortId = (): string => {
|
||||
const ms = BigInt(Date.now()) & ((1n << 45n) - 1n);
|
||||
|
||||
const words = new Uint32Array(2);
|
||||
let filled = false;
|
||||
try {
|
||||
const cryptoObj = globalThis.crypto;
|
||||
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
|
||||
cryptoObj.getRandomValues(words);
|
||||
filled = true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to Math.random
|
||||
}
|
||||
if (!filled) {
|
||||
words[0] = Math.floor(Math.random() * 2 ** 32) >>> 0;
|
||||
words[1] = Math.floor(Math.random() * 2 ** 32) >>> 0;
|
||||
}
|
||||
const rand = ((BigInt(words[0]) << 32n) | BigInt(words[1])) & ((1n << 35n) - 1n);
|
||||
|
||||
return encodeBase32(ms, TIME_CHARS) + encodeBase32(rand, RAND_CHARS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mint an entity ID in the unified `{prefix}_{shortId}` format, e.g.
|
||||
* `prefixedId('msg')` -> `msg_0fh3k…`. Frontend mirror of the Rust
|
||||
* `nomifun-common::generate_prefixed_id` — the minting convention for the
|
||||
* TEXT short-id entities (messages `msg_`, providers `prov_`, …).
|
||||
*
|
||||
* NOTE: conversations/requirements/terminal sessions are now
|
||||
* `INTEGER PRIMARY KEY AUTOINCREMENT` and are minted **only** by the backend
|
||||
* (`last_insert_rowid()`); the frontend must NOT mint `conv_`/`req_`/`term_`
|
||||
* ids — create the row first and use the integer id the backend returns. See
|
||||
* the numeric-id spec §5 (create flow). Use this for any TEXT id that is sent
|
||||
* to / stored by the backend; for throwaway local UI keys, `uuid()` from
|
||||
* ./utils is fine.
|
||||
*/
|
||||
export const prefixedId = (prefix: string): string => `${prefix}_${shortId()}`;
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
import { ipcBridge } from '@/common';
|
||||
|
||||
/**
|
||||
* Thin pass-through over `ipcBridge.fs.readAssistant{Rule,Skill}`. The backend
|
||||
* performs source classification (builtin / user / extension) and serves the
|
||||
* appropriate rule md from the backend manifest, extension bundle, or user
|
||||
* directory. Callers no longer need to distinguish builtin vs. user here.
|
||||
*
|
||||
* `enabledSkills` / `excludeAutoInjectSkills` are now part of the Assistant
|
||||
* record returned by `/api/assistants`; callers should read them directly from
|
||||
* there rather than via this helper. The override hooks on
|
||||
* `PresetAssistantResourceDeps` remain for tests and narrowly-scoped call sites
|
||||
* that need custom read/list behavior.
|
||||
*/
|
||||
|
||||
export type PresetAssistantResourceDeps = {
|
||||
readAssistantRule: (args: { assistant_id: string; locale: string }) => Promise<string>;
|
||||
readAssistantSkill: (args: { assistant_id: string; locale: string }) => Promise<string>;
|
||||
getEnabledSkills: (custom_agent_id: string) => Promise<string[] | undefined>;
|
||||
getExcludeAutoInjectSkills: (custom_agent_id: string) => Promise<string[] | undefined>;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
};
|
||||
|
||||
export type LoadPresetAssistantResourcesOptions = {
|
||||
custom_agent_id?: string;
|
||||
localeKey: string;
|
||||
fallbackRules?: string;
|
||||
};
|
||||
|
||||
export type PresetAssistantResources = {
|
||||
rules?: string;
|
||||
skills: string;
|
||||
enabled_skills?: string[];
|
||||
exclude_auto_inject_skills?: string[];
|
||||
};
|
||||
|
||||
const defaultDeps: PresetAssistantResourceDeps = {
|
||||
readAssistantRule: (args) => ipcBridge.fs.readAssistantRule.invoke(args),
|
||||
readAssistantSkill: (args) => ipcBridge.fs.readAssistantSkill.invoke(args),
|
||||
getEnabledSkills: async (custom_agent_id) => {
|
||||
try {
|
||||
const list = await ipcBridge.assistants.list.invoke();
|
||||
return list.find((a) => a.id === custom_agent_id)?.enabled_skills;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
getExcludeAutoInjectSkills: async (custom_agent_id) => {
|
||||
try {
|
||||
const list = await ipcBridge.assistants.list.invoke();
|
||||
return list.find((a) => a.id === custom_agent_id)?.disabled_builtin_skills;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
warn: (message, error) => {
|
||||
console.warn(message, error);
|
||||
},
|
||||
};
|
||||
|
||||
export async function loadPresetAssistantResources(
|
||||
options: LoadPresetAssistantResourcesOptions,
|
||||
deps: PresetAssistantResourceDeps = defaultDeps
|
||||
): Promise<PresetAssistantResources> {
|
||||
const { custom_agent_id, localeKey, fallbackRules } = options;
|
||||
|
||||
if (!custom_agent_id) {
|
||||
return {
|
||||
rules: fallbackRules,
|
||||
skills: '',
|
||||
enabled_skills: undefined,
|
||||
exclude_auto_inject_skills: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let rules = '';
|
||||
let skills = '';
|
||||
|
||||
try {
|
||||
rules = (await deps.readAssistantRule({ assistant_id: custom_agent_id, locale: localeKey })) || '';
|
||||
} catch (error) {
|
||||
deps.warn(`[presetAssistantResources] Failed to load rules for ${custom_agent_id}`, error);
|
||||
}
|
||||
|
||||
try {
|
||||
skills = (await deps.readAssistantSkill({ assistant_id: custom_agent_id, locale: localeKey })) || '';
|
||||
} catch (error) {
|
||||
deps.warn(`[presetAssistantResources] Failed to load skills for ${custom_agent_id}`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
rules: rules || fallbackRules,
|
||||
skills,
|
||||
enabled_skills: await deps.getEnabledSkills(custom_agent_id),
|
||||
exclude_auto_inject_skills: await deps.getExcludeAutoInjectSkills(custom_agent_id),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* NomiRouter 协议检测器
|
||||
* Protocol Detector for NomiRouter
|
||||
*
|
||||
* 支持自动检测 API 端点使用的协议类型:
|
||||
* - OpenAI 协议(大多数第三方服务)
|
||||
* - Gemini 协议(Google 官方)
|
||||
* - Anthropic 协议(Claude 官方)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 支持的协议类型
|
||||
* Supported protocol types
|
||||
*/
|
||||
export type ProtocolType = 'openai' | 'gemini' | 'anthropic' | 'unknown';
|
||||
|
||||
/**
|
||||
* 多 Key 测试结果
|
||||
* Multi-key test result
|
||||
*/
|
||||
export interface MultiKeyTestResult {
|
||||
/** 总 Key 数量 / Total key count */
|
||||
total: number;
|
||||
/** 有效 Key 数量 / Valid key count */
|
||||
valid: number;
|
||||
/** 无效 Key 数量 / Invalid key count */
|
||||
invalid: number;
|
||||
/** 每个 Key 的详细结果 / Detailed result for each key */
|
||||
details: Array<{
|
||||
/** Key 索引 / Key index */
|
||||
index: number;
|
||||
/** Key 掩码(只显示前后几位)/ Masked key */
|
||||
maskedKey: string;
|
||||
/** 是否有效 / Whether valid */
|
||||
valid: boolean;
|
||||
/** 错误信息 / Error message */
|
||||
error?: string;
|
||||
/** 响应时间 / Latency */
|
||||
latency?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议检测请求参数
|
||||
* Protocol detection request parameters
|
||||
*/
|
||||
export interface ProtocolDetectionRequest {
|
||||
/** Base URL */
|
||||
base_url: string;
|
||||
/** API Key(可以是逗号或换行分隔的多个 Key)/ API Key (can be comma or newline separated) */
|
||||
api_key: string;
|
||||
/** 超时时间(毫秒)/ Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** 是否测试所有 Key(默认只测试第一个)/ Whether to test all keys */
|
||||
testAllKeys?: boolean;
|
||||
/** 指定要测试的协议(如果已知)/ Specific protocol to test (if known) */
|
||||
preferredProtocol?: ProtocolType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议检测响应
|
||||
* Protocol detection response
|
||||
*/
|
||||
export interface ProtocolDetectionResponse {
|
||||
/** 是否成功 / Whether successful */
|
||||
success: boolean;
|
||||
/** 检测到的协议 / Detected protocol */
|
||||
protocol: ProtocolType;
|
||||
/** 置信度 / Confidence */
|
||||
confidence: number;
|
||||
/** 错误信息 / Error message */
|
||||
error?: string;
|
||||
/** 修正后的 base URL / Fixed base URL */
|
||||
fixedBaseUrl?: string;
|
||||
/** 建议操作 / Suggested action */
|
||||
suggestion?: {
|
||||
/** 建议类型 / Suggestion type */
|
||||
type: 'switch_platform' | 'fix_url' | 'check_key' | 'none';
|
||||
/** 建议消息 / Suggestion message */
|
||||
message: string;
|
||||
/** 建议的平台 / Suggested platform */
|
||||
suggestedPlatform?: string;
|
||||
/** i18n key(前端使用)/ i18n key for frontend */
|
||||
i18nKey?: string;
|
||||
/** i18n 参数 / i18n parameters */
|
||||
i18nParams?: Record<string, string>;
|
||||
};
|
||||
/** 多 Key 测试结果(如果启用)/ Multi-key test result if enabled */
|
||||
multiKeyResult?: MultiKeyTestResult;
|
||||
/** 模型列表 / Model list */
|
||||
models?: string[];
|
||||
/**
|
||||
* 检测到的所有协议(聚合网关可能在同一地址同时提供多种协议,如 gpt + claude)
|
||||
* All protocols that succeeded — aggregator gateways may serve several on one base_url (e.g. gpt + claude).
|
||||
*/
|
||||
detectedProtocols?: Array<{
|
||||
protocol: ProtocolType;
|
||||
confidence: number;
|
||||
models?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议特征定义
|
||||
* Protocol signature definitions
|
||||
*/
|
||||
interface ProtocolSignature {
|
||||
/** 协议类型 / Protocol type */
|
||||
protocol: ProtocolType;
|
||||
/** 测试端点模板 / Test endpoint templates */
|
||||
endpoints: Array<{
|
||||
path: string;
|
||||
method: 'GET' | 'POST';
|
||||
/** 请求头 / Headers */
|
||||
headers?: (api_key: string) => Record<string, string>;
|
||||
/** 请求体(POST 请求)/ Request body for POST */
|
||||
body?: object;
|
||||
/** 响应验证器 / Response validator */
|
||||
validator: (response: any, status: number) => boolean;
|
||||
}>;
|
||||
/** API Key 格式验证 / API Key format validation */
|
||||
keyPattern?: RegExp;
|
||||
/** URL 特征 / URL characteristics */
|
||||
urlPatterns?: RegExp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议签名配置
|
||||
* Protocol signature configurations
|
||||
*
|
||||
* 参考 GPT-Load 的 Channel 设计,每个协议定义其特征
|
||||
* Reference GPT-Load Channel design, each protocol defines its signatures
|
||||
*/
|
||||
export const PROTOCOL_SIGNATURES: ProtocolSignature[] = [
|
||||
// Gemini 协议
|
||||
{
|
||||
protocol: 'gemini',
|
||||
// Gemini API Key 格式:AIza 开头,后跟 35 个字符
|
||||
// Gemini API Key format: starts with AIza, followed by 35 characters
|
||||
keyPattern: /^AIza[A-Za-z0-9_-]{35}$/,
|
||||
urlPatterns: [
|
||||
/generativelanguage\.googleapis\.com/, // 标准 Gemini API
|
||||
/aiplatform\.googleapis\.com/, // Vertex AI
|
||||
/gemini\.google\.com/, // Gemini 网页版
|
||||
/aistudio\.google\.com/, // AI Studio
|
||||
],
|
||||
endpoints: [
|
||||
{
|
||||
path: '/v1beta/models',
|
||||
method: 'GET',
|
||||
headers: () => ({}),
|
||||
validator: (response, status) => {
|
||||
if (status !== 200) return false;
|
||||
return response?.models && Array.isArray(response.models);
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/v1/models',
|
||||
method: 'GET',
|
||||
headers: () => ({}),
|
||||
validator: (response, status) => {
|
||||
if (status !== 200) return false;
|
||||
return response?.models && Array.isArray(response.models);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// OpenAI 协议(包括兼容服务)
|
||||
{
|
||||
protocol: 'openai',
|
||||
// OpenAI Key 格式多样:
|
||||
// - 标准格式: sk-xxx
|
||||
// - 项目 Key: sk-proj-xxx
|
||||
// - 服务账号: sk-svcacct-xxx
|
||||
// - 第三方服务可能使用其他格式
|
||||
keyPattern: /^sk-[A-Za-z0-9-_]{20,}$/,
|
||||
urlPatterns: [
|
||||
/api\.openai\.com/, // OpenAI 官方
|
||||
/\.openai\.azure\.com/, // Azure OpenAI
|
||||
/api\.deepseek\.com/, // DeepSeek
|
||||
/api\.moonshot\.cn/, // Moonshot/Kimi China
|
||||
/api\.moonshot\.ai/, // Moonshot/Kimi Global
|
||||
/api\.mistral\.ai/, // Mistral AI
|
||||
/api\.groq\.com/, // Groq
|
||||
/openrouter\.ai/, // OpenRouter
|
||||
/api\.together\.xyz/, // Together AI
|
||||
/api\.perplexity\.ai/, // Perplexity
|
||||
/dashscope\.aliyuncs\.com/, // 阿里云 DashScope
|
||||
/aip\.baidubce\.com/, // 百度千帆
|
||||
/ark\.cn-beijing\.volces\.com/, // 火山引擎
|
||||
/open\.bigmodel\.cn/, // 智谱 AI
|
||||
/api\.siliconflow\.cn/, // SiliconFlow
|
||||
/api\.siliconflow\.com/, // SiliconFlow (.com)
|
||||
/api\.lingyiwanwu\.com/, // 零一万物
|
||||
/api\.minimaxi\.com/, // MiniMax China
|
||||
/api\.minimax\.io/, // MiniMax Global
|
||||
/platform\.minimaxi\.com/, // MiniMax Platform
|
||||
/localhost/, // 本地服务
|
||||
/127\.0\.0\.1/, // 本地服务
|
||||
/0\.0\.0\.0/, // 本地服务
|
||||
],
|
||||
endpoints: [
|
||||
{
|
||||
path: '/models',
|
||||
method: 'GET',
|
||||
headers: (api_key) => ({
|
||||
Authorization: `Bearer ${api_key}`,
|
||||
}),
|
||||
validator: (response, status) => {
|
||||
if (status !== 200) return false;
|
||||
return response?.data && Array.isArray(response.data);
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/v1/models',
|
||||
method: 'GET',
|
||||
headers: (api_key) => ({
|
||||
Authorization: `Bearer ${api_key}`,
|
||||
}),
|
||||
validator: (response, status) => {
|
||||
if (status !== 200) return false;
|
||||
return response?.data && Array.isArray(response.data);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Anthropic 协议
|
||||
{
|
||||
protocol: 'anthropic',
|
||||
// Anthropic Key 格式:sk-ant- 开头
|
||||
keyPattern: /^sk-ant-[A-Za-z0-9-]{80,}$/,
|
||||
urlPatterns: [
|
||||
/api\.anthropic\.com/, // Anthropic 官方
|
||||
/claude\.ai/, // Claude 网页版
|
||||
],
|
||||
endpoints: [
|
||||
{
|
||||
// Anthropic 没有 models 端点,使用 messages 端点测试
|
||||
// Anthropic doesn't have models endpoint, use messages endpoint
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: (api_key) => ({
|
||||
'x-api-key': api_key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: {
|
||||
model: 'claude-3-haiku-20240307',
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
},
|
||||
validator: (_response, status) => {
|
||||
// 200 或 400(参数错误但认证成功)都认为是有效的
|
||||
// 200 or 400 (param error but auth success) are both valid
|
||||
return status === 200 || status === 400;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 已知的第三方 OpenAI 兼容服务 Key 格式
|
||||
* Known third-party OpenAI-compatible service key patterns
|
||||
*
|
||||
* 这些服务使用 OpenAI 协议,但 Key 格式不同
|
||||
* These services use OpenAI protocol but with different key formats
|
||||
*/
|
||||
export const THIRD_PARTY_KEY_PATTERNS: Array<{ pattern: RegExp; name: string; protocol: ProtocolType }> = [
|
||||
{ pattern: /^sk-[A-Za-z0-9-_]{20,}$/, name: 'OpenAI/Compatible', protocol: 'openai' },
|
||||
{ pattern: /^AIza[A-Za-z0-9_-]{35}$/, name: 'Google/Gemini', protocol: 'gemini' },
|
||||
{ pattern: /^sk-ant-[A-Za-z0-9-]{80,}$/, name: 'Anthropic', protocol: 'anthropic' },
|
||||
{ pattern: /^gsk_[A-Za-z0-9]{52}$/, name: 'Groq', protocol: 'openai' },
|
||||
{ pattern: /^pplx-[A-Za-z0-9]{48}$/, name: 'Perplexity', protocol: 'openai' },
|
||||
{ pattern: /^[A-Za-z0-9]{32}$/, name: 'DeepSeek/Moonshot', protocol: 'openai' },
|
||||
{ pattern: /^[A-Za-z0-9]{64}$/, name: 'SiliconFlow/Together', protocol: 'openai' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 解析多个 API Key
|
||||
* Parse multiple API keys from string
|
||||
*/
|
||||
export function parseApiKeys(api_keyString: string): string[] {
|
||||
if (!api_keyString) return [];
|
||||
return api_keyString
|
||||
.split(/[,\n]/)
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 掩码 API Key
|
||||
* Mask API key for display
|
||||
*/
|
||||
export function maskApiKey(api_key: string): string {
|
||||
if (api_key.length <= 8) return '***';
|
||||
return `${api_key.substring(0, 4)}...${api_key.substring(api_key.length - 4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 常见的 API 路径后缀
|
||||
* Common API path suffixes
|
||||
*
|
||||
* 用于生成候选 URL 列表,当用户输入完整端点 URL 时可以尝试移除这些后缀
|
||||
* Used to generate candidate URL list when user enters full endpoint URL
|
||||
*/
|
||||
export const API_PATH_SUFFIXES = [
|
||||
// Gemini 路径
|
||||
'/v1beta/models',
|
||||
'/v1/models',
|
||||
'/models',
|
||||
// OpenAI 路径
|
||||
'/v1/chat/completions',
|
||||
'/chat/completions',
|
||||
'/v1/completions',
|
||||
'/completions',
|
||||
'/v1/embeddings',
|
||||
'/embeddings',
|
||||
// Anthropic 路径
|
||||
'/v1/messages',
|
||||
'/messages',
|
||||
];
|
||||
|
||||
/**
|
||||
* 规范化 Base URL(仅做基本清理)
|
||||
* Normalize base URL (basic cleanup only)
|
||||
*
|
||||
* 只移除末尾斜杠,不修改路径
|
||||
* Only removes trailing slashes, does not modify path
|
||||
*/
|
||||
export function normalizeBaseUrl(base_url: string): string {
|
||||
if (!base_url) return '';
|
||||
let url = base_url.trim();
|
||||
// 移除末尾斜杠
|
||||
url = url.replace(/\/+$/, '');
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 中移除已知的 API 路径后缀
|
||||
* Remove known API path suffix from URL
|
||||
*/
|
||||
export function removeApiPathSuffix(base_url: string): string | null {
|
||||
if (!base_url) return null;
|
||||
const url = base_url.replace(/\/+$/, '');
|
||||
|
||||
// 按长度降序排列,先匹配更长的路径
|
||||
const sortedSuffixes = [...API_PATH_SUFFIXES].toSorted((a, b) => b.length - a.length);
|
||||
for (const suffix of sortedSuffixes) {
|
||||
if (url.toLowerCase().endsWith(suffix.toLowerCase())) {
|
||||
return url.slice(0, -suffix.length).replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
return null; // 没有匹配的后缀
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 URL 猜测协议类型
|
||||
* Guess protocol type from URL
|
||||
*/
|
||||
export function guessProtocolFromUrl(base_url: string): ProtocolType | null {
|
||||
const url = base_url.toLowerCase();
|
||||
|
||||
for (const sig of PROTOCOL_SIGNATURES) {
|
||||
if (sig.urlPatterns) {
|
||||
for (const pattern of sig.urlPatterns) {
|
||||
if (pattern.test(url)) {
|
||||
return sig.protocol;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 API Key 格式猜测协议类型
|
||||
* Guess protocol type from API key format
|
||||
*
|
||||
* 优先匹配更具体的模式,然后是通用模式
|
||||
* Prioritize more specific patterns, then general patterns
|
||||
*/
|
||||
export function guessProtocolFromKey(api_key: string): ProtocolType | null {
|
||||
// 先尝试标准协议签名
|
||||
for (const sig of PROTOCOL_SIGNATURES) {
|
||||
if (sig.keyPattern && sig.keyPattern.test(api_key)) {
|
||||
return sig.protocol;
|
||||
}
|
||||
}
|
||||
|
||||
// 再尝试第三方服务 Key 格式
|
||||
for (const pattern of THIRD_PARTY_KEY_PATTERNS) {
|
||||
if (pattern.pattern.test(api_key)) {
|
||||
return pattern.protocol;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 API Key 识别服务提供商名称
|
||||
* Identify service provider name from API key
|
||||
*/
|
||||
export function identifyProviderFromKey(api_key: string): string | null {
|
||||
for (const pattern of THIRD_PARTY_KEY_PATTERNS) {
|
||||
if (pattern.pattern.test(api_key)) {
|
||||
return pattern.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取协议的显示名称
|
||||
* Get display name for protocol
|
||||
*/
|
||||
export function getProtocolDisplayName(protocol: ProtocolType): string {
|
||||
const names: Record<ProtocolType, string> = {
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
anthropic: 'Anthropic',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
return names[protocol] || protocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取协议对应的推荐平台
|
||||
* Get recommended platform for protocol
|
||||
*/
|
||||
export function getRecommendedPlatform(protocol: ProtocolType): string | null {
|
||||
const platforms: Record<ProtocolType, string | null> = {
|
||||
openai: null, // OpenAI 协议是当前项目通过 custom 支持的
|
||||
gemini: 'gemini',
|
||||
anthropic: 'Anthropic',
|
||||
unknown: null,
|
||||
};
|
||||
return platforms[protocol];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Use a path that won't be caught by the @xterm/headless alias
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const xtermHeadless = require('@xterm/headless/lib-headless/xterm-headless.js');
|
||||
const Terminal = xtermHeadless.Terminal;
|
||||
|
||||
export { Terminal };
|
||||
export default { Terminal };
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* API 提供商主机配置
|
||||
* API Provider Host Configuration
|
||||
*
|
||||
* 集中管理各 AI 服务商的官方 API 主机名
|
||||
* Centralized management of official API hostnames for AI providers
|
||||
*/
|
||||
export const API_HOST_CONFIG = {
|
||||
/**
|
||||
* Google AI 官方主机
|
||||
* Google AI Official Hosts
|
||||
*/
|
||||
google: {
|
||||
/** Gemini API (generativelanguage.googleapis.com) */
|
||||
gemini: 'generativelanguage.googleapis.com',
|
||||
/** Vertex AI (aiplatform.googleapis.com) */
|
||||
vertexAi: 'aiplatform.googleapis.com',
|
||||
},
|
||||
|
||||
/**
|
||||
* OpenAI 官方主机
|
||||
* OpenAI Official Hosts
|
||||
*/
|
||||
openai: {
|
||||
api: 'api.openai.com',
|
||||
},
|
||||
|
||||
/**
|
||||
* Anthropic 官方主机
|
||||
* Anthropic Official Hosts
|
||||
*/
|
||||
anthropic: {
|
||||
api: 'api.anthropic.com',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Google API 主机白名单(从配置派生)
|
||||
* Google API Hosts Whitelist (derived from config)
|
||||
*/
|
||||
export const GOOGLE_API_HOSTS = Object.values(API_HOST_CONFIG.google);
|
||||
|
||||
/**
|
||||
* 安全验证 URL 是否为指定提供商的官方主机
|
||||
* Safely validate if URL is an official host for specified provider
|
||||
*
|
||||
* @param urlString - 要验证的 URL 字符串 / URL string to validate
|
||||
* @param allowedHosts - 允许的主机名列表 / List of allowed hostnames
|
||||
* @returns 如果是有效的官方主机返回 true / Returns true if valid official host
|
||||
*/
|
||||
export function isOfficialHost(urlString: string, allowedHosts: readonly string[]): boolean {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
return allowedHosts.includes(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全验证 URL 是否为 Google APIs 主机
|
||||
* Safely validate if URL is a Google APIs host
|
||||
*
|
||||
* 使用 URL 解析而非字符串包含检查,防止恶意 URL 绕过
|
||||
* Uses URL parsing instead of string includes to prevent malicious URL bypass
|
||||
*
|
||||
* @param urlString - 要验证的 URL 字符串 / URL string to validate
|
||||
* @returns 如果是有效的 Google APIs 主机返回 true / Returns true if valid Google APIs host
|
||||
*
|
||||
* @example
|
||||
* isGoogleApisHost('https://generativelanguage.googleapis.com/v1') // true
|
||||
* isGoogleApisHost('https://evil.com/generativelanguage.googleapis.com') // false
|
||||
* isGoogleApisHost('https://generativelanguage.googleapis.com.evil.com') // false
|
||||
*/
|
||||
export function isGoogleApisHost(urlString: string): boolean {
|
||||
return isOfficialHost(urlString, GOOGLE_API_HOSTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 URL 是否为 OpenAI 官方主机
|
||||
* Validate if URL is an official OpenAI host
|
||||
*/
|
||||
export function isOpenAIHost(urlString: string): boolean {
|
||||
return isOfficialHost(urlString, Object.values(API_HOST_CONFIG.openai));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 URL 是否为 Anthropic 官方主机
|
||||
* Validate if URL is an official Anthropic host
|
||||
*/
|
||||
export function isAnthropicHost(urlString: string): boolean {
|
||||
return isOfficialHost(urlString, Object.values(API_HOST_CONFIG.anthropic));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveLocaleKey } from './utils';
|
||||
|
||||
describe('resolveLocaleKey', () => {
|
||||
test('only resolves supported app language families to Chinese or English locale keys', () => {
|
||||
expect(resolveLocaleKey('zh-CN')).toBe('zh-CN');
|
||||
expect(resolveLocaleKey('zh')).toBe('zh-CN');
|
||||
expect(resolveLocaleKey('en-US')).toBe('en-US');
|
||||
expect(resolveLocaleKey('ja-JP')).toBe('en-US');
|
||||
expect(resolveLocaleKey('ko-KR')).toBe('en-US');
|
||||
expect(resolveLocaleKey('tr-TR')).toBe('en-US');
|
||||
expect(resolveLocaleKey('ru-RU')).toBe('en-US');
|
||||
expect(resolveLocaleKey('uk-UA')).toBe('en-US');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Based on AionUi (https://github.com/iOfficeAI/AionUi)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Short random hex string for *local, throwaway* UI keys (React list keys,
|
||||
* row ids, component instance ids). NOT for entity IDs: anything persisted
|
||||
* by / sent to the backend (conversations, messages, providers, …) must use
|
||||
* `prefixedId(prefix)` from ./prefixedId, which mints the unified
|
||||
* `{prefix}_{uuidv7}` format shared with the Rust backend.
|
||||
*/
|
||||
export const uuid = (length = 8) => {
|
||||
try {
|
||||
// globalThis.crypto is available in all modern browsers and Node.js 19+
|
||||
const crypto = globalThis.crypto;
|
||||
if (crypto) {
|
||||
if (typeof crypto.randomUUID === 'function' && length >= 36) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
if (typeof crypto.getRandomValues === 'function') {
|
||||
const bytes = new Uint8Array(Math.ceil(length / 2));
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, length);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback without crypto
|
||||
}
|
||||
|
||||
// Monotonic fallback without cryptographically secure randomness
|
||||
const base = Date.now().toString(36);
|
||||
return (base + base).slice(0, length);
|
||||
};
|
||||
|
||||
export const parseError = (error: unknown): string => {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const err = error as { backendMessage?: unknown; msg?: unknown; message?: unknown };
|
||||
if (typeof err.msg === 'string') return err.msg;
|
||||
if (typeof err.backendMessage === 'string' && err.backendMessage.trim()) return err.backendMessage;
|
||||
if (typeof err.message === 'string') return err.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据语言代码解析为标准化的区域键
|
||||
* Resolve language code to standardized locale key
|
||||
*/
|
||||
export const resolveLocaleKey = (language: string): 'zh-CN' | 'en-US' => {
|
||||
const lang = language.toLowerCase();
|
||||
if (lang.startsWith('zh')) return 'zh-CN';
|
||||
return 'en-US';
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-house bridge: transport-agnostic pub/sub + request/response RPC.
|
||||
*
|
||||
* Replaces the bridge surface of the former third-party platform package.
|
||||
* The wire protocol is preserved byte-for-byte so the Rust backend
|
||||
* (`WebSocketMessage{name,data}`), the mobile client, and the desktop
|
||||
* adapters all keep working unchanged:
|
||||
*
|
||||
* - invoke(name, data):
|
||||
* emits event `"subscribe-" + name` with body `{ id, data }`,
|
||||
* then awaits a one-shot `"subscribe.callback-" + name + id`.
|
||||
* - subscribe(name, handler):
|
||||
* listens on `"subscribe-" + name`, runs the handler, then emits
|
||||
* `"subscribe.callback-" + name + body.id` with the result.
|
||||
* - buildEmitter uses the bare event name (fire-and-forget, no callback).
|
||||
*
|
||||
* The id is concatenated to the name WITHOUT a delimiter — this matches the
|
||||
* original library and the consumers that hand-build the callback name
|
||||
* (mobile bridge.ts, useDirectorySelection.tsx). Do not change it.
|
||||
*/
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
type AnyFn = (...args: any[]) => any;
|
||||
|
||||
interface Adapter {
|
||||
emit(name: string, data: any, ...args: any[]): void;
|
||||
on(emitter: { emit(name: string, ...args: any[]): void }): void;
|
||||
}
|
||||
|
||||
interface InterceptParams {
|
||||
name: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
// Internal event hub. The adapter plugs the real transport into `outbound`
|
||||
// (how events leave this process) and feeds inbound events back via the
|
||||
// emitter handed to `adapter.on`.
|
||||
const hub = new EventEmitter();
|
||||
|
||||
let logOutput: ((...args: any[]) => void) | null = null;
|
||||
const log = (...args: any[]) => {
|
||||
if (logOutput) logOutput(...args);
|
||||
};
|
||||
|
||||
const interceptors: Array<(params: InterceptParams) => Promise<void>> = [];
|
||||
|
||||
// Default outbound: loopback (same-process). The adapter overrides this.
|
||||
let outbound: (name: string, data: any, ...args: any[]) => void = (name, data, ...args) => {
|
||||
hub.emit(name, data, ...args);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a listener. Callback-channel events (`subscribe(.callback)?-`)
|
||||
* bypass interceptors so RPC plumbing is never delayed by app-level guards.
|
||||
*/
|
||||
const on = (name: string, callback: AnyFn): (() => void) => {
|
||||
const wrapped = (...args: any[]) => {
|
||||
if (/^subscribe(\.callback)?-/.test(name) || interceptors.length === 0) {
|
||||
return callback(...args);
|
||||
}
|
||||
return Promise.all(interceptors.map((i) => i({ name, data: args[0] }))).then(() => callback(...args));
|
||||
};
|
||||
hub.on(name, wrapped);
|
||||
return () => hub.off(name, wrapped);
|
||||
};
|
||||
|
||||
const off = (name: string, callback: AnyFn): void => {
|
||||
// Best-effort: matches the original loose contract. Listeners created via
|
||||
// `on()` return their own disposer; this removes direct hub listeners.
|
||||
hub.off(name, callback);
|
||||
};
|
||||
|
||||
const emit = (name: string, data: any, ...args: any[]): void => {
|
||||
log('bridge.emit', name, data);
|
||||
outbound(name, data, ...args);
|
||||
};
|
||||
|
||||
// Monotonic, collision-resistant id. Format is opaque; only uniqueness as a
|
||||
// string matters (consumers concatenate it onto the callback event name).
|
||||
let idCounter = 0;
|
||||
const nextId = (prefix: string): string => `${prefix}_${idCounter++}_${randomSuffix()}`;
|
||||
const randomSuffix = (): string => {
|
||||
// Avoid Math.random/Date in hot equality paths is unnecessary here; this is
|
||||
// a fresh id each call. Use performance-cheap entropy.
|
||||
let s = '';
|
||||
for (let i = 0; i < 6; i++) s += ((Math.random() * 36) | 0).toString(36);
|
||||
return s;
|
||||
};
|
||||
|
||||
const subscribe = <Data = any, Result = any>(name: string, callback: (data: Data) => Promise<Result>): (() => void) => {
|
||||
return on('subscribe-' + name, (body: { id: string; data: Data }, ...rest: any[]) => {
|
||||
Promise.resolve(callback(body.data)).then((result) => {
|
||||
emit('subscribe.callback-' + name + body.id, result, ...rest);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const invoke = <Data = any>(name: string, data?: any): Promise<Data> => {
|
||||
const id = nextId(name);
|
||||
return new Promise<Data>((resolve) => {
|
||||
// Register the one-shot callback listener BEFORE emitting the request, so a
|
||||
// synchronous responder (loopback / same-process provider) cannot deliver
|
||||
// the result before we are listening. With an async transport the order is
|
||||
// immaterial; this is correct in both cases.
|
||||
const dispose = on('subscribe.callback-' + name + id, (result: Data) => {
|
||||
resolve(result);
|
||||
dispose();
|
||||
});
|
||||
emit('subscribe-' + name, { id, data });
|
||||
});
|
||||
};
|
||||
|
||||
const create = <Data = any, Result = any>(key: string) => ({
|
||||
invoke: (data: Data) => invoke<Result>(key, data),
|
||||
subscribe: (handler: (data: Data) => Promise<Result>) => subscribe<Data, Result>(key, handler),
|
||||
});
|
||||
|
||||
const buildProvider = <Data = any, Params = undefined>(key: string) => {
|
||||
const channel = create<Params, Data>(key);
|
||||
return {
|
||||
provider: (provider: Params extends undefined ? () => Promise<Data> : (params: Params) => Promise<Data>) => {
|
||||
channel.subscribe((params: Params) => (provider as (p: Params) => Promise<Data>)(params));
|
||||
},
|
||||
invoke: ((params?: Params) => channel.invoke(params as Params)) as Params extends undefined
|
||||
? () => Promise<Data>
|
||||
: (params: Params) => Promise<Data>,
|
||||
};
|
||||
};
|
||||
|
||||
const buildEmitter = <Params = undefined>(key: string) => ({
|
||||
on: (callback: Params extends undefined ? () => void : (params: Params) => void) => on(key, callback as AnyFn),
|
||||
emit: ((params?: Params) => emit(key, params)) as Params extends undefined ? () => void : (params: Params) => void,
|
||||
});
|
||||
|
||||
const intercept = (callback: (params: InterceptParams) => Promise<void>): (() => void) => {
|
||||
interceptors.push(callback);
|
||||
return () => {
|
||||
const i = interceptors.indexOf(callback);
|
||||
if (i >= 0) interceptors.splice(i, 1);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Install the real transport. `emit` ships an outbound event; `on` is handed
|
||||
* an emitter whose `.emit(name, data)` feeds inbound events into the hub.
|
||||
*/
|
||||
const adapter = (config: Adapter): void => {
|
||||
outbound = (name, data, ...args) => config.emit(name, data, ...args);
|
||||
config.on({
|
||||
emit: (name: string, ...args: any[]) => {
|
||||
hub.emit(name, ...args);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const logger = (output: (...args: any[]) => void): void => {
|
||||
logOutput = output;
|
||||
};
|
||||
|
||||
// Inert lifecycle hooks kept for API compatibility (no consumer relies on
|
||||
// their behavior; they existed on the original surface).
|
||||
const start = (): void => {};
|
||||
const stop = (): void => {};
|
||||
const status = (): boolean => true;
|
||||
const debug = (_flag?: boolean): void => {};
|
||||
|
||||
export {
|
||||
adapter,
|
||||
buildProvider,
|
||||
buildEmitter,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
subscribe,
|
||||
invoke,
|
||||
create,
|
||||
intercept,
|
||||
logger,
|
||||
start,
|
||||
stop,
|
||||
status,
|
||||
debug,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-house replacement for the former third-party platform package.
|
||||
*
|
||||
* Exposes the same import shape the app already uses:
|
||||
* import { bridge, storage, theme, logger } from '@/platform';
|
||||
*
|
||||
* Only the surface actually consumed by this project is implemented (bridge
|
||||
* pub/sub + RPC, namespaced storage, a few design tokens, a logger facade).
|
||||
* The wire protocol is preserved so the Rust backend and mobile client are
|
||||
* unaffected. See ./bridge.ts for the protocol contract.
|
||||
*/
|
||||
export * as bridge from './bridge';
|
||||
export * as storage from './storage';
|
||||
export * as theme from './theme';
|
||||
export * as logger from './logger';
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-house logger facade.
|
||||
*
|
||||
* Replaces the logger surface of the former third-party platform package.
|
||||
* Only `config` and `provider` are used by the app; the rest of the original
|
||||
* surface (log/info/warn/error/performance/logPath) is unused and omitted.
|
||||
*/
|
||||
|
||||
interface LogEntry {
|
||||
type: 'log' | 'info' | 'warn' | 'error';
|
||||
logs: any[];
|
||||
}
|
||||
|
||||
interface LogProvider {
|
||||
log(entry: LogEntry): void;
|
||||
path(): Promise<string>;
|
||||
}
|
||||
|
||||
let printToConsole = false;
|
||||
let sink: LogProvider | null = null;
|
||||
|
||||
const emit = (type: LogEntry['type'], logs: any[]): void => {
|
||||
if (sink) sink.log({ type, logs });
|
||||
if (printToConsole) console[type](...logs);
|
||||
};
|
||||
|
||||
export const config = (extra?: { print?: boolean }): { logPath: Promise<string> } => {
|
||||
printToConsole = !!extra?.print;
|
||||
return { logPath: sink ? sink.path() : Promise.resolve('') };
|
||||
};
|
||||
|
||||
export const provider = (config: LogProvider): (() => void) => {
|
||||
sink = config;
|
||||
return () => {
|
||||
if (sink === config) sink = null;
|
||||
};
|
||||
};
|
||||
|
||||
export const log = (...args: any[]): void => emit('log', args);
|
||||
export const info = (...args: any[]): void => emit('info', args);
|
||||
export const warn = (...args: any[]): void => emit('warn', args);
|
||||
export const error = (...args: any[]): void => emit('error', args);
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-house namespaced key-value storage.
|
||||
*
|
||||
* Replaces the storage surface of the former third-party platform package,
|
||||
* preserving its exact behavior:
|
||||
* - Each group exposes get/set/remove backed by bridge RPC channels
|
||||
* `"<group>.storage.{get,set,clear,remove}"`.
|
||||
* - `interceptor(impl)` registers the providers for those channels (the main
|
||||
* process plugs its file-backed store in here).
|
||||
* - When `debug` is enabled, ops use a `window.localStorage` fallback with
|
||||
* the RAW key (no group prefix) — matching the original library so existing
|
||||
* persisted values remain readable.
|
||||
* - The `set` RPC payload shape is `{ key, data }`; the interceptor unpacks it
|
||||
* to positional `impl.set(key, data)`.
|
||||
*/
|
||||
import { buildProvider } from './bridge';
|
||||
|
||||
interface StorageInterceptor<S> {
|
||||
get?<K extends keyof S>(key: K): Promise<S[K]>;
|
||||
set?<K extends keyof S>(key: K, data: S[K]): Promise<S[K] | void>;
|
||||
clear?(): Promise<unknown>;
|
||||
remove?<K extends keyof S>(key: K): Promise<unknown>;
|
||||
}
|
||||
|
||||
const hasLocalStorage = (): boolean => typeof window !== 'undefined' && !!window.localStorage;
|
||||
|
||||
export const buildStorage = <S extends Record<string, any> = Record<string, any>>(
|
||||
group: string,
|
||||
options?: { debug: boolean }
|
||||
) => {
|
||||
const debug = !!options?.debug;
|
||||
|
||||
const getCh = buildProvider<any, string>(group + '.storage.get');
|
||||
const setCh = buildProvider<any, { key: string; data: any }>(group + '.storage.set');
|
||||
const clearCh = buildProvider<any>(group + '.storage.clear');
|
||||
const removeCh = buildProvider<any, string>(group + '.storage.remove');
|
||||
|
||||
// When debug, prefer the local fallback; otherwise route through the bridge.
|
||||
const route = <T>(viaBridge: () => Promise<T>, viaLocal: () => Promise<T>): Promise<T> =>
|
||||
debug ? viaLocal() : viaBridge();
|
||||
|
||||
return {
|
||||
get<K extends keyof S>(key: K): Promise<S[K]> {
|
||||
return route(
|
||||
() => getCh.invoke(String(key)),
|
||||
async () => {
|
||||
if (!hasLocalStorage()) return undefined as S[K];
|
||||
const raw = window.localStorage.getItem(String(key));
|
||||
if (!raw) return undefined as S[K];
|
||||
try {
|
||||
return JSON.parse(raw) as S[K];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return undefined as S[K];
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
set<K extends keyof S>(key: K, data: S[K]): Promise<any> {
|
||||
return route(
|
||||
() => setCh.invoke({ key: String(key), data }),
|
||||
async () => {
|
||||
if (hasLocalStorage()) window.localStorage.setItem(String(key), JSON.stringify(data));
|
||||
}
|
||||
);
|
||||
},
|
||||
clear(): Promise<any> {
|
||||
return route(
|
||||
() => clearCh.invoke(),
|
||||
async () => {
|
||||
if (hasLocalStorage()) window.localStorage.clear();
|
||||
}
|
||||
);
|
||||
},
|
||||
remove(key: keyof S): Promise<any> {
|
||||
return route(
|
||||
() => removeCh.invoke(String(key)),
|
||||
async () => {
|
||||
if (hasLocalStorage()) window.localStorage.removeItem(String(key));
|
||||
}
|
||||
);
|
||||
},
|
||||
debug(_flag: boolean): void {
|
||||
// Parity stub; routing is fixed at build time via options.debug.
|
||||
},
|
||||
/**
|
||||
* Register the backing store. Mirrors the original contract: `get`/`remove`
|
||||
* receive the raw key; `set` receives `{ key, data }` and is unpacked to
|
||||
* positional args.
|
||||
*/
|
||||
interceptor(impl: StorageInterceptor<S>): void {
|
||||
if (impl.get) getCh.provider((key: string) => impl.get!(key as keyof S));
|
||||
if (impl.set)
|
||||
setCh.provider((payload: { key: string; data: any }) => impl.set!(payload.key as keyof S, payload.data));
|
||||
if (impl.clear) clearCh.provider(() => impl.clear!());
|
||||
if (impl.remove) removeCh.provider((key: string) => impl.remove!(key as keyof S));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Top-level "global" group, mirroring the original convenience exports.
|
||||
const globalStore = buildStorage('global');
|
||||
export const get = globalStore.get;
|
||||
export const set = globalStore.set;
|
||||
export const remove = globalStore.remove;
|
||||
export const interceptor = globalStore.interceptor;
|
||||
export const debug = globalStore.debug;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025-2026 NomiFun (nomifun.com)
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-house design tokens.
|
||||
*
|
||||
* Replaces the theme surface of the former third-party platform package.
|
||||
* Only the tokens actually consumed by the app are kept; values are copied
|
||||
* verbatim from the original library (v0.3.16) so there is zero visual change.
|
||||
*/
|
||||
|
||||
export const Color = {
|
||||
PrimaryColor: '#4a58fa',
|
||||
FunctionalColor: {
|
||||
error: '#f53f3f',
|
||||
warn: '#ff7d00',
|
||||
success: '#00b42a',
|
||||
link: '#165dff',
|
||||
yellow: '#fadc19',
|
||||
cyan: '#13c1b8',
|
||||
purple: '#722ed1',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const Size = {
|
||||
IconSize: {
|
||||
normal: 16,
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="none" fill-rule="evenodd"><path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z"/><path fill="#1677FF" d="M6.802 2.02a1 1 0 0 1 .849.22l9.751 8.359a2 2 0 0 1 .235 2.799l-1.06 1.272l.87.436a1 1 0 0 1 .134 1.708l-7 5a1 1 0 0 1-1.539-1.101l1.21-4.034c-2.363-.9-3.747-3.055-4.233-5.483A1 1 0 0 1 7.01 10c-.474-.703-.86-1.42-1.134-2.149c-.649-1.73-.658-3.523.23-5.298a1 1 0 0 1 .696-.533"/></g></svg>
|
||||
|
After Width: | Height: | Size: 936 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1.29em" height="1em" viewBox="0 0 256 199"><path fill="#5865F2" d="M216.856 16.597A208.5 208.5 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046q-29.538-4.442-58.533 0c-1.832-4.4-4.55-9.933-6.846-14.046a207.8 207.8 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161 161 0 0 0 79.735 175.3a136.4 136.4 0 0 1-21.846-10.632a109 109 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a132 132 0 0 0 5.355 4.237a136 136 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848c21.142-6.58 42.646-16.637 64.815-33.213c5.316-56.288-9.08-105.09-38.056-148.36M85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2s23.236 11.804 23.015 26.2c.02 14.375-10.148 26.18-23.015 26.18m85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2c0 14.375-10.148 26.18-23.015 26.18"/></svg>
|
||||
|
After Width: | Height: | Size: 945 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user