feat: prepare Zhinian desktop pilot
This commit is contained in:
@@ -22,6 +22,8 @@ import {
|
||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||
const WECOM_PLUGIN_ID = 'wecom';
|
||||
const DISABLED_PLUGIN_CHANNEL_TYPES = new Set(['dingtalk', 'wecom', 'feishu']);
|
||||
const DISABLED_PLUGIN_IDS = ['dingtalk', 'wecom', 'feishu', 'openclaw-lark', 'feishu-openclaw-plugin'];
|
||||
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin ID needed.
|
||||
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
@@ -417,6 +419,13 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
|
||||
// ── Channel operations ───────────────────────────────────────────
|
||||
|
||||
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
|
||||
if (DISABLED_PLUGIN_CHANNEL_TYPES.has(channelType)) {
|
||||
for (const pluginId of DISABLED_PLUGIN_IDS) {
|
||||
removePluginRegistration(currentConfig, pluginId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||
ensurePluginRegistration(currentConfig, channelType);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
export const PORTS = {
|
||||
/** ClawX GUI development server port */
|
||||
CLAWX_DEV: 5173,
|
||||
CLAWX_DEV: 5188,
|
||||
|
||||
/** ClawX GUI production port (for reference) */
|
||||
CLAWX_GUI: 23333,
|
||||
|
||||
492
electron/utils/model-diagnostics.ts
Normal file
492
electron/utils/model-diagnostics.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { getOpenClawConfigDir } from './paths';
|
||||
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { logger } from './logger';
|
||||
|
||||
const YINIAN_MODEL_TIMEOUT_SECONDS = 300;
|
||||
const YINIAN_MODEL_PROVIDER_KEY = 'minimax';
|
||||
const YINIAN_MODEL_ID = 'MiniMax-M2.7';
|
||||
const YINIAN_MODEL_REF = `${YINIAN_MODEL_PROVIDER_KEY}/${YINIAN_MODEL_ID}`;
|
||||
const YINIAN_INTERNAL_PROVIDER_KEYS = ['minimax', 'minimax-portal'];
|
||||
const YINIAN_MODEL_AUTH_ALIAS_PROFILE_IDS = [
|
||||
'minimax:cn',
|
||||
'minimax-cn:default',
|
||||
'minimax-portal-cn:default',
|
||||
'minimax-portal:default',
|
||||
];
|
||||
const YINIAN_MODEL_AUTH_TARGET_PROFILE_ID = 'minimax:default';
|
||||
const YINIAN_MODEL_AUTH_TARGET_PROVIDER = 'minimax';
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type AuthProfileEntry = {
|
||||
type?: unknown;
|
||||
provider?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type AuthProfilesStore = {
|
||||
version?: unknown;
|
||||
profiles?: Record<string, AuthProfileEntry>;
|
||||
order?: Record<string, string[]>;
|
||||
lastGood?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ModelDiagnosticStatus = 'ok' | 'warning' | 'error';
|
||||
|
||||
export interface YinianModelConfigCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
status: ModelDiagnosticStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface YinianModelConfigDiagnostics {
|
||||
capturedAt: number;
|
||||
ok: boolean;
|
||||
model: {
|
||||
primary: string | null;
|
||||
fallbacks: string[];
|
||||
providerKey: string | null;
|
||||
modelId: string | null;
|
||||
};
|
||||
runtime: {
|
||||
pricingEnabled: boolean | null;
|
||||
pricingCatalogFetchDisabled: boolean;
|
||||
};
|
||||
providers: Array<{
|
||||
key: string;
|
||||
configured: boolean;
|
||||
baseUrl: string | null;
|
||||
api: string | null;
|
||||
timeoutSeconds: number | null;
|
||||
authHeader: boolean | null;
|
||||
modelCount: number;
|
||||
}>;
|
||||
authProfiles: {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
providers: Array<{
|
||||
provider: string;
|
||||
profileCount: number;
|
||||
profileIds: string[];
|
||||
types: string[];
|
||||
hasDefaultProfile: boolean;
|
||||
lastGoodProfileId: string | null;
|
||||
}>;
|
||||
};
|
||||
checks: YinianModelConfigCheck[];
|
||||
paths: {
|
||||
openclawConfig: string;
|
||||
authProfiles: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getAuthProfilesPath(agentId = 'main'): string {
|
||||
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function readAuthProfilesStore(filePath: string): Promise<AuthProfilesStore> {
|
||||
return readJsonFile<AuthProfilesStore>(filePath).then((store) => {
|
||||
if (store && isPlainRecord(store.profiles)) return store;
|
||||
return { version: 1, profiles: {} };
|
||||
});
|
||||
}
|
||||
|
||||
function getPrimaryModel(config: JsonObject): string | null {
|
||||
const agents = isPlainRecord(config.agents) ? config.agents : {};
|
||||
const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
|
||||
const model = isPlainRecord(defaults.model) ? defaults.model : {};
|
||||
return typeof model.primary === 'string' && model.primary.trim() ? model.primary : null;
|
||||
}
|
||||
|
||||
function getFallbackModels(config: JsonObject): string[] {
|
||||
const agents = isPlainRecord(config.agents) ? config.agents : {};
|
||||
const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
|
||||
const model = isPlainRecord(defaults.model) ? defaults.model : {};
|
||||
return Array.isArray(model.fallbacks)
|
||||
? model.fallbacks.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function splitModelRef(modelRef: string | null): { providerKey: string | null; modelId: string | null } {
|
||||
if (!modelRef) return { providerKey: null, modelId: null };
|
||||
const slashIndex = modelRef.indexOf('/');
|
||||
if (slashIndex <= 0 || slashIndex >= modelRef.length - 1) {
|
||||
return { providerKey: null, modelId: modelRef };
|
||||
}
|
||||
return {
|
||||
providerKey: modelRef.slice(0, slashIndex),
|
||||
modelId: modelRef.slice(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function groupAuthProfiles(store: AuthProfilesStore): YinianModelConfigDiagnostics['authProfiles']['providers'] {
|
||||
const profiles = isPlainRecord(store.profiles) ? store.profiles : {};
|
||||
const groups = new Map<string, { ids: string[]; types: Set<string> }>();
|
||||
|
||||
for (const [profileId, profile] of Object.entries(profiles)) {
|
||||
if (!isPlainRecord(profile)) continue;
|
||||
const provider = typeof profile.provider === 'string' && profile.provider.trim()
|
||||
? profile.provider
|
||||
: profileId.split(':')[0] || 'unknown';
|
||||
const type = typeof profile.type === 'string' && profile.type.trim() ? profile.type : 'unknown';
|
||||
const group = groups.get(provider) ?? { ids: [], types: new Set<string>() };
|
||||
group.ids.push(profileId);
|
||||
group.types.add(type);
|
||||
groups.set(provider, group);
|
||||
}
|
||||
|
||||
const lastGood = isPlainRecord(store.lastGood) ? store.lastGood as Record<string, unknown> : {};
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, group]) => ({
|
||||
provider,
|
||||
profileCount: group.ids.length,
|
||||
profileIds: group.ids.sort(),
|
||||
types: Array.from(group.types).sort(),
|
||||
hasDefaultProfile: group.ids.includes(`${provider}:default`),
|
||||
lastGoodProfileId: typeof lastGood[provider] === 'string' ? lastGood[provider] as string : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function hasMatchingAuthProfile(store: AuthProfilesStore, providerKey: string | null): boolean {
|
||||
if (!providerKey || !isPlainRecord(store.profiles)) return false;
|
||||
return Object.values(store.profiles).some((profile) => (
|
||||
isPlainRecord(profile) && profile.provider === providerKey
|
||||
));
|
||||
}
|
||||
|
||||
function hasConfiguredProvider(config: JsonObject, providerKey: string | null): boolean {
|
||||
if (!providerKey) return false;
|
||||
const models = isPlainRecord(config.models) ? config.models : {};
|
||||
const providers = isPlainRecord(models.providers) ? models.providers : {};
|
||||
return isPlainRecord(providers[providerKey]);
|
||||
}
|
||||
|
||||
function buildProviderDiagnostics(config: JsonObject, primaryProviderKey: string | null): YinianModelConfigDiagnostics['providers'] {
|
||||
const models = isPlainRecord(config.models) ? config.models : {};
|
||||
const providers = isPlainRecord(models.providers) ? models.providers : {};
|
||||
const providerKeys = Array.from(new Set([
|
||||
...(primaryProviderKey ? [primaryProviderKey] : []),
|
||||
...YINIAN_INTERNAL_PROVIDER_KEYS,
|
||||
]));
|
||||
|
||||
return providerKeys.map((key) => {
|
||||
const entry = isPlainRecord(providers[key]) ? providers[key] : {};
|
||||
const modelsList = Array.isArray(entry.models) ? entry.models : [];
|
||||
return {
|
||||
key,
|
||||
configured: isPlainRecord(providers[key]),
|
||||
baseUrl: typeof entry.baseUrl === 'string' ? entry.baseUrl : null,
|
||||
api: typeof entry.api === 'string' ? entry.api : null,
|
||||
timeoutSeconds: typeof entry.timeoutSeconds === 'number' ? entry.timeoutSeconds : null,
|
||||
authHeader: typeof entry.authHeader === 'boolean' ? entry.authHeader : null,
|
||||
modelCount: modelsList.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getPricingEnabled(config: JsonObject): boolean | null {
|
||||
const models = isPlainRecord(config.models) ? config.models : {};
|
||||
const pricing = isPlainRecord(models.pricing) ? models.pricing : null;
|
||||
return typeof pricing?.enabled === 'boolean' ? pricing.enabled : null;
|
||||
}
|
||||
|
||||
export async function ensureYinianModelRuntimeConfigured(): Promise<void> {
|
||||
const config = await readOpenClawConfig() as JsonObject;
|
||||
const models = isPlainRecord(config.models) ? { ...config.models } : {};
|
||||
const providers = isPlainRecord(models.providers) ? { ...models.providers } : {};
|
||||
const pricing = isPlainRecord(models.pricing) ? { ...models.pricing } : {};
|
||||
let changed = false;
|
||||
|
||||
const currentYinianProvider = isPlainRecord(providers[YINIAN_MODEL_PROVIDER_KEY])
|
||||
? { ...providers[YINIAN_MODEL_PROVIDER_KEY] }
|
||||
: {};
|
||||
const currentModels = Array.isArray(currentYinianProvider.models)
|
||||
? currentYinianProvider.models.filter(isPlainRecord)
|
||||
: [];
|
||||
const hasYinianModel = currentModels.some((item) => item.id === YINIAN_MODEL_ID);
|
||||
const nextYinianProvider = {
|
||||
...currentYinianProvider,
|
||||
baseUrl: typeof currentYinianProvider.baseUrl === 'string' && currentYinianProvider.baseUrl.trim()
|
||||
? currentYinianProvider.baseUrl
|
||||
: 'https://api.minimaxi.com/anthropic',
|
||||
api: typeof currentYinianProvider.api === 'string' && currentYinianProvider.api.trim()
|
||||
? currentYinianProvider.api
|
||||
: 'anthropic-messages',
|
||||
authHeader: typeof currentYinianProvider.authHeader === 'boolean'
|
||||
? currentYinianProvider.authHeader
|
||||
: true,
|
||||
timeoutSeconds: YINIAN_MODEL_TIMEOUT_SECONDS,
|
||||
models: hasYinianModel
|
||||
? currentModels
|
||||
: [
|
||||
...currentModels,
|
||||
{
|
||||
id: YINIAN_MODEL_ID,
|
||||
name: 'MiniMax M2.7',
|
||||
reasoning: true,
|
||||
input: ['text', 'image'],
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (JSON.stringify(providers[YINIAN_MODEL_PROVIDER_KEY]) !== JSON.stringify(nextYinianProvider)) {
|
||||
providers[YINIAN_MODEL_PROVIDER_KEY] = nextYinianProvider;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (const providerKey of YINIAN_INTERNAL_PROVIDER_KEYS) {
|
||||
const currentProvider = providers[providerKey];
|
||||
if (!isPlainRecord(currentProvider)) continue;
|
||||
|
||||
if (currentProvider.timeoutSeconds !== YINIAN_MODEL_TIMEOUT_SECONDS) {
|
||||
providers[providerKey] = {
|
||||
...currentProvider,
|
||||
timeoutSeconds: YINIAN_MODEL_TIMEOUT_SECONDS,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (pricing.enabled !== false) {
|
||||
pricing.enabled = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (models.mode !== 'merge') {
|
||||
models.mode = 'merge';
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const agents = isPlainRecord(config.agents) ? { ...config.agents } : {};
|
||||
const defaults = isPlainRecord(agents.defaults) ? { ...agents.defaults } : {};
|
||||
const defaultModel = isPlainRecord(defaults.model) ? { ...defaults.model } : {};
|
||||
if (defaultModel.primary !== YINIAN_MODEL_REF) {
|
||||
defaultModel.primary = YINIAN_MODEL_REF;
|
||||
changed = true;
|
||||
}
|
||||
if (!Array.isArray(defaultModel.fallbacks)) {
|
||||
defaultModel.fallbacks = ['minimax/MiniMax-M2.5'];
|
||||
changed = true;
|
||||
}
|
||||
defaults.model = defaultModel;
|
||||
if (!Array.isArray(defaults.skills)) {
|
||||
defaults.skills = [];
|
||||
changed = true;
|
||||
}
|
||||
const heartbeat = isPlainRecord(defaults.heartbeat) ? { ...defaults.heartbeat } : {};
|
||||
if (heartbeat.every !== '0m') {
|
||||
heartbeat.every = '0m';
|
||||
defaults.heartbeat = heartbeat;
|
||||
changed = true;
|
||||
}
|
||||
defaults.workspace = typeof defaults.workspace === 'string' && defaults.workspace.trim()
|
||||
? defaults.workspace
|
||||
: join(homedir(), '.openclaw', 'workspace');
|
||||
agents.defaults = defaults;
|
||||
|
||||
const list = Array.isArray(agents.list) ? agents.list : [];
|
||||
const normalizedList = list.map((item) => {
|
||||
if (!isPlainRecord(item) || item.id !== 'main') return item;
|
||||
const tools = isPlainRecord(item.tools) ? item.tools : {};
|
||||
return {
|
||||
...item,
|
||||
tools: {
|
||||
...tools,
|
||||
profile: 'coding',
|
||||
},
|
||||
};
|
||||
});
|
||||
if (JSON.stringify(normalizedList) !== JSON.stringify(list)) {
|
||||
agents.list = normalizedList;
|
||||
changed = true;
|
||||
}
|
||||
const activeList = Array.isArray(agents.list) ? agents.list : list;
|
||||
const hasMainAgent = activeList.some((item) => isPlainRecord(item) && item.id === 'main');
|
||||
if (!hasMainAgent) {
|
||||
agents.list = [
|
||||
...activeList,
|
||||
{
|
||||
id: 'main',
|
||||
name: '智念助手',
|
||||
default: true,
|
||||
workspace: join(homedir(), '.openclaw', 'workspace'),
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
skills: [],
|
||||
tools: { profile: 'coding' },
|
||||
},
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
if (JSON.stringify(config.agents) !== JSON.stringify(agents)) {
|
||||
config.agents = agents;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
models.providers = providers;
|
||||
models.pricing = pricing;
|
||||
config.models = models;
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('[provider-sync] Applied Yinian model runtime defaults');
|
||||
}
|
||||
|
||||
await ensureYinianModelAuthProfileAliases();
|
||||
}
|
||||
|
||||
export async function ensureYinianModelAuthProfileAliases(agentId = 'main'): Promise<void> {
|
||||
const authPath = getAuthProfilesPath(agentId);
|
||||
const store = await readAuthProfilesStore(authPath);
|
||||
store.version = typeof store.version === 'number' ? store.version : 1;
|
||||
store.profiles = isPlainRecord(store.profiles) ? store.profiles : {};
|
||||
|
||||
const target = store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID];
|
||||
const sourceId = YINIAN_MODEL_AUTH_ALIAS_PROFILE_IDS.find((profileId) => isPlainRecord(store.profiles?.[profileId]));
|
||||
const source = sourceId ? store.profiles[sourceId] : null;
|
||||
let changed = false;
|
||||
|
||||
if (!isPlainRecord(target) && isPlainRecord(source)) {
|
||||
store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID] = {
|
||||
...source,
|
||||
provider: YINIAN_MODEL_AUTH_TARGET_PROVIDER,
|
||||
};
|
||||
changed = true;
|
||||
} else if (isPlainRecord(target) && target.provider !== YINIAN_MODEL_AUTH_TARGET_PROVIDER) {
|
||||
store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID] = {
|
||||
...target,
|
||||
provider: YINIAN_MODEL_AUTH_TARGET_PROVIDER,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (isPlainRecord(store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID])) {
|
||||
const order = isPlainRecord(store.order) ? { ...store.order } as Record<string, string[]> : {};
|
||||
const currentOrder = Array.isArray(order[YINIAN_MODEL_AUTH_TARGET_PROVIDER])
|
||||
? order[YINIAN_MODEL_AUTH_TARGET_PROVIDER]
|
||||
: [];
|
||||
if (!currentOrder.includes(YINIAN_MODEL_AUTH_TARGET_PROFILE_ID)) {
|
||||
order[YINIAN_MODEL_AUTH_TARGET_PROVIDER] = [
|
||||
YINIAN_MODEL_AUTH_TARGET_PROFILE_ID,
|
||||
...currentOrder.filter((item) => item !== YINIAN_MODEL_AUTH_TARGET_PROFILE_ID),
|
||||
];
|
||||
store.order = order;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const lastGood = isPlainRecord(store.lastGood) ? { ...store.lastGood } as Record<string, string> : {};
|
||||
if (!lastGood[YINIAN_MODEL_AUTH_TARGET_PROVIDER]) {
|
||||
lastGood[YINIAN_MODEL_AUTH_TARGET_PROVIDER] = YINIAN_MODEL_AUTH_TARGET_PROFILE_ID;
|
||||
store.lastGood = lastGood;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeJsonFile(authPath, store);
|
||||
logger.info('[provider-sync] Normalized Yinian model auth profile aliases');
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildYinianModelConfigDiagnostics(): Promise<YinianModelConfigDiagnostics> {
|
||||
const openclawConfigPath = join(getOpenClawConfigDir(), 'openclaw.json');
|
||||
const authProfilesPath = getAuthProfilesPath('main');
|
||||
const config = await readOpenClawConfig() as JsonObject;
|
||||
const authStore = await readAuthProfilesStore(authProfilesPath);
|
||||
const primary = getPrimaryModel(config);
|
||||
const fallbacks = getFallbackModels(config);
|
||||
const { providerKey, modelId } = splitModelRef(primary);
|
||||
const pricingEnabled = getPricingEnabled(config);
|
||||
const providerConfigured = hasConfiguredProvider(config, providerKey);
|
||||
const authProfileConfigured = hasMatchingAuthProfile(authStore, providerKey);
|
||||
const providers = buildProviderDiagnostics(config, providerKey);
|
||||
const primaryProvider = providers.find((provider) => provider.key === providerKey);
|
||||
const checks: YinianModelConfigCheck[] = [
|
||||
{
|
||||
id: 'primary-model',
|
||||
label: '默认模型',
|
||||
status: primary ? 'ok' : 'error',
|
||||
detail: primary ?? '未配置默认模型',
|
||||
},
|
||||
{
|
||||
id: 'provider-entry',
|
||||
label: '模型服务',
|
||||
status: providerConfigured ? 'ok' : 'error',
|
||||
detail: providerKey
|
||||
? (providerConfigured ? `已配置 ${providerKey}` : `缺少 ${providerKey} 服务配置`)
|
||||
: '无法从默认模型识别服务',
|
||||
},
|
||||
{
|
||||
id: 'auth-profile',
|
||||
label: '调用凭据',
|
||||
status: authProfileConfigured ? 'ok' : 'error',
|
||||
detail: providerKey
|
||||
? (authProfileConfigured ? `已找到 ${providerKey} 的本地凭据` : `未找到 ${providerKey} 的本地凭据`)
|
||||
: '无法检查调用凭据',
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
label: '长任务等待',
|
||||
status: (primaryProvider?.timeoutSeconds ?? 0) >= YINIAN_MODEL_TIMEOUT_SECONDS ? 'ok' : 'warning',
|
||||
detail: primaryProvider?.timeoutSeconds
|
||||
? `${primaryProvider.timeoutSeconds} 秒`
|
||||
: '未显式配置等待时长',
|
||||
},
|
||||
{
|
||||
id: 'pricing-catalog',
|
||||
label: '外部价格目录',
|
||||
status: pricingEnabled === false ? 'ok' : 'warning',
|
||||
detail: pricingEnabled === false ? '已关闭启动时外部目录拉取' : '未关闭,弱网环境可能拖慢后台启动',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
capturedAt: Date.now(),
|
||||
ok: checks.every((check) => check.status !== 'error'),
|
||||
model: {
|
||||
primary,
|
||||
fallbacks,
|
||||
providerKey,
|
||||
modelId,
|
||||
},
|
||||
runtime: {
|
||||
pricingEnabled,
|
||||
pricingCatalogFetchDisabled: pricingEnabled === false,
|
||||
},
|
||||
providers,
|
||||
authProfiles: {
|
||||
path: authProfilesPath,
|
||||
exists: existsSync(authProfilesPath),
|
||||
providers: groupAuthProfiles(authStore),
|
||||
},
|
||||
checks,
|
||||
paths: {
|
||||
openclawConfig: openclawConfigPath,
|
||||
authProfiles: authProfilesPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
431
electron/utils/nianxx-play-service.ts
Normal file
431
electron/utils/nianxx-play-service.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { app } from 'electron';
|
||||
import { createServer } from 'node:net';
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { logger } from './logger';
|
||||
|
||||
const DEFAULT_NIANXX_PLAY_PORT = 3000;
|
||||
const STARTUP_TIMEOUT_MS = 20_000;
|
||||
const STARTUP_POLL_INTERVAL_MS = 500;
|
||||
const HEALTH_PATH = '/api/desktop/health';
|
||||
const RUNTIME_ENV_FILE_NAME = '.env.runtime';
|
||||
|
||||
type ProcessWithResourcesPath = NodeJS.Process & { resourcesPath?: string };
|
||||
type NianxxPlayRuntimeKind = 'source' | 'standalone';
|
||||
|
||||
interface NianxxPlayRuntime {
|
||||
kind: NianxxPlayRuntimeKind;
|
||||
dir: string;
|
||||
serverPath?: string;
|
||||
}
|
||||
|
||||
interface NianxxPlayHealthPayload {
|
||||
appId?: unknown;
|
||||
ok?: unknown;
|
||||
desktopManaged?: unknown;
|
||||
}
|
||||
|
||||
export interface NianxxPlayServiceStatus {
|
||||
success: boolean;
|
||||
running: boolean;
|
||||
starting: boolean;
|
||||
managed: boolean;
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
projectDir?: string;
|
||||
runtimeKind?: NianxxPlayRuntimeKind;
|
||||
pid?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let nianxxPlayProcess: ChildProcessWithoutNullStreams | null = null;
|
||||
let lastServiceError: string | null = null;
|
||||
let activePort: number | null = null;
|
||||
|
||||
function getConfiguredPort(): number {
|
||||
const raw = process.env.NIANXX_PLAY_PORT?.trim();
|
||||
if (!raw) return DEFAULT_NIANXX_PLAY_PORT;
|
||||
const parsed = Number(raw);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_NIANXX_PLAY_PORT;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const explicitUrl = process.env.NIANXX_PLAY_URL?.trim();
|
||||
if (explicitUrl) return explicitUrl.replace(/\/$/, '');
|
||||
return `http://127.0.0.1:${activePort ?? getConfiguredPort()}`;
|
||||
}
|
||||
|
||||
function allowExternalNianxxPlayRuntime(): boolean {
|
||||
return Boolean(process.env.NIANXX_PLAY_URL?.trim());
|
||||
}
|
||||
|
||||
function getNpmCommand(): string {
|
||||
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
}
|
||||
|
||||
function getScriptName(): string {
|
||||
return process.env.NIANXX_PLAY_SCRIPT?.trim() || (process.env.NODE_ENV === 'production' ? 'start' : 'dev');
|
||||
}
|
||||
|
||||
function getResourcePathCandidates(): string[] {
|
||||
const resourcesPath = (process as ProcessWithResourcesPath).resourcesPath;
|
||||
return [
|
||||
process.env.NIANXX_PLAY_DIR?.trim() || '',
|
||||
join(process.cwd(), '..', 'NianxxPlay'),
|
||||
join(process.cwd(), 'NianxxPlay'),
|
||||
join(process.cwd(), 'build', 'apps', 'nianxx-play'),
|
||||
resourcesPath ? join(resourcesPath, 'nianxx-play') : '',
|
||||
resourcesPath ? join(resourcesPath, 'resources', 'nianxx-play') : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function createRuntimeCandidate(candidate: string): NianxxPlayRuntime | undefined {
|
||||
const dir = resolve(candidate);
|
||||
const directStandaloneServer = join(dir, 'server.js');
|
||||
const nestedStandaloneServer = join(dir, 'standalone', 'server.js');
|
||||
if (existsSync(directStandaloneServer)) {
|
||||
return { kind: 'standalone', dir, serverPath: directStandaloneServer };
|
||||
}
|
||||
if (existsSync(nestedStandaloneServer)) {
|
||||
return { kind: 'standalone', dir: join(dir, 'standalone'), serverPath: nestedStandaloneServer };
|
||||
}
|
||||
if (existsSync(join(dir, 'package.json'))) {
|
||||
return { kind: 'source', dir };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveNianxxPlayRuntime(): NianxxPlayRuntime | undefined {
|
||||
for (const candidate of getResourcePathCandidates()) {
|
||||
const runtime = createRuntimeCandidate(candidate);
|
||||
if (runtime) return runtime;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function canReachNianxxPlay(baseUrl = getBaseUrl()): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1_500);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${HEALTH_PATH}`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const payload = (await response.json().catch(() => undefined)) as NianxxPlayHealthPayload | undefined;
|
||||
const isNianxxPlay = Boolean(payload && payload.appId === 'nianxx-play' && payload.ok);
|
||||
if (!isNianxxPlay) return false;
|
||||
return payload?.desktopManaged === true || allowExternalNianxxPlayRuntime();
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function createStatus(overrides: Partial<NianxxPlayServiceStatus> = {}): NianxxPlayServiceStatus {
|
||||
const runtime = resolveNianxxPlayRuntime();
|
||||
const baseUrl = getBaseUrl();
|
||||
return {
|
||||
success: true,
|
||||
running: false,
|
||||
starting: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
|
||||
managed: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
|
||||
baseUrl,
|
||||
port: activePort ?? getConfiguredPort(),
|
||||
projectDir: runtime?.dir,
|
||||
runtimeKind: runtime?.kind,
|
||||
pid: nianxxPlayProcess?.pid,
|
||||
error: lastServiceError ?? undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function attachProcessLogger(stream: NodeJS.ReadableStream, level: 'info' | 'warn'): void {
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk) => {
|
||||
buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (level === 'warn') {
|
||||
logger.warn(`[nianxx-play] ${trimmed}`);
|
||||
} else {
|
||||
logger.info(`[nianxx-play] ${trimmed}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntilReachable(baseUrl: string): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < STARTUP_TIMEOUT_MS) {
|
||||
if (await canReachNianxxPlay(baseUrl)) {
|
||||
return true;
|
||||
}
|
||||
await delay(STARTUP_POLL_INTERVAL_MS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolveAvailable) => {
|
||||
const server = createServer();
|
||||
server.once('error', () => resolveAvailable(false));
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolveAvailable(true));
|
||||
});
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(preferredPort: number): Promise<number> {
|
||||
if (await isPortAvailable(preferredPort)) return preferredPort;
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : preferredPort;
|
||||
server.close(() => resolvePort(port));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
function getRuntimeDataDirs() {
|
||||
const userData = app.getPath('userData');
|
||||
const runtimeRoot = join(userData, 'apps', 'nianxx-play');
|
||||
const dataDir = join(runtimeRoot, 'data');
|
||||
const uploadDir = join(runtimeRoot, 'uploads');
|
||||
const resultDir = join(runtimeRoot, 'generated-results');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
mkdirSync(uploadDir, { recursive: true });
|
||||
mkdirSync(resultDir, { recursive: true });
|
||||
return { runtimeRoot, dataDir, uploadDir, resultDir };
|
||||
}
|
||||
|
||||
function hasDirectoryEntries(dir: string): boolean {
|
||||
try {
|
||||
return readdirSync(dir).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyFileIfMissing(sourcePath: string, targetPath: string): void {
|
||||
if (!existsSync(sourcePath) || existsSync(targetPath)) return;
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
cpSync(sourcePath, targetPath, { dereference: true });
|
||||
}
|
||||
|
||||
function readJsonFile<T>(filePath: string): T | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getArrayLength(record: Record<string, unknown> | null, key: string): number {
|
||||
const value = record?.[key];
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
}
|
||||
|
||||
function migrateStateFile(sourcePath: string, targetPath: string): void {
|
||||
if (!existsSync(sourcePath)) return;
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
if (!existsSync(targetPath)) {
|
||||
cpSync(sourcePath, targetPath, { dereference: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceState = readJsonFile<Record<string, unknown>>(sourcePath);
|
||||
const targetState = readJsonFile<Record<string, unknown>>(targetPath);
|
||||
if (!sourceState || !targetState) return;
|
||||
|
||||
const targetProjects = getArrayLength(targetState, 'projects');
|
||||
const sourceProjects = getArrayLength(sourceState, 'projects');
|
||||
if (targetProjects === 0 && sourceProjects > 0) {
|
||||
writeFileSync(targetPath, JSON.stringify(sourceState, null, 2), 'utf8');
|
||||
logger.info(`[nianxx-play] Migrated ${sourceProjects} project record(s) from bundled/source runtime data`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirectoryIfEmpty(sourceDir: string, targetDir: string): void {
|
||||
if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) return;
|
||||
if (hasDirectoryEntries(targetDir)) return;
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
}
|
||||
|
||||
function migrateExistingRuntimeData(runtime: NianxxPlayRuntime, dirs: ReturnType<typeof getRuntimeDataDirs>): void {
|
||||
try {
|
||||
migrateStateFile(join(runtime.dir, '.data', 'app-state.json'), join(dirs.dataDir, 'app-state.json'));
|
||||
copyDirectoryIfEmpty(join(runtime.dir, 'public', 'uploads'), dirs.uploadDir);
|
||||
copyDirectoryIfEmpty(join(runtime.dir, 'public', 'generated-results'), dirs.resultDir);
|
||||
} catch (error) {
|
||||
logger.warn('[nianxx-play] Failed to migrate existing local runtime data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRuntimeEnvValue(raw: string): string {
|
||||
const value = raw.trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadBundledRuntimeEnv(runtime: NianxxPlayRuntime): Record<string, string> {
|
||||
const runtimeEnvPath = join(runtime.dir, RUNTIME_ENV_FILE_NAME);
|
||||
if (!existsSync(runtimeEnvPath)) return {};
|
||||
try {
|
||||
const values: Record<string, string> = {};
|
||||
const raw = readFileSync(runtimeEnvPath, 'utf8');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
values[match[1]] = parseRuntimeEnvValue(match[2]);
|
||||
}
|
||||
logger.info(`[nianxx-play] Loaded bundled runtime env (${Object.keys(values).length} values)`);
|
||||
return values;
|
||||
} catch (error) {
|
||||
logger.warn('[nianxx-play] Failed to load bundled runtime env:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeEnv(port: number, runtime: NianxxPlayRuntime) {
|
||||
const dirs = getRuntimeDataDirs();
|
||||
migrateExistingRuntimeData(runtime, dirs);
|
||||
const bundledRuntimeEnv = loadBundledRuntimeEnv(runtime);
|
||||
return {
|
||||
...process.env,
|
||||
...bundledRuntimeEnv,
|
||||
PORT: String(port),
|
||||
HOSTNAME: '127.0.0.1',
|
||||
NEXT_TELEMETRY_DISABLED: '1',
|
||||
NIANXXPLAY_RUNTIME_DIR: dirs.runtimeRoot,
|
||||
NIANXXPLAY_DATA_DIR: dirs.dataDir,
|
||||
NIANXXPLAY_UPLOAD_DIR: dirs.uploadDir,
|
||||
NIANXXPLAY_RESULT_DIR: dirs.resultDir,
|
||||
NIANXXPLAY_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
NIANXXPLAY_DESKTOP_MANAGED: '1',
|
||||
};
|
||||
}
|
||||
|
||||
function spawnSourceRuntime(runtime: NianxxPlayRuntime, port: number): ChildProcessWithoutNullStreams {
|
||||
const scriptName = getScriptName();
|
||||
logger.info(`[nianxx-play] Starting source service: npm run ${scriptName} (cwd=${runtime.dir}, port=${port})`);
|
||||
return spawn(getNpmCommand(), ['run', scriptName], {
|
||||
cwd: runtime.dir,
|
||||
env: createRuntimeEnv(port, runtime),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnStandaloneRuntime(runtime: NianxxPlayRuntime, port: number): ChildProcessWithoutNullStreams {
|
||||
if (!runtime.serverPath) {
|
||||
throw new Error('NianxxPlay standalone server path is missing.');
|
||||
}
|
||||
logger.info(`[nianxx-play] Starting bundled service: ${runtime.serverPath} (port=${port})`);
|
||||
return spawn(process.execPath, [runtime.serverPath], {
|
||||
cwd: runtime.dir,
|
||||
env: {
|
||||
...createRuntimeEnv(port, runtime),
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
});
|
||||
}
|
||||
|
||||
function attachLifecycleHandlers(): void {
|
||||
if (!nianxxPlayProcess) return;
|
||||
attachProcessLogger(nianxxPlayProcess.stdout, 'info');
|
||||
attachProcessLogger(nianxxPlayProcess.stderr, 'warn');
|
||||
nianxxPlayProcess.once('exit', (code, signal) => {
|
||||
const reason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
|
||||
logger.warn(`[nianxx-play] Service exited with ${reason}`);
|
||||
if (code && code !== 0) {
|
||||
lastServiceError = `NianxxPlay exited with ${reason}`;
|
||||
}
|
||||
nianxxPlayProcess = null;
|
||||
activePort = null;
|
||||
});
|
||||
nianxxPlayProcess.once('error', (error) => {
|
||||
lastServiceError = error.message;
|
||||
logger.warn('[nianxx-play] Failed to start service:', error);
|
||||
nianxxPlayProcess = null;
|
||||
activePort = null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getNianxxPlayServiceStatus(): Promise<NianxxPlayServiceStatus> {
|
||||
const running = await canReachNianxxPlay();
|
||||
return createStatus({
|
||||
running,
|
||||
error: running ? undefined : (lastServiceError ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureNianxxPlayServiceStarted(): Promise<NianxxPlayServiceStatus> {
|
||||
const baseUrl = getBaseUrl();
|
||||
if (await canReachNianxxPlay(baseUrl)) {
|
||||
lastServiceError = null;
|
||||
return createStatus({ running: true, starting: false, managed: Boolean(nianxxPlayProcess), error: undefined });
|
||||
}
|
||||
|
||||
const runtime = resolveNianxxPlayRuntime();
|
||||
if (!runtime) {
|
||||
lastServiceError = 'NianxxPlay runtime was not found.';
|
||||
return createStatus({ success: false, running: false, starting: false, error: lastServiceError });
|
||||
}
|
||||
|
||||
if (!nianxxPlayProcess || nianxxPlayProcess.killed) {
|
||||
const port = await findAvailablePort(getConfiguredPort());
|
||||
activePort = port;
|
||||
nianxxPlayProcess = runtime.kind === 'standalone'
|
||||
? spawnStandaloneRuntime(runtime, port)
|
||||
: spawnSourceRuntime(runtime, port);
|
||||
attachLifecycleHandlers();
|
||||
}
|
||||
|
||||
const running = await waitUntilReachable(getBaseUrl());
|
||||
if (!running) {
|
||||
lastServiceError = `NianxxPlay did not become ready within ${Math.round(STARTUP_TIMEOUT_MS / 1000)}s.`;
|
||||
} else {
|
||||
lastServiceError = null;
|
||||
}
|
||||
|
||||
return createStatus({
|
||||
running,
|
||||
starting: !running && Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
|
||||
managed: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
|
||||
error: running ? undefined : (lastServiceError ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
export function stopNianxxPlayService(): void {
|
||||
if (!nianxxPlayProcess || nianxxPlayProcess.killed) return;
|
||||
logger.info('[nianxx-play] Stopping service');
|
||||
nianxxPlayProcess.kill();
|
||||
nianxxPlayProcess = null;
|
||||
activePort = null;
|
||||
}
|
||||
@@ -32,6 +32,17 @@ const AUTH_STORE_VERSION = 1;
|
||||
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
|
||||
const LEGACY_MINIMAX_OAUTH_PLUGIN_ID = 'minimax-portal-auth';
|
||||
const MERGED_MINIMAX_PLUGIN_ID = 'minimax';
|
||||
const YINIAN_DESKTOP_TOOLS_PROFILE = 'coding';
|
||||
const YINIAN_INTERNAL_MODEL_REF = 'minimax/MiniMax-M2.7';
|
||||
const YINIAN_CORE_PLUGIN_IDS = new Set([
|
||||
'minimax',
|
||||
'cloud-sync',
|
||||
'openclaw-weixin',
|
||||
'agentbus',
|
||||
]);
|
||||
const YINIAN_BASE_PLUGIN_IDS = new Set(['minimax', 'cloud-sync']);
|
||||
const YINIAN_CORE_CHANNEL_IDS = new Set(['openclaw-weixin', 'agentbus']);
|
||||
const YINIAN_DISABLED_CHANNEL_IDS = new Set(['feishu', 'dingtalk', 'wecom']);
|
||||
|
||||
interface BundledPluginManifest {
|
||||
id: string;
|
||||
@@ -1031,6 +1042,87 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isYinianManagedConfig(config: Record<string, unknown>): boolean {
|
||||
const models = isPlainRecord(config.models) ? config.models : null;
|
||||
const providers = models && isPlainRecord(models.providers) ? models.providers : null;
|
||||
if (providers && isPlainRecord(providers[OPENCLAW_PROVIDER_KEY_MINIMAX])) return true;
|
||||
|
||||
const agents = isPlainRecord(config.agents) ? config.agents : null;
|
||||
const defaults = agents && isPlainRecord(agents.defaults) ? agents.defaults : null;
|
||||
const model = defaults && isPlainRecord(defaults.model) ? defaults.model : null;
|
||||
return model?.primary === YINIAN_INTERNAL_MODEL_REF;
|
||||
}
|
||||
|
||||
function trimYinianPluginSurface(config: Record<string, unknown>): boolean {
|
||||
if (!isYinianManagedConfig(config)) return false;
|
||||
const plugins = isPlainRecord(config.plugins) ? config.plugins : null;
|
||||
let modified = false;
|
||||
|
||||
if (plugins) {
|
||||
if (Array.isArray(plugins.allow)) {
|
||||
const channels = isPlainRecord(config.channels) ? config.channels : null;
|
||||
const nextAllow = (plugins.allow as unknown[])
|
||||
.filter((pluginId): pluginId is string => {
|
||||
if (typeof pluginId !== 'string') return false;
|
||||
if (YINIAN_BASE_PLUGIN_IDS.has(pluginId)) return true;
|
||||
return YINIAN_CORE_CHANNEL_IDS.has(pluginId) && Boolean(channels?.[pluginId]);
|
||||
});
|
||||
for (const pluginId of YINIAN_BASE_PLUGIN_IDS) {
|
||||
if (!nextAllow.includes(pluginId)) nextAllow.push(pluginId);
|
||||
}
|
||||
for (const pluginId of YINIAN_CORE_CHANNEL_IDS) {
|
||||
if (channels?.[pluginId] && !nextAllow.includes(pluginId)) nextAllow.push(pluginId);
|
||||
}
|
||||
if (JSON.stringify(nextAllow) !== JSON.stringify(plugins.allow)) {
|
||||
plugins.allow = nextAllow;
|
||||
modified = true;
|
||||
console.log(`[sanitize] Trimmed plugins.allow to Yinian core plugins: ${nextAllow.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlainRecord(plugins.entries)) {
|
||||
for (const pluginId of Object.keys(plugins.entries)) {
|
||||
if (YINIAN_CORE_PLUGIN_IDS.has(pluginId)) continue;
|
||||
delete plugins.entries[pluginId];
|
||||
modified = true;
|
||||
console.log(`[sanitize] Removed non-core plugin entry "${pluginId}" from Yinian OpenClaw config`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const channels = isPlainRecord(config.channels) ? config.channels : null;
|
||||
if (channels) {
|
||||
for (const channelId of Object.keys(channels)) {
|
||||
if (YINIAN_CORE_CHANNEL_IDS.has(channelId)) continue;
|
||||
delete channels[channelId];
|
||||
modified = true;
|
||||
console.log(`[sanitize] Removed disabled channel "${channelId}" from Yinian OpenClaw config`);
|
||||
}
|
||||
}
|
||||
|
||||
const agents = isPlainRecord(config.agents) ? config.agents : null;
|
||||
if (Array.isArray(agents?.list)) {
|
||||
const nextAgents = agents.list.filter((entry) => {
|
||||
if (!isPlainRecord(entry)) return true;
|
||||
const id = typeof entry.id === 'string' ? entry.id : '';
|
||||
const channelType = typeof entry.channelType === 'string'
|
||||
? entry.channelType
|
||||
: typeof entry.channel === 'string'
|
||||
? entry.channel
|
||||
: '';
|
||||
if (YINIAN_DISABLED_CHANNEL_IDS.has(channelType)) return false;
|
||||
return !Array.from(YINIAN_DISABLED_CHANNEL_IDS).some((disabledId) => id.startsWith(`channel-${disabledId}-`));
|
||||
});
|
||||
if (nextAgents.length !== agents.list.length) {
|
||||
agents.list = nextAgents;
|
||||
modified = true;
|
||||
console.log('[sanitize] Removed disabled channel agents from Yinian OpenClaw config');
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
function removeLegacyMoonshotKimiSearchConfig(config: Record<string, unknown>): boolean {
|
||||
const tools = isPlainRecord(config.tools) ? config.tools : null;
|
||||
const web = tools && isPlainRecord(tools.web) ? tools.web : null;
|
||||
@@ -1687,6 +1779,15 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
if (modified) config.plugins = validPlugins;
|
||||
} else if (typeof plugins === 'object') {
|
||||
const pluginsObj = plugins as Record<string, unknown>;
|
||||
const KNOWN_INVALID_PLUGINS_ROOT_KEYS = ['bundledDiscovery'];
|
||||
for (const key of KNOWN_INVALID_PLUGINS_ROOT_KEYS) {
|
||||
if (key in pluginsObj) {
|
||||
console.log(`[sanitize] Removing deprecated key "plugins.${key}" from openclaw.json`);
|
||||
delete pluginsObj[key];
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(pluginsObj.load)) {
|
||||
const validLoad: unknown[] = [];
|
||||
for (const p of pluginsObj.load) {
|
||||
@@ -1784,14 +1885,16 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
|
||||
// ── tools.profile & sessions.visibility ───────────────────────
|
||||
// OpenClaw 3.8+ requires tools.profile = 'full' and tools.sessions.visibility = 'all'
|
||||
// for ClawX to properly integrate with its updated tool system.
|
||||
const toolsConfig = (config.tools as Record<string, unknown> | undefined) || {};
|
||||
// 智念助手需要保留多轮上下文、文件生成与能力包调用。
|
||||
// OpenClaw 的 messaging/minimal 档会进入 raw model run,导致每轮不 replay 历史。
|
||||
// 因此桌面端使用 coding 档,UI 层再隐藏普通用户不需要理解的开发者概念。
|
||||
const toolsConfig = isPlainRecord(config.tools) ? config.tools : {};
|
||||
let toolsModified = false;
|
||||
|
||||
if (toolsConfig.profile !== 'full') {
|
||||
toolsConfig.profile = 'full';
|
||||
if (toolsConfig.profile !== YINIAN_DESKTOP_TOOLS_PROFILE) {
|
||||
toolsConfig.profile = YINIAN_DESKTOP_TOOLS_PROFILE;
|
||||
toolsModified = true;
|
||||
console.log(`[sanitize] Set tools.profile="${YINIAN_DESKTOP_TOOLS_PROFILE}" for Yinian desktop chat latency`);
|
||||
}
|
||||
|
||||
const sessions = (toolsConfig.sessions as Record<string, unknown> | undefined) || {};
|
||||
@@ -1821,6 +1924,56 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// ── agents.defaults desktop defaults ──────────────────────────
|
||||
// OpenClaw 会把可见 skills 汇总进系统上下文。对智念助手的普通对话,
|
||||
// 默认不展开全量 skill 目录;后续由“快捷任务/能力包”按需注入。
|
||||
// OpenClaw 默认每 30 分钟在 main session 跑一次 heartbeat。桌面端不
|
||||
// 使用这类后台心跳任务,避免它变成普通用户可见的历史会话。
|
||||
if (isYinianManagedConfig(config)) {
|
||||
const agentsConfig = isPlainRecord(config.agents) ? config.agents : {};
|
||||
const defaults = isPlainRecord(agentsConfig.defaults) ? agentsConfig.defaults : {};
|
||||
if (!Array.isArray(defaults.skills)) {
|
||||
defaults.skills = [];
|
||||
agentsConfig.defaults = defaults;
|
||||
config.agents = agentsConfig;
|
||||
modified = true;
|
||||
console.log('[sanitize] Set agents.defaults.skills=[] for Yinian desktop lightweight chat');
|
||||
}
|
||||
const heartbeat = isPlainRecord(defaults.heartbeat) ? defaults.heartbeat : {};
|
||||
if (heartbeat.every !== '0m') {
|
||||
heartbeat.every = '0m';
|
||||
defaults.heartbeat = heartbeat;
|
||||
agentsConfig.defaults = defaults;
|
||||
config.agents = agentsConfig;
|
||||
modified = true;
|
||||
console.log('[sanitize] Disabled OpenClaw agent heartbeat for Yinian desktop');
|
||||
}
|
||||
if (Array.isArray(agentsConfig.list)) {
|
||||
const nextList = agentsConfig.list.map((entry) => {
|
||||
if (!isPlainRecord(entry) || entry.id !== 'main') return entry;
|
||||
const tools = isPlainRecord(entry.tools) ? entry.tools : {};
|
||||
if (tools.profile === YINIAN_DESKTOP_TOOLS_PROFILE) return entry;
|
||||
return {
|
||||
...entry,
|
||||
tools: {
|
||||
...tools,
|
||||
profile: YINIAN_DESKTOP_TOOLS_PROFILE,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (JSON.stringify(nextList) !== JSON.stringify(agentsConfig.list)) {
|
||||
agentsConfig.list = nextList;
|
||||
config.agents = agentsConfig;
|
||||
modified = true;
|
||||
console.log(`[sanitize] Set main agent tools.profile="${YINIAN_DESKTOP_TOOLS_PROFILE}" for Yinian desktop context replay`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (trimYinianPluginSurface(config)) {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// ── plugins.entries.feishu cleanup ──────────────────────────────
|
||||
// Normalize feishu plugin ids dynamically based on installed manifest.
|
||||
// Different environments may report either "openclaw-lark" or
|
||||
@@ -2224,6 +2377,10 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (trimYinianPluginSurface(config)) {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await writeOpenClawJson(config);
|
||||
console.log('[sanitize] openclaw.json sanitized successfully');
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -150,10 +150,9 @@ export async function installOpenClawCli(): Promise<{
|
||||
try {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Remove existing file/symlink to avoid EEXIST
|
||||
if (existsSync(target)) {
|
||||
unlinkSync(target);
|
||||
}
|
||||
// Remove existing file/symlink to avoid EEXIST. `existsSync` is false for
|
||||
// broken symlinks, so use rmSync directly with force.
|
||||
rmSync(target, { force: true });
|
||||
|
||||
symlinkSync(wrapperSrc, target);
|
||||
chmodSync(wrapperSrc, 0o755);
|
||||
@@ -304,7 +303,7 @@ export async function autoInstallCliIfNeeded(
|
||||
if (isCliInstalled()) {
|
||||
if (target && wrapperSrc && existsSync(target)) {
|
||||
try {
|
||||
unlinkSync(target);
|
||||
rmSync(target, { force: true });
|
||||
symlinkSync(wrapperSrc, target);
|
||||
logger.debug(`Refreshed CLI symlink: ${target} -> ${wrapperSrc}`);
|
||||
} catch {
|
||||
|
||||
74
electron/utils/optional-native-cleanup.ts
Normal file
74
electron/utils/optional-native-cleanup.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function fsPath(filePath: string): string {
|
||||
if (process.platform !== 'win32') return filePath;
|
||||
if (!filePath) return filePath;
|
||||
if (filePath.startsWith('\\\\?\\')) return filePath;
|
||||
return `\\\\?\\${filePath.replace(/\//g, '\\')}`;
|
||||
}
|
||||
|
||||
function removeClipboardPackagesFromScope(scopeDir: string): number {
|
||||
if (!existsSync(fsPath(scopeDir))) return 0;
|
||||
|
||||
let removed = 0;
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = readdirSync(fsPath(scopeDir));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry !== 'clipboard' && !entry.startsWith('clipboard-')) continue;
|
||||
try {
|
||||
rmSync(fsPath(join(scopeDir, entry)), { recursive: true, force: true });
|
||||
removed += 1;
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
function cleanupNodeModules(nodeModulesDir: string): number {
|
||||
return removeClipboardPackagesFromScope(join(nodeModulesDir, '@mariozechner'));
|
||||
}
|
||||
|
||||
function cleanupNodeModulesChildren(rootDir: string): number {
|
||||
if (!existsSync(fsPath(rootDir))) return 0;
|
||||
|
||||
let removed = 0;
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean }> = [];
|
||||
try {
|
||||
entries = readdirSync(fsPath(rootDir), { withFileTypes: true }) as typeof entries;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
removed += cleanupNodeModules(join(rootDir, entry.name, 'node_modules'));
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function cleanupOpenClawRuntimeNativeClipboard(openclawDir: string): number {
|
||||
return cleanupNodeModules(join(openclawDir, 'node_modules'));
|
||||
}
|
||||
|
||||
export function cleanupOpenClawUserNativeClipboard(): number {
|
||||
const openclawHome = join(homedir(), '.openclaw');
|
||||
let removed = 0;
|
||||
|
||||
removed += cleanupNodeModules(join(openclawHome, 'runtime', 'openclaw', 'node_modules'));
|
||||
removed += cleanupNodeModules(join(openclawHome, 'npm', 'node_modules'));
|
||||
removed += cleanupNodeModules(join(openclawHome, 'node_modules'));
|
||||
removed += cleanupNodeModulesChildren(join(openclawHome, 'extensions'));
|
||||
removed += cleanupNodeModulesChildren(join(openclawHome, 'plugin-runtime-deps'));
|
||||
|
||||
return removed;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { createRequire } from 'node:module';
|
||||
import { dirname, isAbsolute, join, normalize } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'fs';
|
||||
import { cleanupOpenClawRuntimeNativeClipboard } from './optional-native-cleanup';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -33,6 +34,8 @@ const REQUIRED_OPENCLAW_CONTEXT_MODULES = [
|
||||
'qrcode-terminal/vendor/QRCode/index.js',
|
||||
'qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js',
|
||||
] as const;
|
||||
const YINIAN_OPENCLAW_RUNTIME_PATCH_MARKER = '.yinian-runtime-patch.json';
|
||||
const YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION = '2026-05-07-desktop-fast-chat-v1';
|
||||
|
||||
export {
|
||||
quoteForCmd,
|
||||
@@ -172,6 +175,25 @@ function hasRequiredOpenClawContextModules(dir: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function readYinianOpenClawRuntimePatchVersion(dir: string): string | undefined {
|
||||
try {
|
||||
const markerPath = join(dir, YINIAN_OPENCLAW_RUNTIME_PATCH_MARKER);
|
||||
if (!existsSync(fsPath(markerPath))) return undefined;
|
||||
const marker = JSON.parse(readFileSync(fsPath(markerPath), 'utf-8')) as { version?: string };
|
||||
return marker.version;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hasYinianOpenClawRuntimePatch(dir: string): boolean {
|
||||
return readYinianOpenClawRuntimePatchVersion(dir) === YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION;
|
||||
}
|
||||
|
||||
function hasPackagedOpenClawRuntimeRequirements(dir: string): boolean {
|
||||
return hasRequiredOpenClawContextModules(dir) && hasYinianOpenClawRuntimePatch(dir);
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
try {
|
||||
return realpathSync(fsPath(left)) === realpathSync(fsPath(right));
|
||||
@@ -253,10 +275,12 @@ function findExternalOpenClawDir(excludedDirs: string[]): string | null {
|
||||
seen.add(candidate);
|
||||
if (excludedDirs.some((excluded) => samePath(candidate, excluded))) continue;
|
||||
if (!isValidOpenClawPackageDir(candidate)) continue;
|
||||
if (hasRequiredOpenClawContextModules(candidate)) return candidate;
|
||||
logOpenClawRuntime('[openclaw-runtime] Ignoring external OpenClaw installation because required app dependencies are missing', {
|
||||
if (hasPackagedOpenClawRuntimeRequirements(candidate)) return candidate;
|
||||
logOpenClawRuntime('[openclaw-runtime] Ignoring external OpenClaw installation because it is not a patched Yinian runtime', {
|
||||
candidate,
|
||||
requiredModules: REQUIRED_OPENCLAW_CONTEXT_MODULES,
|
||||
runtimePatchVersion: readYinianOpenClawRuntimePatchVersion(candidate) ?? null,
|
||||
expectedRuntimePatchVersion: YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,7 +295,7 @@ function installBundledOpenClawToManagedRuntime(bundledDir: string, managedDir:
|
||||
? readOpenClawVersion(managedDir)
|
||||
: undefined;
|
||||
|
||||
if (managedVersion && bundledVersion && managedVersion === bundledVersion && hasRequiredOpenClawContextModules(managedDir)) {
|
||||
if (managedVersion && bundledVersion && managedVersion === bundledVersion && hasPackagedOpenClawRuntimeRequirements(managedDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -279,6 +303,12 @@ function installBundledOpenClawToManagedRuntime(bundledDir: string, managedDir:
|
||||
rmSync(fsPath(tempDir), { recursive: true, force: true });
|
||||
mkdirSync(fsPath(dirname(tempDir)), { recursive: true });
|
||||
cpSync(fsPath(bundledDir), fsPath(tempDir), { recursive: true, dereference: true });
|
||||
const removedClipboardPackages = cleanupOpenClawRuntimeNativeClipboard(tempDir);
|
||||
if (removedClipboardPackages > 0) {
|
||||
logOpenClawRuntime('[openclaw-runtime] Removed optional native clipboard packages from managed runtime', {
|
||||
removedClipboardPackages,
|
||||
});
|
||||
}
|
||||
rmSync(fsPath(managedDir), { recursive: true, force: true });
|
||||
cpSync(fsPath(tempDir), fsPath(managedDir), { recursive: true, dereference: true });
|
||||
rmSync(fsPath(tempDir), { recursive: true, force: true });
|
||||
@@ -303,7 +333,7 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
|
||||
return cachedOpenClawRuntime;
|
||||
}
|
||||
|
||||
if (hasRequiredOpenClawContextModules(managedDir)) {
|
||||
if (hasPackagedOpenClawRuntimeRequirements(managedDir)) {
|
||||
cachedOpenClawRuntime = {
|
||||
dir: managedDir,
|
||||
source: 'managed',
|
||||
@@ -337,7 +367,7 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRequiredOpenClawContextModules(managedDir)) {
|
||||
if (hasPackagedOpenClawRuntimeRequirements(managedDir)) {
|
||||
cachedOpenClawRuntime = {
|
||||
dir: managedDir,
|
||||
source: 'managed',
|
||||
@@ -356,9 +386,11 @@ function resolveOpenClawRuntime(): OpenClawRuntimeResolution {
|
||||
}
|
||||
|
||||
if (isValidOpenClawPackageDir(managedDir)) {
|
||||
logOpenClawRuntime('[openclaw-runtime] Ignoring managed OpenClaw runtime because required app dependencies are missing', {
|
||||
logOpenClawRuntime('[openclaw-runtime] Ignoring managed OpenClaw runtime because it is not a patched Yinian runtime', {
|
||||
managedDir,
|
||||
requiredModules: REQUIRED_OPENCLAW_CONTEXT_MODULES,
|
||||
runtimePatchVersion: readYinianOpenClawRuntimePatchVersion(managedDir) ?? null,
|
||||
expectedRuntimePatchVersion: YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -415,10 +447,11 @@ export function reinstallManagedOpenClawRuntime(): OpenClawRuntimeResolution {
|
||||
installedFromBundled = installBundledOpenClawToManagedRuntime(bundledDir, managedDir);
|
||||
}
|
||||
|
||||
const managedReady = hasPackagedOpenClawRuntimeRequirements(managedDir);
|
||||
cachedOpenClawRuntime = {
|
||||
dir: hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir,
|
||||
source: hasRequiredOpenClawContextModules(managedDir) ? 'managed' : isValidOpenClawPackageDir(bundledDir) ? 'bundled' : 'missing',
|
||||
version: readOpenClawVersion(hasRequiredOpenClawContextModules(managedDir) ? managedDir : bundledDir),
|
||||
dir: managedReady ? managedDir : bundledDir,
|
||||
source: managedReady ? 'managed' : isValidOpenClawPackageDir(bundledDir) ? 'bundled' : 'missing',
|
||||
version: readOpenClawVersion(managedReady ? managedDir : bundledDir),
|
||||
bundledDir,
|
||||
managedDir,
|
||||
installedFromBundled,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Shared OpenClaw Plugin Install Utilities
|
||||
*
|
||||
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins
|
||||
* (DingTalk, WeCom, Feishu, WeChat). Used both at app startup (to auto-upgrade
|
||||
* stale plugins) and when a user configures a channel.
|
||||
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins.
|
||||
* 智念助手内测包只主动管理核心插件,避免无用渠道插件被重新拉回。
|
||||
*
|
||||
* Note: QQBot was moved to a built-in channel in OpenClaw 3.31 and is no longer
|
||||
* managed as a plugin.
|
||||
@@ -115,11 +114,9 @@ function toErrorDiagnostic(error: unknown): { code?: string; name?: string; mess
|
||||
|
||||
// ── Known plugin-ID corrections ─────────────────────────────────────────────
|
||||
// Some npm packages ship with an openclaw.plugin.json whose "id" field
|
||||
// doesn't match the ID the plugin code actually exports. After copying we
|
||||
// patch both the manifest AND the compiled JS so the Gateway accepts them.
|
||||
const MANIFEST_ID_FIXES: Record<string, string> = {
|
||||
'wecom-openclaw-plugin': 'wecom',
|
||||
};
|
||||
// doesn't match the ID the plugin code actually exports. Keep this hook for
|
||||
// future bundled plugins that may need it.
|
||||
const MANIFEST_ID_FIXES: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* After a plugin has been copied to ~/.openclaw/extensions/<dir>, fix any
|
||||
@@ -231,10 +228,6 @@ function patchPluginEntryIds(targetDir: string): void {
|
||||
// ── Plugin npm name mapping ──────────────────────────────────────────────────
|
||||
|
||||
const PLUGIN_NPM_NAMES: Record<string, string> = {
|
||||
dingtalk: '@soimy/dingtalk',
|
||||
wecom: '@wecom/wecom-openclaw-plugin',
|
||||
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
|
||||
|
||||
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
|
||||
};
|
||||
|
||||
@@ -498,24 +491,6 @@ export function buildCandidateSources(pluginDirName: string): string[] {
|
||||
|
||||
// ── Per-channel plugin helpers ───────────────────────────────────────────────
|
||||
|
||||
export function ensureDingTalkPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk');
|
||||
}
|
||||
|
||||
export function ensureWeComPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
|
||||
}
|
||||
|
||||
export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled(
|
||||
'feishu-openclaw-plugin',
|
||||
buildCandidateSources('feishu-openclaw-plugin'),
|
||||
'Feishu',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
|
||||
}
|
||||
@@ -530,10 +505,6 @@ export function ensureCloudSyncPluginInstalled(): { installed: boolean; warning?
|
||||
* All bundled plugins, in the same order as after-pack.cjs BUNDLED_PLUGINS.
|
||||
*/
|
||||
const ALL_BUNDLED_PLUGINS = [
|
||||
{ fn: ensureDingTalkPluginInstalled, label: 'DingTalk' },
|
||||
{ fn: ensureWeComPluginInstalled, label: 'WeCom' },
|
||||
|
||||
{ fn: ensureFeishuPluginInstalled, label: 'Feishu' },
|
||||
{ fn: ensureWeChatPluginInstalled, label: 'WeChat' },
|
||||
{ fn: ensureCloudSyncPluginInstalled, label: 'Cloud Sync' },
|
||||
] as const;
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface YinianInitializationStatus {
|
||||
const INTERNAL_PROVIDER_KEY = 'minimax';
|
||||
const INTERNAL_MODEL_ID = 'MiniMax-M2.7';
|
||||
const INTERNAL_MODEL_REF = `${INTERNAL_PROVIDER_KEY}/${INTERNAL_MODEL_ID}`;
|
||||
const INTERNAL_MODEL_TIMEOUT_SECONDS = 300;
|
||||
const DESKTOP_TOOLS_PROFILE = 'coding';
|
||||
let initializationInFlight: Promise<YinianInitializationStatus> | null = null;
|
||||
|
||||
const DEFAULT_STEPS: YinianInitializationStep[] = [
|
||||
@@ -143,10 +145,12 @@ async function seedInternalModelConfig(): Promise<void> {
|
||||
const config = await readJsonFile(configPath);
|
||||
const models = asObject(config.models);
|
||||
const providers = asObject(models.providers);
|
||||
const pricing = asObject(models.pricing);
|
||||
providers[INTERNAL_PROVIDER_KEY] = {
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
api: 'anthropic-messages',
|
||||
authHeader: true,
|
||||
timeoutSeconds: INTERNAL_MODEL_TIMEOUT_SECONDS,
|
||||
models: [
|
||||
{
|
||||
id: INTERNAL_MODEL_ID,
|
||||
@@ -158,10 +162,19 @@ async function seedInternalModelConfig(): Promise<void> {
|
||||
},
|
||||
],
|
||||
};
|
||||
pricing.enabled = false;
|
||||
models.mode = 'merge';
|
||||
models.providers = providers;
|
||||
models.pricing = pricing;
|
||||
config.models = models;
|
||||
|
||||
const tools = asObject(config.tools);
|
||||
tools.profile = DESKTOP_TOOLS_PROFILE;
|
||||
const sessions = asObject(tools.sessions);
|
||||
sessions.visibility = 'all';
|
||||
tools.sessions = sessions;
|
||||
config.tools = tools;
|
||||
|
||||
const agents = asObject(config.agents);
|
||||
const defaults = asObject(agents.defaults);
|
||||
defaults.model = {
|
||||
@@ -169,6 +182,11 @@ async function seedInternalModelConfig(): Promise<void> {
|
||||
fallbacks: ['minimax/MiniMax-M2.5'],
|
||||
};
|
||||
defaults.workspace = join(homedir(), '.openclaw', 'workspace');
|
||||
defaults.skills = [];
|
||||
defaults.heartbeat = {
|
||||
...asObject(defaults.heartbeat),
|
||||
every: '0m',
|
||||
};
|
||||
agents.defaults = defaults;
|
||||
if (!Array.isArray(agents.list)) {
|
||||
agents.list = [
|
||||
@@ -178,8 +196,23 @@ async function seedInternalModelConfig(): Promise<void> {
|
||||
default: true,
|
||||
workspace: join(homedir(), '.openclaw', 'workspace'),
|
||||
agentDir: '~/.openclaw/agents/main/agent',
|
||||
skills: [],
|
||||
tools: { profile: DESKTOP_TOOLS_PROFILE },
|
||||
},
|
||||
];
|
||||
} else {
|
||||
agents.list = agents.list.map((entry) => {
|
||||
const agent = asObject(entry);
|
||||
if (agent.id !== 'main') return entry;
|
||||
return {
|
||||
...agent,
|
||||
skills: Array.isArray(agent.skills) ? agent.skills : [],
|
||||
tools: {
|
||||
...asObject(agent.tools),
|
||||
profile: DESKTOP_TOOLS_PROFILE,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
config.agents = agents;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user