Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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=falsepinned_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',
}
+834
View File
@@ -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;
+9
View File
@@ -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
View File
@@ -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;
};
+12
View File
@@ -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
View File
@@ -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';
};