diff --git a/.env.example b/.env.example index 8e33b98..29254f5 100644 --- a/.env.example +++ b/.env.example @@ -1,41 +1,45 @@ -# ClawX Environment Variables +# Zhinian Desktop Environment Template +# +# Copy this file to `.env.local` for local development only. +# Do not commit `.env.local` or any real API keys, tokens, client secrets, +# certificates, or customer-specific service endpoints. # OpenClaw Gateway Configuration OPENCLAW_GATEWAY_PORT=18789 # Development Configuration -VITE_DEV_SERVER_PORT=5173 +VITE_DEV_SERVER_PORT=5188 # Zhinian service connection -# Required for real login. The desktop app no longer falls back to demo login -# unless YINIAN_CONTROL_PLANE_MODE=mock is explicitly set. -YINIAN_API_BASE_URL=https://onefeel.brother7.cn/ingress -YINIAN_AUTH_CLIENT_ID=customPC +# Required for real customer login. Get these values from the deployment owner. +YINIAN_API_BASE_URL=https://your-zhinian-api.example/ingress +YINIAN_AUTH_CLIENT_ID=your-client-id YINIAN_AUTH_SCOPE=server -# Optional, depending on your server OAuth client settings: -# YINIAN_AUTH_CLIENT_SECRET=customPC -# YINIAN_AUTH_BASIC=Basic Y3VzdG9tUEM6Y3VzdG9tUEM= +# Optional, depending on your server OAuth client settings. Leave blank unless +# the deployment owner provides values through a private channel. +# YINIAN_AUTH_CLIENT_SECRET= +# YINIAN_AUTH_BASIC= # Optional enterprise-space/application endpoints. Template variables are supported: # {workspaceId}, {workspace_id}, {hotelId}, {hotel_id}, {tenantId}, {tenant_id} # YINIAN_CONFIG_SYNC_PATH=/config/sync # YINIAN_SKILLS_MANIFEST_PATH=/skills/manifest -# Optional OpenClaw cloud sync plugin service. Defaults to YINIAN_API_BASE_URL -# with a trailing /ingress removed, then https://onefeel.brother7.cn. -# YINIAN_CLOUD_SYNC_SERVER_URL=https://onefeel.brother7.cn +# Optional OpenClaw cloud sync plugin service. +# YINIAN_CLOUD_SYNC_SERVER_URL=https://your-zhinian-api.example # YINIAN_CLOUD_SYNC_ENABLED=1 -# Local demo mode, for visual QA or offline demos only. +# Local demo mode, for visual QA or offline demos only. This avoids requiring +# a real service endpoint while testing the desktop shell. # YINIAN_CONTROL_PLANE_MODE=mock # Release Configuration (CI/CD) -# Apple Developer Credentials -APPLE_ID=your@email.com -APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx -APPLE_TEAM_ID=XXXXXXXXXX +# Apple Developer Credentials. Keep real values in CI secrets or local shell. +APPLE_ID= +APPLE_APP_SPECIFIC_PASSWORD= +APPLE_TEAM_ID= -# Code Signing Certificate -CSC_LINK=path/to/certificate.p12 -CSC_KEY_PASSWORD=certificate_password +# Code Signing Certificate. Keep real files and passwords out of git. +CSC_LINK= +CSC_KEY_PASSWORD= -# GitHub Token for releases -GH_TOKEN=github_personal_access_token +# Release token. Keep real values in CI secrets or local shell. +GH_TOKEN= diff --git a/.gitignore b/.gitignore index 0cdac6d..5802e9b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ pnpm-debug.log* yarn-debug.log* yarn-error.log* +# Generated local documents +*.docx + # OS files .DS_Store Thumbs.db diff --git a/README.md b/README.md index f4d2d0a..d3701f2 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,6 @@ ![对话与快捷任务](resources/readme/04-chat-quick-task.png) -### 应用中心 - -![应用中心](resources/readme/05-app-center.png) - -### 智念视频助手 - -![智念视频助手](resources/readme/06-nianxx-play.png) - ### 知识库 ![知识库](resources/readme/07-knowledge.png) @@ -80,7 +72,7 @@ - **知识库**:支持文本类资料导入,包含 Word 等常用格式;导入后会复制到本地安全目录,避免源文件删除后丢失。 - **定时任务**:面向用户可见、可管理的自动化任务能力,与 OpenClaw 内部 heartbeat 机制分离。 - **渠道管理**:支持渠道和账号绑定,渠道账号可维护备注;新增渠道时会自动创建对应 Agent,删除渠道时同步清理绑定 Agent。 -- **应用中心**:承载固定业务应用,当前重点集成 `NianxxPlay / 智念视频助手`,桌面端负责伴随安装、启动、健康检查与外壳承载。 +- **应用中心**:保留固定业务应用入口;当前不再内置业务应用,后续由产品交付节奏重新接入。 - **初始化流程**:首次打开时引导安装或重装 OpenClaw 运行环境,并内置内测阶段模型配置与必要依赖。 - **运行维护**:管理员模式下提供模型配置诊断、Gateway 状态、OpenClaw 运行信息和高级排查入口。 @@ -92,7 +84,6 @@ - 状态管理:Zustand - Agent 内核:OpenClaw Gateway - 包管理:pnpm -- 内置应用:NianxxPlay 作为应用中心内的大型业务应用 ## 目录结构 @@ -104,7 +95,7 @@ yinian-desktop/ │ ├── main/ # Electron 主进程入口与 IPC │ └── utils/ # 初始化、配置、打包、诊断等工具 ├── resources/ # 图标、内置资源、预装能力包资源 -├── scripts/ # 打包、OpenClaw bundle、NianxxPlay bundle 脚本 +├── scripts/ # 打包、OpenClaw bundle、预装能力包等脚本 ├── shared/ # Main / Renderer 共享类型 ├── src/ # Renderer 前端 │ ├── components/ # 公共组件 @@ -117,6 +108,25 @@ yinian-desktop/ ## 本地开发 +### 快速开始 + +面向客户或测试人员的最快路径: + +```bash +git clone <仓库地址> +cd yinian-desktop +pnpm install +cp .env.example .env.local +``` + +如果只是体验桌面外壳和本地能力,可以在 `.env.local` 中启用离线演示模式: + +```bash +YINIAN_CONTROL_PLANE_MODE=mock +``` + +如果需要连接真实组织空间,请从部署负责人处获取服务地址和 OAuth 客户端配置,写入本机 `.env.local`。仓库只保留 `.env.example` 占位模板,不提交真实 API key、token、client secret、证书或客户专属服务端点。 + ### 环境要求 - Node.js 20+ @@ -159,7 +169,9 @@ pnpm run package:mac:pilot:arm64 pnpm run package:mac:pilot ``` -打包前脚本会准备 OpenClaw runtime、OpenClaw plugins、预装能力包、NianxxPlay bundle、Node/uv 等必要运行资源。 +打包前脚本会准备 OpenClaw runtime、OpenClaw plugins、预装能力包、Node/uv 等必要运行资源。 + +如果没有私有模型凭据或客户专属服务配置,建议先使用普通开发模式或非 pilot 打包;真实密钥应只通过 CI Secret、本机环境变量或服务端下发进入运行时,不应进入 git 仓库。 ## 运行时说明 @@ -186,7 +198,7 @@ pnpm run package:mac:pilot - 能力包列表、本地安装与快捷任务触发 - 知识库上传、备份、删除与对话上下文选择 - 定时任务创建、启停与执行记录 -- 应用中心打开 NianxxPlay、刷新、返回与历史项目 +- 应用中心空状态与导航入口 - 设置页管理员模式、模型诊断、渠道管理 - macOS arm64 安装包启动、权限、签名与公证 diff --git a/electron-builder.yml b/electron-builder.yml index 842084c..1d43a2e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -29,9 +29,6 @@ extraResources: # Pre-bundled third-party skills (full directories, not only SKILL.md) - from: build/preinstalled-skills/ to: resources/preinstalled-skills/ - # Built-in application center apps. - - from: build/apps/nianxx-play/ - to: resources/nianxx-play/ # Internal pilot-only runtime material. Production builds generate a benign # manifest; pilot builds may include temporary model auth for closed testing. - from: build/yinian-internal/ @@ -90,7 +87,7 @@ mac: dmg: # Explicit volume size prevents dmg-builder@1.2.0 auto-calculation from # underestimating (causes "No space left on device" for large app bundles). - # The app currently embeds OpenClaw + NianxxPlay assets; the final .dmg is + # The app embeds OpenClaw assets; the final .dmg is # compressed, so this mostly affects the temporary mounted volume. size: 7g background: resources/dmg-background.png diff --git a/electron/api/routes/agent-system-documents.ts b/electron/api/routes/agent-system-documents.ts new file mode 100644 index 0000000..2db8bc0 --- /dev/null +++ b/electron/api/routes/agent-system-documents.ts @@ -0,0 +1,79 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + isAgentSystemDocumentKind, + readAgentSystemDocuments, + resetAgentSystemDocument, + saveAgentSystemDocument, +} from '../../utils/agent-system-documents'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +function scheduleGatewayReload(ctx: HostApiContext): void { + if (ctx.gatewayManager.getStatus().state !== 'stopped') { + ctx.gatewayManager.debouncedReload(); + } +} + +function parseDocumentPath(url: URL): { kind: string; reset: boolean } | null { + const prefix = '/api/agent-system-documents/'; + if (!url.pathname.startsWith(prefix)) return null; + + const parts = url.pathname.slice(prefix.length).split('/').filter(Boolean); + if (parts.length === 1) { + return { kind: decodeURIComponent(parts[0]), reset: false }; + } + if (parts.length === 2 && parts[1] === 'reset') { + return { kind: decodeURIComponent(parts[0]), reset: true }; + } + return null; +} + +export async function handleAgentSystemDocumentRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/agent-system-documents' && req.method === 'GET') { + try { + sendJson(res, 200, await readAgentSystemDocuments(url.searchParams.get('agentId') ?? undefined)); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + const parsed = parseDocumentPath(url); + if (!parsed) return false; + + if (!isAgentSystemDocumentKind(parsed.kind)) { + sendJson(res, 404, { success: false, error: `Unsupported system document kind "${parsed.kind}"` }); + return true; + } + + if (!parsed.reset && req.method === 'PUT') { + try { + const body = await parseJsonBody<{ agentId?: string; content?: string }>(req); + const snapshot = await saveAgentSystemDocument(body.agentId, parsed.kind, body.content ?? ''); + scheduleGatewayReload(ctx); + sendJson(res, 200, snapshot); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (parsed.reset && req.method === 'POST') { + try { + const body = await parseJsonBody<{ agentId?: string }>(req); + const snapshot = await resetAgentSystemDocument(body.agentId, parsed.kind); + scheduleGatewayReload(ctx); + sendJson(res, 200, snapshot); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/apps.ts b/electron/api/routes/apps.ts deleted file mode 100644 index 1769847..0000000 --- a/electron/api/routes/apps.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import type { HostApiContext } from '../context'; -import { sendJson } from '../route-utils'; -import { - ensureNianxxPlayServiceStarted, - getNianxxPlayServiceStatus, -} from '../../utils/nianxx-play-service'; - -export async function handleAppIntegrationRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - _ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/apps/nianxx-play/status' && req.method === 'GET') { - sendJson(res, 200, await getNianxxPlayServiceStatus()); - return true; - } - - if (url.pathname === '/api/apps/nianxx-play/start' && req.method === 'POST') { - sendJson(res, 200, await ensureNianxxPlayServiceStarted()); - return true; - } - - return false; -} diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index e930fda..9c7b2bf 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -27,6 +27,7 @@ import { listAgentsSnapshotFromConfig, } from '../../utils/agent-config'; import { + ensureFeishuPluginInstalled, ensureWeChatPluginInstalled, } from '../../utils/plugin-install'; import { @@ -80,7 +81,7 @@ import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; const WECHAT_QR_TIMEOUT_MS = 8 * 60 * 1000; -const DISABLED_PLUGIN_CHANNEL_TYPES = new Set(['dingtalk', 'wecom', 'feishu']); +const DISABLED_PLUGIN_CHANNEL_TYPES = new Set(['dingtalk', 'wecom']); const activeQrLogins = new Map(); interface WebLoginStartResult { @@ -1568,6 +1569,13 @@ export async function handleChannelRoutes( return true; } } + if (storedChannelType === 'feishu') { + const installResult = await ensureFeishuPluginInstalled(); + if (!installResult.installed) { + sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' }); + return true; + } + } const existingValues = await getChannelFormValues(body.channelType, body.accountId); if (isSameConfigValues(existingValues, body.config)) { await ensureScopedChannelBinding(body.channelType, body.accountId); diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 6b231fa..fe57e50 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -82,6 +82,7 @@ export async function handleProviderRoutes( const body = await parseJsonBody<{ accountId: string }>(req); const currentDefault = await providerService.getDefaultAccountId(); if (currentDefault === body.accountId) { + await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager); sendJson(res, 200, { success: true, noChange: true }); return true; } @@ -174,6 +175,7 @@ export async function handleProviderRoutes( const body = await parseJsonBody<{ providerId: string }>(req); const currentDefault = await providerService.getDefaultLegacyProvider(); if (currentDefault === body.providerId) { + await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager); sendJson(res, 200, { success: true, noChange: true }); return true; } diff --git a/electron/api/server.ts b/electron/api/server.ts index f5adf86..f67ec94 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -5,11 +5,11 @@ import { logger } from '../utils/logger'; import { extensionRegistry } from '../extensions/registry'; import type { HostApiContext } from './context'; import { handleAppRoutes } from './routes/app'; -import { handleAppIntegrationRoutes } from './routes/apps'; import { handleGatewayRoutes } from './routes/gateway'; import { handleSettingsRoutes } from './routes/settings'; import { handleProviderRoutes } from './routes/providers'; import { handleAgentRoutes } from './routes/agents'; +import { handleAgentSystemDocumentRoutes } from './routes/agent-system-documents'; import { handleChannelRoutes } from './routes/channels'; import { handleLogRoutes } from './routes/logs'; import { handleUsageRoutes } from './routes/usage'; @@ -31,11 +31,11 @@ type RouteHandler = ( const coreRouteHandlers: RouteHandler[] = [ handleAppRoutes, - handleAppIntegrationRoutes, handleGatewayRoutes, handleSettingsRoutes, handleProviderRoutes, handleAgentRoutes, + handleAgentSystemDocumentRoutes, handleChannelRoutes, handleSkillRoutes, handleFileRoutes, diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index 2b54e21..6a31ae7 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -38,10 +38,11 @@ import { logger } from '../utils/logger'; import { prependPathEntry } from '../utils/env-path'; import { buildDotnetEnv } from '../utils/dotnet-runtime'; import { buildPlaywrightRuntimeEnv, ensureYinianPlaywrightRuntimeDirs } from '../utils/playwright-runtime'; -import { copyPluginFromNodeModules, ensureCloudSyncPluginInstalled, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install'; +import { copyPluginFromNodeModules, ensureCloudSyncPluginInstalled, fixupPluginManifest, cpSyncSafe, hasPluginRuntimeEntry } from '../utils/plugin-install'; import { stripSystemdSupervisorEnv } from './config-sync-env'; import { ensureYinianModelRuntimeConfigured } from '../utils/model-diagnostics'; import { cleanupOpenClawUserNativeClipboard } from '../utils/optional-native-cleanup'; +import { syncDefaultProviderToRuntime } from '../services/providers/provider-runtime-sync'; export interface GatewayLaunchContext { @@ -61,6 +62,7 @@ export interface GatewayLaunchContext { const CHANNEL_PLUGIN_MAP: Record = { 'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' }, + feishu: { dirName: 'openclaw-lark', npmName: '@larksuite/openclaw-lark' }, }; const REMOVED_CHANNEL_PLUGIN_DIRS = ['dingtalk', 'wecom', 'feishu-openclaw-plugin']; @@ -197,9 +199,12 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { if (bundledDir) { const sourceVersion = readPluginVersion(join(bundledDir, 'package.json')); + const installedRuntimeReady = isInstalled ? hasPluginRuntimeEntry(targetDir) : false; + const sourceRuntimeReady = hasPluginRuntimeEntry(bundledDir); // Install or upgrade if version differs or plugin not installed - if (!isInstalled || (sourceVersion && installedVersion && sourceVersion !== installedVersion)) { - logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (bundled)`); + if (!isInstalled || (sourceVersion && installedVersion && sourceVersion !== installedVersion) || (!installedRuntimeReady && sourceRuntimeReady)) { + const reinstallReason = isInstalled && !installedRuntimeReady && sourceRuntimeReady ? 'repairing missing runtime entry for' : (isInstalled ? 'Auto-upgrading' : 'Installing'); + logger.info(`[plugin] ${reinstallReason} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (bundled)`); try { mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true }); rmSync(fsPath(targetDir), { recursive: true, force: true }); @@ -222,13 +227,16 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { if (!existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) continue; const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json')); if (!sourceVersion) continue; + const installedRuntimeReady = isInstalled ? hasPluginRuntimeEntry(targetDir) : false; + const sourceRuntimeReady = hasPluginRuntimeEntry(npmPkgPath); // Skip only if installed AND same version — but still patch manifest ID. - if (isInstalled && installedVersion && sourceVersion === installedVersion) { + if (isInstalled && installedVersion && sourceVersion === installedVersion && (installedRuntimeReady || !sourceRuntimeReady)) { fixupPluginManifest(targetDir); continue; } - logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`); + const reinstallReason = isInstalled && !installedRuntimeReady && sourceRuntimeReady ? 'Repairing missing runtime entry for' : (isInstalled ? 'Auto-upgrading' : 'Installing'); + logger.info(`[plugin] ${reinstallReason} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`); try { mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true }); @@ -432,6 +440,15 @@ export async function syncGatewayConfigBeforeLaunch( } catch (err) { logger.warn('Failed to configure Yinian model runtime defaults before launch:', err); } + + try { + const defaultProviderId = await getDefaultProvider(); + if (defaultProviderId) { + await syncDefaultProviderToRuntime(defaultProviderId); + } + } catch (err) { + logger.warn('Failed to sync default provider to OpenClaw before launch:', err); + } } async function loadProviderEnv(): Promise<{ providerEnv: Record; loadedProviderKeyCount: number }> { diff --git a/electron/main/index.ts b/electron/main/index.ts index b086435..a1d6162 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -40,7 +40,6 @@ import { createSignalQuitHandler } from './signal-quit'; import { acquireProcessInstanceFileLock } from './process-instance-lock'; import { getSetting } from '../utils/store'; import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config'; -import { stopNianxxPlayService } from '../utils/nianxx-play-service'; import { startHostApiServer } from '../api/server'; import { HostEventBus } from '../api/event-bus'; @@ -665,7 +664,6 @@ if (gotTheLock) { hostEventBus.closeAll(); hostApiServer?.close(); - stopNianxxPlayService(); void extensionRegistry.teardownAll(); const stopPromise = gatewayManager.stop().catch((err) => { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ff7a220..2020626 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -34,6 +34,7 @@ import { validateChannelConfig, validateChannelCredentials, } from '../utils/channel-config'; +import { ensureFeishuPluginInstalled } from '../utils/plugin-install'; import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias'; import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup'; import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; @@ -1456,7 +1457,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { // initialize / tear-down plugin connections. SIGUSR1 in-process reload is // not sufficient for channel plugins (see restartGatewayForAgentDeletion). const forceRestartChannels = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot']); - const disabledPluginChannels = new Set(['dingtalk', 'wecom', 'feishu']); + const disabledPluginChannels = new Set(['dingtalk', 'wecom']); const scheduleGatewayChannelRestart = (reason: string): void => { if (gatewayManager.getStatus().state !== 'stopped') { @@ -1537,6 +1538,12 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { if (disabledPluginChannels.has(channelType)) { return { success: false, error: '当前内测版本未启用该渠道' }; } + if (channelType === 'feishu') { + const installResult = await ensureFeishuPluginInstalled(); + if (!installResult.installed) { + return { success: false, error: installResult.warning || 'Feishu plugin install failed' }; + } + } await saveChannelConfig(channelType, config); scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); return { success: true }; diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 4614edf..6539793 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -1,5 +1,5 @@ import type { GatewayManager } from '../../gateway/manager'; -import { getProviderAccount, listProviderAccounts } from './provider-store'; +import { getProviderAccount, listProviderAccounts, providerAccountToConfig } from './provider-store'; import { getProviderSecret } from '../secrets/secret-store'; import type { ProviderConfig } from '../../utils/secure-storage'; import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage'; @@ -144,6 +144,16 @@ export function getProviderModelRef(config: ProviderConfig): string | undefined : `${providerKey}/${defaultModel}`; } +async function getProviderConfigForRuntime(providerId: string): Promise { + const legacyProvider = await getProvider(providerId); + if (legacyProvider) { + return legacyProvider; + } + + const account = await getProviderAccount(providerId); + return account ? providerAccountToConfig(account) : null; +} + export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { const allProviders = await getAllProviders(); const providerMap = new Map(allProviders.map((provider) => [provider.id, provider])); @@ -495,7 +505,8 @@ export async function syncSavedProviderToRuntime( scheduleGatewayRefresh( gatewayManager, - `Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`, + `Scheduling Gateway restart after saving provider "${context.runtimeProviderKey}" config`, + { mode: 'restart' }, ); } @@ -543,7 +554,8 @@ export async function syncUpdatedProviderToRuntime( scheduleGatewayRefresh( gatewayManager, - `Scheduling Gateway reload after updating provider "${ock}" config`, + `Scheduling Gateway restart after updating provider "${ock}" config`, + { mode: 'restart' }, ); } @@ -584,7 +596,7 @@ export async function syncDefaultProviderToRuntime( providerId: string, gatewayManager?: GatewayManager, ): Promise { - const provider = await getProvider(providerId); + const provider = await getProviderConfigForRuntime(providerId); if (!provider) { return; } @@ -656,7 +668,8 @@ export async function syncDefaultProviderToRuntime( } scheduleGatewayRefresh( gatewayManager, - `Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`, + `Scheduling Gateway restart after provider switch to "${browserOAuthRuntimeProvider}"`, + { mode: 'restart' }, ); return; } @@ -718,7 +731,7 @@ export async function syncDefaultProviderToRuntime( scheduleGatewayRefresh( gatewayManager, - `Scheduling Gateway reload after provider switch to "${ock}"`, - { onlyIfRunning: true }, + `Scheduling Gateway restart after provider switch to "${ock}"`, + { onlyIfRunning: true, mode: 'restart' }, ); } diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index 681925e..dca911e 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -32,6 +32,12 @@ import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../ut import { getAliasSourceTypes, getOpenClawProviderKeyForType } from '../../utils/provider-keys'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; +import { + YINIAN_MODEL_DEFAULT_BASE_URL, + YINIAN_MODEL_DEFAULT_ID, + YINIAN_MODEL_PROVIDER_KEY, + YINIAN_MODEL_REF, +} from '../../../shared/yinian-model'; function maskApiKey(apiKey: string | null): string | null { if (!apiKey) return null; @@ -67,6 +73,35 @@ function inferProviderVendorIdFromOpenClawEntry( return ((BUILTIN_PROVIDER_TYPES as readonly string[]).includes(key) ? key : 'custom') as ProviderType | 'custom'; } +function isPlaceholderYinianModelAccount(account: ProviderAccount): boolean { + const model = account.model?.startsWith(`${YINIAN_MODEL_PROVIDER_KEY}/`) + ? account.model + : `${YINIAN_MODEL_PROVIDER_KEY}/${account.model ?? ''}`; + + return account.id === YINIAN_MODEL_PROVIDER_KEY + && account.vendorId === 'custom' + && account.baseUrl === YINIAN_MODEL_DEFAULT_BASE_URL + && model === YINIAN_MODEL_REF; +} + +function isPlaceholderYinianModelEntry( + key: string, + entry: Record, + defaultModel: string | undefined, +): boolean { + if (key !== YINIAN_MODEL_PROVIDER_KEY) return false; + if (!entry.baseUrl) return true; + if (entry.baseUrl !== YINIAN_MODEL_DEFAULT_BASE_URL) return false; + + const models = Array.isArray(entry.models) ? entry.models : []; + const hasPlaceholderModel = models.some((model) => { + if (!model || typeof model !== 'object') return false; + return (model as Record).id === YINIAN_MODEL_DEFAULT_ID; + }); + + return defaultModel === YINIAN_MODEL_REF || hasPlaceholderModel; +} + export class ProviderService { async listVendors(): Promise { return PROVIDER_DEFINITIONS; @@ -75,21 +110,16 @@ export class ProviderService { async listAccounts(): Promise { await ensureProviderStoreMigrated(); - // ── openclaw.json is the ONLY source of truth ── - // The provider list is derived entirely from openclaw.json. - // The electron-store is only used as a metadata cache (label, authMode, etc.). + // Provider accounts are the settings source of truth. openclaw.json is the + // generated runtime output, but we still import from it for older installs. const { providers: openClawProviders, defaultModel } = await getOpenClawProvidersConfig(); const activeProviders = await getActiveOpenClawProviders(); - if (activeProviders.size === 0) { - return []; - } + const rawStoreAccounts = await listProviderAccounts(); + const allStoreAccounts = rawStoreAccounts + .filter((account) => !isPlaceholderYinianModelAccount(account)); - // Read store accounts as a lookup cache (NOT as the source of what to display). - const allStoreAccounts = await listProviderAccounts(); - - // Index store accounts by their openclaw runtime key for fast lookup. const storeByKey = new Map(); for (const account of allStoreAccounts) { const ock = getOpenClawProviderKeyForType(account.vendorId, account.id); @@ -101,47 +131,59 @@ export class ProviderService { const result: ProviderAccount[] = []; const processedKeys = new Set(); - // For each active provider in openclaw.json, produce exactly ONE account. - for (const key of activeProviders) { + const storeKeys = Array.from(storeByKey.keys()); + for (const key of storeKeys) { if (processedKeys.has(key)) continue; processedKeys.add(key); const storeGroup = storeByKey.get(key) ?? []; + const aliasAccounts = storeGroup.filter((a) => a.vendorId !== key); + const candidates = aliasAccounts.length > 0 ? aliasAccounts : storeGroup; + candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + result.push(candidates[0]); - if (storeGroup.length > 0) { - // Pick the best store account for this key: - // 1. Prefer alias variants (e.g. minimax-portal-cn over minimax-portal) - // 2. Among equal variants, prefer the most recently updated - const aliasAccounts = storeGroup.filter((a) => a.vendorId !== key); - const candidates = aliasAccounts.length > 0 ? aliasAccounts : storeGroup; - candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - result.push(candidates[0]); - - // Clean up orphaned duplicates from the store. - const kept = candidates[0]; - for (const account of storeGroup) { - if (account.id !== kept.id) { - logger.info( - `[provider-sync] Removing orphaned account "${account.id}" for key "${key}" (keeping "${kept.id}")`, - ); - await deleteProviderAccount(account.id); - } - } - } else { - // No store account for this key — create a seed from openclaw.json. - const entry = openClawProviders[key]; - if (entry) { - const seeded = ProviderService.buildAccountsFromOpenClawEntries( - { [key]: entry }, - new Set(), - new Set(), - defaultModel, + const kept = candidates[0]; + for (const account of storeGroup) { + if (account.id !== kept.id) { + logger.info( + `[provider-sync] Removing orphaned account "${account.id}" for key "${key}" (keeping "${kept.id}")`, ); - for (const account of seeded) { - await saveProviderAccount(account); - result.push(account); - logger.info(`[provider-sync] Seeded provider account "${account.id}" from openclaw.json`); - } + await deleteProviderAccount(account.id); + } + } + } + + const existingIds = new Set(result.map((account) => account.id)); + const existingVendorIds = new Set(result.map((account) => account.vendorId)); + + for (const key of activeProviders) { + if (processedKeys.has(key)) continue; + const entry = openClawProviders[key]; + if (!entry || isPlaceholderYinianModelEntry(key, entry, defaultModel)) { + continue; + } + + const seeded = ProviderService.buildAccountsFromOpenClawEntries( + { [key]: entry }, + existingIds, + existingVendorIds, + defaultModel, + ); + for (const account of seeded) { + await saveProviderAccount(account); + result.push(account); + existingIds.add(account.id); + existingVendorIds.add(account.vendorId); + logger.info(`[provider-sync] Seeded provider account "${account.id}" from openclaw.json`); + } + } + + for (const account of rawStoreAccounts) { + if (isPlaceholderYinianModelAccount(account)) { + try { + await deleteProviderAccount(account.id); + } catch (err) { + logger.warn(`[provider-sync] Failed to remove placeholder model account "${account.id}":`, err); } } } diff --git a/electron/shared/providers/registry.ts b/electron/shared/providers/registry.ts index 39bcd03..7bec955 100644 --- a/electron/shared/providers/registry.ts +++ b/electron/shared/providers/registry.ts @@ -5,6 +5,8 @@ import type { ProviderTypeInfo, } from './types'; +const MINIMAX_DEFAULT_MODEL_ID = 'MiniMax-M3'; + export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ { id: 'anthropic', @@ -231,10 +233,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ requiresApiKey: false, isOAuth: true, supportsApiKey: true, - defaultModelId: 'MiniMax-M2.7', + defaultModelId: MINIMAX_DEFAULT_MODEL_ID, showModelId: true, - showModelIdInDevModeOnly: true, - modelIdPlaceholder: 'MiniMax-M2.7', + modelIdPlaceholder: MINIMAX_DEFAULT_MODEL_ID, apiKeyUrl: 'https://platform.minimax.io', category: 'official', envVar: 'MINIMAX_API_KEY', @@ -256,10 +257,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ requiresApiKey: false, isOAuth: true, supportsApiKey: true, - defaultModelId: 'MiniMax-M2.7', + defaultModelId: MINIMAX_DEFAULT_MODEL_ID, showModelId: true, - showModelIdInDevModeOnly: true, - modelIdPlaceholder: 'MiniMax-M2.7', + modelIdPlaceholder: MINIMAX_DEFAULT_MODEL_ID, apiKeyUrl: 'https://platform.minimaxi.com/', category: 'official', envVar: 'MINIMAX_CN_API_KEY', diff --git a/electron/utils/agent-system-documents.ts b/electron/utils/agent-system-documents.ts new file mode 100644 index 0000000..bdcb7c8 --- /dev/null +++ b/electron/utils/agent-system-documents.ts @@ -0,0 +1,249 @@ +import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { join } from 'node:path'; +import { listAgentsSnapshot, type AgentSummary } from './agent-config'; +import { expandPath, getOpenClawDir } from './paths'; + +export const AGENT_SYSTEM_DOCUMENTS = [ + { + kind: 'soul', + fileName: 'SOUL.md', + }, + { + kind: 'identity', + fileName: 'IDENTITY.md', + }, + { + kind: 'user', + fileName: 'USER.md', + }, + { + kind: 'agent', + fileName: 'AGENTS.md', + }, + { + kind: 'tool', + fileName: 'TOOLS.md', + }, + { + kind: 'heartbeat', + fileName: 'HEARTBEAT.md', + }, + { + kind: 'boot', + fileName: 'BOOT.md', + }, +] as const; + +export type AgentSystemDocumentKind = typeof AGENT_SYSTEM_DOCUMENTS[number]['kind']; +export type AgentSystemDocumentSource = 'workspace' | 'template' | 'empty'; + +export interface AgentSystemDocumentAgent { + id: string; + name: string; + isDefault: boolean; + workspace: string; +} + +export interface AgentSystemDocument { + kind: AgentSystemDocumentKind; + fileName: string; + path: string; + exists: boolean; + source: AgentSystemDocumentSource; + content: string; + size: number; + updatedAt: number | null; + templateAvailable: boolean; + templatePath: string; +} + +export interface AgentSystemDocumentsSnapshot { + success: true; + selectedAgentId: string; + defaultAgentId: string; + agents: AgentSystemDocumentAgent[]; + documents: AgentSystemDocument[]; + paths: { + workspace: string; + templateDir: string; + }; +} + +const MAX_DOCUMENT_BYTES = 512 * 1024; + +function getDescriptor(kind: string): typeof AGENT_SYSTEM_DOCUMENTS[number] | null { + return AGENT_SYSTEM_DOCUMENTS.find((document) => document.kind === kind) ?? null; +} + +export function isAgentSystemDocumentKind(kind: string): kind is AgentSystemDocumentKind { + return getDescriptor(kind) !== null; +} + +async function fileExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +function resolveTemplateDir(): string { + return join(getOpenClawDir(), 'docs', 'reference', 'templates'); +} + +function resolveTemplatePath(fileName: string): string { + return join(resolveTemplateDir(), fileName); +} + +function toDocumentAgent(agent: AgentSummary): AgentSystemDocumentAgent { + return { + id: agent.id, + name: agent.name, + isDefault: agent.isDefault, + workspace: agent.workspace, + }; +} + +async function resolveAgent(agentId?: string): Promise<{ + selected: AgentSystemDocumentAgent; + defaultAgentId: string; + agents: AgentSystemDocumentAgent[]; +}> { + const snapshot = await listAgentsSnapshot(); + const agents = snapshot.agents.map(toDocumentAgent); + if (agents.length === 0) { + throw new Error('No OpenClaw agents are configured'); + } + + const requestedAgentId = typeof agentId === 'string' ? agentId.trim() : ''; + const selected = requestedAgentId + ? agents.find((agent) => agent.id === requestedAgentId) + : agents.find((agent) => agent.id === snapshot.defaultAgentId) ?? agents[0]; + + if (!selected) { + throw new Error(`Agent "${requestedAgentId}" not found`); + } + + return { + selected, + defaultAgentId: snapshot.defaultAgentId, + agents, + }; +} + +async function readTemplate(fileName: string): Promise { + const templatePath = resolveTemplatePath(fileName); + if (!(await fileExists(templatePath))) return null; + return readFile(templatePath, 'utf8'); +} + +async function readDocument(agent: AgentSystemDocumentAgent, kind: AgentSystemDocumentKind): Promise { + const descriptor = getDescriptor(kind); + if (!descriptor) { + throw new Error(`Unsupported system document kind "${kind}"`); + } + + const workspace = expandPath(agent.workspace); + const documentPath = join(workspace, descriptor.fileName); + const templatePath = resolveTemplatePath(descriptor.fileName); + const template = await readTemplate(descriptor.fileName); + + if (await fileExists(documentPath)) { + const [content, stats] = await Promise.all([ + readFile(documentPath, 'utf8'), + stat(documentPath), + ]); + return { + kind, + fileName: descriptor.fileName, + path: documentPath, + exists: true, + source: 'workspace', + content, + size: stats.size, + updatedAt: stats.mtimeMs, + templateAvailable: template !== null, + templatePath, + }; + } + + const content = template ?? ''; + return { + kind, + fileName: descriptor.fileName, + path: documentPath, + exists: false, + source: template === null ? 'empty' : 'template', + content, + size: 0, + updatedAt: null, + templateAvailable: template !== null, + templatePath, + }; +} + +export async function readAgentSystemDocuments(agentId?: string): Promise { + const { selected, defaultAgentId, agents } = await resolveAgent(agentId); + const documents = await Promise.all( + AGENT_SYSTEM_DOCUMENTS.map((document) => readDocument(selected, document.kind)), + ); + return { + success: true, + selectedAgentId: selected.id, + defaultAgentId, + agents, + documents, + paths: { + workspace: expandPath(selected.workspace), + templateDir: resolveTemplateDir(), + }, + }; +} + +export async function saveAgentSystemDocument( + agentId: string | undefined, + kind: AgentSystemDocumentKind, + content: string, +): Promise { + if (typeof content !== 'string') { + throw new Error('Document content must be a string'); + } + const byteLength = Buffer.byteLength(content, 'utf8'); + if (byteLength > MAX_DOCUMENT_BYTES) { + throw new Error(`Document is too large (${byteLength} bytes, max ${MAX_DOCUMENT_BYTES})`); + } + + const { selected } = await resolveAgent(agentId); + const descriptor = getDescriptor(kind); + if (!descriptor) { + throw new Error(`Unsupported system document kind "${kind}"`); + } + + const workspace = expandPath(selected.workspace); + await mkdir(workspace, { recursive: true }); + await writeFile(join(workspace, descriptor.fileName), content, 'utf8'); + return readAgentSystemDocuments(selected.id); +} + +export async function resetAgentSystemDocument( + agentId: string | undefined, + kind: AgentSystemDocumentKind, +): Promise { + const { selected } = await resolveAgent(agentId); + const descriptor = getDescriptor(kind); + if (!descriptor) { + throw new Error(`Unsupported system document kind "${kind}"`); + } + + const template = await readTemplate(descriptor.fileName); + if (template === null) { + throw new Error(`Template for ${descriptor.fileName} is not available`); + } + + const workspace = expandPath(selected.workspace); + await mkdir(workspace, { recursive: true }); + await writeFile(join(workspace, descriptor.fileName), template, 'utf8'); + return readAgentSystemDocuments(selected.id); +} diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 42c7fa2..6ecbfb3 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -22,8 +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']; +const DISABLED_PLUGIN_CHANNEL_TYPES = new Set(['dingtalk', 'wecom']); +const DISABLED_PLUGIN_IDS = ['dingtalk', 'wecom']; // 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; diff --git a/electron/utils/model-diagnostics.ts b/electron/utils/model-diagnostics.ts index c8abd3d..3bfbede 100644 --- a/electron/utils/model-diagnostics.ts +++ b/electron/utils/model-diagnostics.ts @@ -5,19 +5,17 @@ import { dirname, join } from 'node:path'; import { getOpenClawConfigDir } from './paths'; import { readOpenClawConfig, writeOpenClawConfig } from './channel-config'; import { logger } from './logger'; +import { + YINIAN_LEGACY_MODEL_PROVIDER_KEYS, + YINIAN_MODEL_AUTH_PROFILE_ID, + YINIAN_MODEL_DEFAULT_BASE_URL, + YINIAN_MODEL_PROVIDER_KEY, +} from '../../shared/yinian-model'; -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'; +const YINIAN_INTERNAL_PROVIDER_KEYS = [ + YINIAN_MODEL_PROVIDER_KEY, + ...YINIAN_LEGACY_MODEL_PROVIDER_KEYS, +] as const; const YINIAN_FALLBACK_SKILL_IDS = ['docx', 'pdf', 'pptx', 'xlsx', 'design', 'image-search', 'web-search']; type JsonObject = Record; @@ -162,7 +160,10 @@ function splitModelRef(modelRef: string | null): { providerKey: string | null; m }; } -function groupAuthProfiles(store: AuthProfilesStore): YinianModelConfigDiagnostics['authProfiles']['providers'] { +function groupAuthProfiles( + store: AuthProfilesStore, + referencedProviderKeys?: Set, +): YinianModelConfigDiagnostics['authProfiles']['providers'] { const profiles = isPlainRecord(store.profiles) ? store.profiles : {}; const groups = new Map }>(); @@ -171,6 +172,7 @@ function groupAuthProfiles(store: AuthProfilesStore): YinianModelConfigDiagnosti const provider = typeof profile.provider === 'string' && profile.provider.trim() ? profile.provider : profileId.split(':')[0] || 'unknown'; + if (referencedProviderKeys && !referencedProviderKeys.has(provider)) continue; const type = typeof profile.type === 'string' && profile.type.trim() ? profile.type : 'unknown'; const group = groups.get(provider) ?? { ids: [], types: new Set() }; group.ids.push(profileId); @@ -205,15 +207,32 @@ function hasConfiguredProvider(config: JsonObject, providerKey: string | null): return isPlainRecord(providers[providerKey]); } -function buildProviderDiagnostics(config: JsonObject, primaryProviderKey: string | null): YinianModelConfigDiagnostics['providers'] { +function getProviderEntry(config: JsonObject, providerKey: string | null): JsonObject | null { + if (!providerKey) return null; 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 isPlainRecord(providers[providerKey]) ? providers[providerKey] : null; +} - return providerKeys.map((key) => { +function isPlaceholderModelApiProvider(provider: JsonObject | null): boolean { + if (!provider) return false; + return provider.baseUrl === YINIAN_MODEL_DEFAULT_BASE_URL; +} + +function collectModelProviderKeys(primary: string | null, fallbacks: string[]): string[] { + const providerKeys = [ + splitModelRef(primary).providerKey, + ...fallbacks.map((modelRef) => splitModelRef(modelRef).providerKey), + ].filter((providerKey): providerKey is string => typeof providerKey === 'string' && providerKey.trim().length > 0); + + return [...new Set(providerKeys)]; +} + +function buildProviderDiagnostics(config: JsonObject, referencedProviderKeys: string[]): YinianModelConfigDiagnostics['providers'] { + const models = isPlainRecord(config.models) ? config.models : {}; + const providers = isPlainRecord(models.providers) ? models.providers : {}; + + return referencedProviderKeys.map((key) => { const entry = isPlainRecord(providers[key]) ? providers[key] : {}; const modelsList = Array.isArray(entry.models) ? entry.models : []; return { @@ -246,47 +265,6 @@ export async function ensureYinianModelRuntimeConfigured(): Promise { changed = true; } - const currentYinianProvider = isPlainRecord(providers[YINIAN_MODEL_PROVIDER_KEY]) - ? { ...providers[YINIAN_MODEL_PROVIDER_KEY] } - : {}; - if ('timeoutSeconds' in currentYinianProvider) { - delete currentYinianProvider.timeoutSeconds; - changed = true; - } - 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, - 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; @@ -307,12 +285,26 @@ export async function ensureYinianModelRuntimeConfigured(): Promise { 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; + const currentPrimary = typeof defaultModel.primary === 'string' && defaultModel.primary.trim() + ? defaultModel.primary.trim() + : ''; + const { providerKey: currentPrimaryProvider } = splitModelRef(currentPrimary || null); + const currentYinianProvider = isPlainRecord(providers[YINIAN_MODEL_PROVIDER_KEY]) + ? providers[YINIAN_MODEL_PROVIDER_KEY] + : null; + const hasPlaceholderYinianProvider = isPlaceholderModelApiProvider(currentYinianProvider); + const shouldCleanYinianModelAuth = !currentYinianProvider || hasPlaceholderYinianProvider; + + if (hasPlaceholderYinianProvider) { + delete providers[YINIAN_MODEL_PROVIDER_KEY]; + if (currentPrimaryProvider === YINIAN_MODEL_PROVIDER_KEY) { + delete defaultModel.primary; + defaultModel.fallbacks = []; + } changed = true; } if (!Array.isArray(defaultModel.fallbacks)) { - defaultModel.fallbacks = ['minimax/MiniMax-M2.5']; + defaultModel.fallbacks = []; changed = true; } defaults.model = defaultModel; @@ -385,59 +377,46 @@ export async function ensureYinianModelRuntimeConfigured(): Promise { logger.info('[provider-sync] Applied Yinian model runtime defaults'); } - await ensureYinianModelAuthProfileAliases(); + if (shouldCleanYinianModelAuth) { + await removeYinianModelAuthProfileLeftovers(); + } } -export async function ensureYinianModelAuthProfileAliases(agentId = 'main'): Promise { +export async function removeYinianModelAuthProfileLeftovers(agentId = 'main'): Promise { 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, - }; + for (const [profileId, profile] of Object.entries(store.profiles)) { + const isYinianProfileId = profileId === YINIAN_MODEL_AUTH_PROFILE_ID + || profileId.startsWith(`${YINIAN_MODEL_PROVIDER_KEY}:`); + const isYinianProvider = isPlainRecord(profile) && profile.provider === YINIAN_MODEL_PROVIDER_KEY; + if (isYinianProfileId || isYinianProvider) { + delete store.profiles[profileId]; + changed = true; + } + } + + if (isPlainRecord(store.order) && YINIAN_MODEL_PROVIDER_KEY in store.order) { + const order = { ...store.order } as Record; + delete order[YINIAN_MODEL_PROVIDER_KEY]; + store.order = order; changed = true; } - if (isPlainRecord(store.profiles[YINIAN_MODEL_AUTH_TARGET_PROFILE_ID])) { - const order = isPlainRecord(store.order) ? { ...store.order } as Record : {}; - 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 : {}; - 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 (isPlainRecord(store.lastGood) && YINIAN_MODEL_PROVIDER_KEY in store.lastGood) { + const lastGood = { ...store.lastGood } as Record; + delete lastGood[YINIAN_MODEL_PROVIDER_KEY]; + store.lastGood = lastGood; + changed = true; } if (changed) { await writeJsonFile(authPath, store); - logger.info('[provider-sync] Normalized Yinian model auth profile aliases'); + logger.info('[provider-sync] Removed legacy Yinian model auth profile leftovers'); } } @@ -448,11 +427,15 @@ export async function buildYinianModelConfigDiagnostics(): Promise 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 getBundledNodeCommand(): string | undefined { - const binName = process.platform === 'win32' ? 'node.exe' : 'node'; - const candidate = app.isPackaged - ? join(process.resourcesPath, 'bin', binName) - : join(process.cwd(), 'resources', 'bin', `${process.platform}-${process.arch}`, binName); - return existsSync(candidate) ? candidate : undefined; -} - -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 { - 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 { - 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 { - 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 { - 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 { - 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 copyFileIfMissing(sourcePath: string, targetPath: string): void { - if (!existsSync(sourcePath) || existsSync(targetPath)) return; - mkdirSync(dirname(targetPath), { recursive: true }); - cpSync(sourcePath, targetPath, { dereference: true }); -} - -function readJsonFile(filePath: string): T | null { - try { - return JSON.parse(readFileSync(filePath, 'utf8')) as T; - } catch { - return null; - } -} - -function getArrayLength(record: Record | 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>(sourcePath); - const targetState = readJsonFile>(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 copyDirectoryMissing(sourceDir: string, targetDir: string): void { - if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) return; - mkdirSync(targetDir, { recursive: true }); - for (const entry of readdirSync(sourceDir)) { - const sourcePath = join(sourceDir, entry); - const targetPath = join(targetDir, entry); - if (statSync(sourcePath).isDirectory()) { - copyDirectoryMissing(sourcePath, targetPath); - continue; - } - copyFileIfMissing(sourcePath, targetPath); - } -} - -function migrateExistingRuntimeData(runtime: NianxxPlayRuntime, dirs: ReturnType): void { - try { - migrateStateFile(join(runtime.dir, '.data', 'app-state.json'), join(dirs.dataDir, 'app-state.json')); - copyDirectoryMissing(join(runtime.dir, 'public', 'uploads'), dirs.uploadDir); - copyDirectoryMissing(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 { - const runtimeEnvPath = join(runtime.dir, RUNTIME_ENV_FILE_NAME); - if (!existsSync(runtimeEnvPath)) return {}; - try { - const values: Record = {}; - 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.'); - } - const bundledNode = getBundledNodeCommand(); - const command = bundledNode ?? process.execPath; - logger.info(`[nianxx-play] Starting bundled service: ${runtime.serverPath} (port=${port}, runner=${bundledNode ? 'bundled-node' : 'electron-node'})`); - return spawn(command, [runtime.serverPath], { - cwd: runtime.dir, - env: { - ...createRuntimeEnv(port, runtime), - ...(bundledNode ? {} : { 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 { - const running = await canReachNianxxPlay(); - return createStatus({ - running, - error: running ? undefined : (lastServiceError ?? undefined), - }); -} - -export async function ensureNianxxPlayServiceStarted(): Promise { - 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; -} diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 2b5c7a6..af2b943 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -19,6 +19,11 @@ import { getProviderDefaultModel, getProviderConfig, } from './provider-registry'; +import { + YINIAN_LEGACY_MODEL_REFS, + YINIAN_MODEL_PROVIDER_KEY, + YINIAN_MODEL_REF, +} from '../../shared/yinian-model'; import { OPENCLAW_PROVIDER_KEY_MINIMAX, OPENCLAW_PROVIDER_KEY_MOONSHOT, @@ -33,7 +38,8 @@ 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_INTERNAL_MODEL_REF = YINIAN_MODEL_REF; +const YINIAN_LEGACY_INTERNAL_MODEL_REFS = new Set(YINIAN_LEGACY_MODEL_REFS); const YINIAN_FALLBACK_SKILL_IDS = ['docx', 'pdf', 'pptx', 'xlsx', 'design', 'image-search', 'web-search']; const YINIAN_SKILLS_LIMITS = { maxCandidatesPerRoot: 24, @@ -46,14 +52,13 @@ const YINIAN_WEB_TOOL_GUARD_ENV = 'YINIAN_ENABLE_OPENCLAW_WEB_TOOLS'; const YINIAN_WEB_TOOL_DENY = ['group:web', 'web_search', 'web_fetch', 'x_search']; const YINIAN_WEB_FETCH_TIMEOUT_SECONDS = 8; 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_BASE_PLUGIN_IDS = new Set(['cloud-sync']); const YINIAN_CORE_CHANNEL_IDS = new Set(['openclaw-weixin', 'agentbus']); -const YINIAN_DISABLED_CHANNEL_IDS = new Set(['feishu', 'dingtalk', 'wecom']); +const YINIAN_DISABLED_CHANNEL_IDS = new Set(['dingtalk', 'wecom']); interface BundledPluginManifest { id: string; @@ -1122,12 +1127,13 @@ function resolveYinianEnabledSkillIds(config: Record): string[] function isYinianManagedConfig(config: Record): 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; + if (providers && isPlainRecord(providers[YINIAN_MODEL_PROVIDER_KEY])) 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; + return model?.primary === YINIAN_INTERNAL_MODEL_REF + || (typeof model?.primary === 'string' && YINIAN_LEGACY_INTERNAL_MODEL_REFS.has(model.primary)); } function trimYinianPluginSurface(config: Record): boolean { diff --git a/electron/utils/paths.ts b/electron/utils/paths.ts index b81f96e..6f48ea2 100644 --- a/electron/utils/paths.ts +++ b/electron/utils/paths.ts @@ -37,9 +37,13 @@ const REQUIRED_OPENCLAW_CONTEXT_MODULES = [ const YINIAN_OPENCLAW_RUNTIME_PATCH_MARKER = '.yinian-runtime-patch.json'; const YINIAN_OPENCLAW_RUNTIME_PATCH_VERSION = '2026-05-12-runtime-templates-selfref-v1'; const REQUIRED_OPENCLAW_RUNTIME_TEMPLATE_FILES = [ + 'SOUL.md', + 'IDENTITY.md', + 'USER.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', + 'BOOT.md', ] as const; export { diff --git a/electron/utils/plugin-install.ts b/electron/utils/plugin-install.ts index 10e6240..eb0a381 100644 --- a/electron/utils/plugin-install.ts +++ b/electron/utils/plugin-install.ts @@ -129,13 +129,26 @@ export function fixupPluginManifest(targetDir: string): void { try { const raw = readFileSync(fsPath(manifestPath), 'utf-8'); const manifest = JSON.parse(raw); + let modified = false; const oldId = manifest.id as string | undefined; if (oldId && MANIFEST_ID_FIXES[oldId]) { const newId = MANIFEST_ID_FIXES[oldId]; manifest.id = newId; - writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + modified = true; logger.info(`[plugin] Fixed manifest ID: ${oldId} → ${newId}`); } + if (!manifest.channelConfigs && Array.isArray(manifest.channels)) { + const schema = { type: 'object' }; + manifest.channelConfigs = Object.fromEntries( + manifest.channels + .filter((channelId: unknown): channelId is string => typeof channelId === 'string' && channelId.trim().length > 0) + .map((channelId: string) => [channelId, { schema }]), + ); + modified = true; + } + if (modified) { + writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + } } catch { // manifest may not exist yet — ignore } @@ -166,6 +179,32 @@ export function fixupPluginManifest(targetDir: string): void { } } + const runtimeEntry = findExistingRuntimeEntry(targetDir, pkg); + if (runtimeEntry) { + if (typeof pkg.main !== 'string' || !runtimeEntryExists(targetDir, pkg.main)) { + pkg.main = toPackageEntry(runtimeEntry); + modified = true; + } + if (typeof pkg.module === 'string' && !runtimeEntryExists(targetDir, pkg.module)) { + pkg.module = toPackageEntry(runtimeEntry); + modified = true; + } + + const openclaw = pkg.openclaw as { extensions?: unknown } | undefined; + if (Array.isArray(openclaw?.extensions)) { + const patchedExtensions = openclaw.extensions.map((entry) => { + const normalized = normalizeRuntimeEntry(entry); + if (!normalized?.endsWith('.ts')) return entry; + const jsEntry = `dist/${normalized.replace(/\.ts$/i, '.js')}`; + return existsSync(fsPath(join(targetDir, jsEntry))) ? toPackageEntry(jsEntry) : entry; + }); + if (JSON.stringify(patchedExtensions) !== JSON.stringify(openclaw.extensions)) { + openclaw.extensions = patchedExtensions; + modified = true; + } + } + } + if (modified) { writeFileSync(fsPath(pkgPath), JSON.stringify(pkg, null, 2) + '\n', 'utf-8'); logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`); @@ -193,7 +232,9 @@ function patchPluginEntryIds(targetDir: string): void { return; } - const entryFiles = [pkg.main, pkg.module].filter(Boolean) as string[]; + const openclaw = pkg.openclaw as { extensions?: unknown } | undefined; + const extensionEntries = Array.isArray(openclaw?.extensions) ? openclaw.extensions : []; + const entryFiles = [...new Set([pkg.main, pkg.module, ...extensionEntries].filter(Boolean))] as string[]; for (const entry of entryFiles) { const entryPath = join(targetDir, entry); @@ -229,6 +270,7 @@ function patchPluginEntryIds(targetDir: string): void { const PLUGIN_NPM_NAMES: Record = { 'openclaw-weixin': '@tencent-weixin/openclaw-weixin', + 'openclaw-lark': '@larksuite/openclaw-lark', }; // ── Version helper ─────────────────────────────────────────────────────────── @@ -243,6 +285,54 @@ function readPluginVersion(pkgJsonPath: string): string | null { } } +function normalizeRuntimeEntry(entry: unknown): string | null { + if (typeof entry !== 'string') return null; + const trimmed = entry.trim(); + if (!trimmed || path.isAbsolute(trimmed)) return null; + return trimmed.replace(/^\.\//, ''); +} + +function toPackageEntry(entry: string): string { + return entry.startsWith('.') ? entry : `./${entry}`; +} + +function isJavaScriptRuntimeEntry(entry: string): boolean { + return /\.(?:cjs|mjs|js)$/i.test(entry); +} + +function runtimeEntryExists(pluginDir: string, entry: unknown): boolean { + const normalized = normalizeRuntimeEntry(entry); + return Boolean(normalized) && existsSync(fsPath(join(pluginDir, normalized!))); +} + +function collectRuntimeEntryHints(pkg: Record): string[] { + const hints: unknown[] = []; + const openclaw = pkg.openclaw as { extensions?: unknown } | undefined; + if (Array.isArray(openclaw?.extensions)) { + hints.push(...openclaw.extensions); + } + hints.push(pkg.main, pkg.module, './dist/index.js', './index.js'); + return [...new Set(hints.map(normalizeRuntimeEntry).filter((entry): entry is string => Boolean(entry)))]; +} + +function findExistingRuntimeEntry(pluginDir: string, pkg: Record): string | null { + for (const entry of collectRuntimeEntryHints(pkg)) { + if (isJavaScriptRuntimeEntry(entry) && existsSync(fsPath(join(pluginDir, entry)))) { + return entry; + } + } + return null; +} + +export function hasPluginRuntimeEntry(pluginDir: string): boolean { + try { + const pkg = JSON.parse(readFileSync(fsPath(join(pluginDir, 'package.json')), 'utf-8')) as Record; + return Boolean(findExistingRuntimeEntry(pluginDir, pkg)); + } catch { + return existsSync(fsPath(join(pluginDir, 'dist', 'index.js'))) || existsSync(fsPath(join(pluginDir, 'index.js'))); + } +} + // ── pnpm-aware node_modules copy helpers ───────────────────────────────────── /** Walk up from a path until we find a parent named node_modules. */ @@ -369,17 +459,33 @@ export function ensurePluginInstalled( if (!sourceDir) return { installed: true }; // no bundled source to compare, keep existing const installedVersion = readPluginVersion(targetPkgJson); const sourceVersion = readPluginVersion(join(sourceDir, 'package.json')); + const installedRuntimeReady = hasPluginRuntimeEntry(targetDir); + const sourceRuntimeReady = hasPluginRuntimeEntry(sourceDir); if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) { - return { installed: true }; // same version or unable to compare + if (!installedRuntimeReady && sourceRuntimeReady) { + logger.info( + `[plugin] Reinstalling ${pluginLabel} plugin: installed copy is missing a loadable runtime entry`, + ); + } else { + fixupPluginManifest(targetDir); + return { installed: true }; // same version or unable to compare + } + } else { + // Version differs — fall through to overwrite install + logger.info( + `[plugin] Upgrading ${pluginLabel} plugin: ${installedVersion} → ${sourceVersion}`, + ); } - // Version differs — fall through to overwrite install - logger.info( - `[plugin] Upgrading ${pluginLabel} plugin: ${installedVersion} → ${sourceVersion}`, - ); } // Fresh install or upgrade — try bundled/build sources first if (sourceDir) { + if (!hasPluginRuntimeEntry(sourceDir)) { + return { + installed: false, + warning: `Bundled ${pluginLabel} plugin mirror is missing a loadable runtime entry. Rebuild bundled plugins.`, + }; + } const extensionsRoot = join(homedir(), '.openclaw', 'extensions'); const attempts: Array<{ attempt: number; code?: string; name?: string; message: string }> = []; const maxAttempts = process.platform === 'win32' ? 2 : 1; @@ -393,6 +499,12 @@ export function ensurePluginInstalled( return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` }; } fixupPluginManifest(targetDir); + if (!hasPluginRuntimeEntry(targetDir)) { + return { + installed: false, + warning: `Installed ${pluginLabel} plugin mirror is missing a loadable runtime entry.`, + }; + } logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`); return { installed: true }; } catch (error) { @@ -431,7 +543,9 @@ export function ensurePluginInstalled( if (existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) { const installedVersion = existsSync(fsPath(targetPkgJson)) ? readPluginVersion(targetPkgJson) : null; const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json')); - if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion)) { + const installedRuntimeReady = existsSync(fsPath(targetManifest)) ? hasPluginRuntimeEntry(targetDir) : false; + const sourceRuntimeReady = hasPluginRuntimeEntry(npmPkgPath); + if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion || (!installedRuntimeReady && sourceRuntimeReady))) { logger.info( `[plugin] ${installedVersion ? 'Upgrading' : 'Installing'} ${pluginLabel} plugin` + `${installedVersion ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`, @@ -440,7 +554,7 @@ export function ensurePluginInstalled( mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true }); copyPluginFromNodeModules(npmPkgPath, targetDir, npmName); fixupPluginManifest(targetDir); - if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) { + if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json'))) && hasPluginRuntimeEntry(targetDir)) { return { installed: true }; } } catch (err) { @@ -458,6 +572,7 @@ export function ensurePluginInstalled( ); } } else if (existsSync(fsPath(targetManifest))) { + fixupPluginManifest(targetDir); return { installed: true }; // same version, already installed } } @@ -495,6 +610,10 @@ export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: s return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat'); } +export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } { + return ensurePluginInstalled('openclaw-lark', buildCandidateSources('openclaw-lark'), 'Feishu'); +} + export function ensureCloudSyncPluginInstalled(): { installed: boolean; warning?: string } { return ensurePluginInstalled('cloud-sync', buildCandidateSources('cloud-sync'), 'Cloud Sync'); } @@ -506,6 +625,7 @@ export function ensureCloudSyncPluginInstalled(): { installed: boolean; warning? */ const ALL_BUNDLED_PLUGINS = [ { fn: ensureWeChatPluginInstalled, label: 'WeChat' }, + { fn: ensureFeishuPluginInstalled, label: 'Feishu' }, { fn: ensureCloudSyncPluginInstalled, label: 'Cloud Sync' }, ] as const; diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index fc2b6fa..0133276 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -173,13 +173,17 @@ export async function getAllProviders(): Promise { await ensureProviderStoreMigrated(); const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; - const legacyProviders = Object.values(providers); - if (legacyProviders.length > 0) { - return legacyProviders; + const merged = new Map(); + + for (const provider of Object.values(providers)) { + merged.set(provider.id, provider); } - const accounts = await listProviderAccounts(); - return accounts.map(providerAccountToConfig); + for (const account of await listProviderAccounts()) { + merged.set(account.id, providerAccountToConfig(account)); + } + + return Array.from(merged.values()); } /** @@ -227,8 +231,8 @@ export async function setDefaultProvider(providerId: string): Promise { export async function getDefaultProvider(): Promise { await ensureProviderStoreMigrated(); const s = await getClawXProviderStore(); - return (s.get('defaultProvider') as string | undefined) - ?? (s.get('defaultProviderAccountId') as string | undefined); + return (s.get('defaultProviderAccountId') as string | undefined) + ?? (s.get('defaultProvider') as string | undefined); } /** diff --git a/electron/utils/yinian-initializer.ts b/electron/utils/yinian-initializer.ts index 9e7363a..2604d89 100644 --- a/electron/utils/yinian-initializer.ts +++ b/electron/utils/yinian-initializer.ts @@ -6,8 +6,27 @@ import { getAllSettings, setSetting } from './store'; import { getOpenClawConfigDir, reinstallManagedOpenClawRuntime } from './paths'; import { logger } from './logger'; import { ensureOfficeSkillRuntimeReady } from './office-skill-runtime'; +import { + YINIAN_MODEL_ENTRY, + YINIAN_MODEL_PROVIDER_KEY, +} from '../../shared/yinian-model'; type JsonObject = Record; +type InternalModelAuthSeedResult = + | { status: 'seeded'; path: string; config: ModelRuntimeConfig } + | { status: 'skipped'; path?: string; reason: string; modelRef?: string }; + +interface ModelRuntimeConfig { + providerKey: string; + modelId: string; + modelName: string; + modelRef: string; + baseUrl: string; + api: string; + authHeader?: boolean; + fallbackModelRefs: string[]; + authProfileId: string; +} export type YinianInitializationStepStatus = 'pending' | 'running' | 'success' | 'error'; @@ -26,18 +45,18 @@ export interface YinianInitializationStatus { steps: YinianInitializationStep[]; } -const INTERNAL_PROVIDER_KEY = 'minimax'; -const INTERNAL_MODEL_ID = 'MiniMax-M2.7'; -const INTERNAL_MODEL_REF = `${INTERNAL_PROVIDER_KEY}/${INTERNAL_MODEL_ID}`; -const INTERNAL_AUTH_PROFILE_ID = 'minimax:default'; const DESKTOP_TOOLS_PROFILE = 'coding'; const YINIAN_FALLBACK_SKILL_IDS = ['docx', 'pdf', 'pptx', 'xlsx', 'design', 'image-search', 'web-search']; const REQUIRED_RUNTIME_FILES = [ 'package.json', 'openclaw.mjs', + join('docs', 'reference', 'templates', 'SOUL.md'), + join('docs', 'reference', 'templates', 'IDENTITY.md'), + join('docs', 'reference', 'templates', 'USER.md'), join('docs', 'reference', 'templates', 'AGENTS.md'), join('docs', 'reference', 'templates', 'TOOLS.md'), join('docs', 'reference', 'templates', 'HEARTBEAT.md'), + join('docs', 'reference', 'templates', 'BOOT.md'), join('node_modules', 'openclaw', 'package.json'), ] as const; let initializationInFlight: Promise | null = null; @@ -45,7 +64,7 @@ let initializationInFlight: Promise | null = null; const DEFAULT_STEPS: YinianInitializationStep[] = [ { id: 'runtime', label: '安装运行环境', status: 'pending' }, { id: 'workspace', label: '准备本地工作区', status: 'pending' }, - { id: 'model', label: '写入内测模型配置', status: 'pending' }, + { id: 'model', label: '准备模型 API 配置', status: 'pending' }, { id: 'python', label: '准备文档处理环境', status: 'pending' }, ]; @@ -54,8 +73,8 @@ export async function getYinianInitializationStatus(): Promise { if (step.id === 'runtime') { return { @@ -97,12 +115,10 @@ export async function getYinianInitializationStatus(): Promise { await mkdir(join(openclawDir, 'agents', 'main', 'agent'), { recursive: true }); } -async function seedInternalModelConfig(): Promise { +async function seedModelApiConfiguration(): Promise { + const bundledAuthPath = resolveBundledModelAuthPath(); + if (!bundledAuthPath) { + logger.warn('[yinian-init] Model API auth bundle was not found'); + const modelRef = await ensureBaseOpenClawConfig(); + return { + status: 'skipped', + reason: '安装包缺少模型 API 凭据资源', + modelRef, + }; + } + + const bundled = await readJsonFile(bundledAuthPath); + if (bundled.bundled !== true) { + const reason = typeof bundled.reason === 'string' && bundled.reason.trim() + ? bundled.reason.trim() + : '构建时未启用模型 API 凭据打包'; + logger.warn('[yinian-init] Model API auth bundle is disabled', { + bundledAuthPath, + reason, + }); + const modelRef = await ensureBaseOpenClawConfig(); + return { + status: 'skipped', + path: bundledAuthPath, + reason, + modelRef, + }; + } + + let runtimeConfig: ModelRuntimeConfig; + try { + runtimeConfig = resolveModelRuntimeConfig(bundled); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.warn('[yinian-init] Model API runtime config bundle is incomplete; skipping bundled model seed', { + bundledAuthPath, + reason, + }); + const modelRef = await ensureBaseOpenClawConfig(); + return { + status: 'skipped', + path: bundledAuthPath, + reason, + modelRef, + }; + } + + const hasBundledAuth = hasUsableBundledModelAuthProfile(bundled, runtimeConfig); + const hasExistingAuth = await hasYinianModelAuthProfile(runtimeConfig.providerKey); + if (!hasBundledAuth && !hasExistingAuth) { + logger.warn('[yinian-init] Model API auth bundle is incomplete; skipping bundled model seed', { bundledAuthPath }); + const modelRef = await ensureBaseOpenClawConfig(); + return { + status: 'skipped', + path: bundledAuthPath, + reason: '模型 API 凭据资源不完整', + modelRef, + }; + } + + await seedModelApiConfig(runtimeConfig); + if (hasBundledAuth) { + await seedModelApiAuthProfiles(bundledAuthPath, bundled, runtimeConfig); + } + + return { + status: 'seeded', + path: bundledAuthPath, + config: runtimeConfig, + }; +} + +async function seedModelApiConfig(runtimeConfig: ModelRuntimeConfig): Promise { + await writeBaseOpenClawConfig(runtimeConfig); +} + +async function ensureBaseOpenClawConfig(): Promise { + return writeBaseOpenClawConfig(); +} + +async function writeBaseOpenClawConfig(runtimeConfig?: ModelRuntimeConfig): Promise { const configDir = getOpenClawConfigDir(); const configPath = join(configDir, 'openclaw.json'); await mkdir(configDir, { recursive: true }); @@ -231,21 +328,20 @@ async function seedInternalModelConfig(): Promise { const config = await readJsonFile(configPath); const models = asObject(config.models); const providers = asObject(models.providers); - providers[INTERNAL_PROVIDER_KEY] = { - baseUrl: 'https://api.minimaxi.com/anthropic', - api: 'anthropic-messages', - authHeader: true, - models: [ - { - id: INTERNAL_MODEL_ID, - name: 'MiniMax M2.7', - reasoning: true, - input: ['text', 'image'], - contextWindow: 204800, - maxTokens: 131072, - }, - ], - }; + if (runtimeConfig) { + providers[runtimeConfig.providerKey] = { + baseUrl: runtimeConfig.baseUrl, + api: runtimeConfig.api, + ...(typeof runtimeConfig.authHeader === 'boolean' ? { authHeader: runtimeConfig.authHeader } : {}), + models: [ + { + ...YINIAN_MODEL_ENTRY, + id: runtimeConfig.modelId, + name: runtimeConfig.modelName, + }, + ], + }; + } models.mode = 'merge'; models.providers = providers; delete models.pricing; @@ -261,10 +357,12 @@ async function seedInternalModelConfig(): Promise { const agents = asObject(config.agents); const defaults = asObject(agents.defaults); const enabledSkillIds = resolveYinianEnabledSkillIds(config); - defaults.model = { - primary: INTERNAL_MODEL_REF, - fallbacks: ['minimax/MiniMax-M2.5'], - }; + if (runtimeConfig) { + defaults.model = { + primary: runtimeConfig.modelRef, + fallbacks: [...runtimeConfig.fallbackModelRefs], + }; + } defaults.workspace = join(homedir(), '.openclaw', 'workspace'); defaults.skills = enabledSkillIds; defaults.heartbeat = { @@ -304,20 +402,23 @@ async function seedInternalModelConfig(): Promise { config.agents = agents; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + return getNonEmptyString(asObject(defaults.model).primary); } -async function seedInternalModelAuthProfiles(): Promise { - const bundledAuthPath = resolveBundledModelAuthPath(); - if (!bundledAuthPath) return; - - const bundled = await readJsonFile(bundledAuthPath); - if (bundled.bundled !== true) return; - +async function seedModelApiAuthProfiles( + bundledAuthPath: string, + bundled: JsonObject, + runtimeConfig: ModelRuntimeConfig, +): Promise { const bundledStore = asObject(bundled.store); const bundledProfiles = asObject(bundledStore.profiles); - const bundledDefault = asObject(bundledProfiles[INTERNAL_AUTH_PROFILE_ID]); - if (!isUsableMinimaxAuthProfile(bundledDefault)) { - throw new Error('内测模型凭据资源不完整'); + const bundledDefault = asObject(bundledProfiles[runtimeConfig.authProfileId]); + const fallbackBundledDefault = Object.values(bundledProfiles) + .map(asObject) + .find((profile) => isUsableModelApiKeyProfile(profile)); + if (!isUsableModelApiKeyProfile(bundledDefault) && !fallbackBundledDefault) { + logger.warn('[yinian-init] Model API auth bundle is incomplete', { bundledAuthPath }); + throw new Error('模型 API 凭据资源不完整'); } const authProfilesPath = join(getOpenClawConfigDir(), 'agents', 'main', 'agent', 'auth-profiles.json'); @@ -329,11 +430,16 @@ async function seedInternalModelAuthProfiles(): Promise { for (const [profileId, profile] of Object.entries(bundledProfiles)) { const bundledProfile = asObject(profile); - if (!isUsableMinimaxAuthProfile(bundledProfile)) continue; - if (!isUsableMinimaxAuthProfile(asObject(profiles[profileId]))) { - profiles[profileId] = { + if (!isUsableModelApiKeyProfile(bundledProfile)) continue; + const targetProfileId = profileId === runtimeConfig.authProfileId + ? runtimeConfig.authProfileId + : profileId.startsWith(`${runtimeConfig.providerKey}:`) + ? profileId + : runtimeConfig.authProfileId; + if (!isUsableModelApiKeyProfile(asObject(profiles[targetProfileId]), runtimeConfig.providerKey)) { + profiles[targetProfileId] = { type: 'api_key', - provider: INTERNAL_PROVIDER_KEY, + provider: runtimeConfig.providerKey, key: bundledProfile.key, }; changed = true; @@ -341,24 +447,26 @@ async function seedInternalModelAuthProfiles(): Promise { } const order = asObject(current.order) as Record; - const minimaxOrder = Array.isArray(order[INTERNAL_PROVIDER_KEY]) - ? (order[INTERNAL_PROVIDER_KEY] as unknown[]).filter((value): value is string => typeof value === 'string') + const currentOrder = Array.isArray(order[runtimeConfig.providerKey]) + ? (order[runtimeConfig.providerKey] as unknown[]).filter((value): value is string => typeof value === 'string') : []; - if (!minimaxOrder.includes(INTERNAL_AUTH_PROFILE_ID)) { - order[INTERNAL_PROVIDER_KEY] = [ - INTERNAL_AUTH_PROFILE_ID, - ...minimaxOrder.filter((profileId) => profileId !== INTERNAL_AUTH_PROFILE_ID), + if (!currentOrder.includes(runtimeConfig.authProfileId)) { + order[runtimeConfig.providerKey] = [ + runtimeConfig.authProfileId, + ...currentOrder.filter((profileId) => profileId !== runtimeConfig.authProfileId), ]; changed = true; } const lastGood = asObject(current.lastGood) as Record; - if (lastGood[INTERNAL_PROVIDER_KEY] !== INTERNAL_AUTH_PROFILE_ID) { - lastGood[INTERNAL_PROVIDER_KEY] = INTERNAL_AUTH_PROFILE_ID; + if (lastGood[runtimeConfig.providerKey] !== runtimeConfig.authProfileId) { + lastGood[runtimeConfig.providerKey] = runtimeConfig.authProfileId; changed = true; } - if (!changed) return; + if (!changed) { + return; + } current.version = typeof current.version === 'number' ? current.version : 1; current.profiles = profiles; current.order = order; @@ -377,16 +485,89 @@ function resolveBundledModelAuthPath(): string | undefined { return candidates.find((candidate) => existsSync(candidate)); } -async function hasYinianModelAuthProfile(): Promise { +function hasUsableBundledModelAuthProfile(bundle: JsonObject, runtimeConfig: ModelRuntimeConfig): boolean { + const bundledStore = asObject(bundle.store); + const bundledProfiles = asObject(bundledStore.profiles); + const bundledDefault = asObject(bundledProfiles[runtimeConfig.authProfileId]); + if (isUsableModelApiKeyProfile(bundledDefault)) { + return true; + } + + return Object.values(bundledProfiles) + .map(asObject) + .some((profile) => isUsableModelApiKeyProfile(profile)); +} + +function resolveModelRuntimeConfig(bundle: JsonObject): ModelRuntimeConfig { + const model = asObject(bundle.model); + const providerKey = getNonEmptyString(model.providerKey); + const modelId = getNonEmptyString(model.modelId); + const baseUrl = getNonEmptyString(model.baseUrl); + const api = getNonEmptyString(model.api); + const missingFields = [ + !providerKey ? 'model.providerKey' : '', + !modelId ? 'model.modelId' : '', + !baseUrl ? 'model.baseUrl' : '', + !api ? 'model.api' : '', + ].filter(Boolean); + if (missingFields.length > 0) { + throw new Error(`模型 API 配置资源不完整:缺少 ${missingFields.join(', ')}`); + } + const modelName = getNonEmptyString(model.modelName) || getNonEmptyString(model.name) || modelId; + const authProfileId = getNonEmptyString(model.authProfileId) || `${providerKey}:default`; + const fallbackModelRefs = readModelRefList(model.fallbackModelRefs) + .concat(readModelRefList(model.fallbacks)) + .filter((value, index, list) => list.indexOf(value) === index); + + return { + providerKey, + modelId, + modelName, + modelRef: `${providerKey}/${modelId}`, + baseUrl, + api, + authHeader: typeof model.authHeader === 'boolean' ? model.authHeader : undefined, + fallbackModelRefs, + authProfileId, + }; +} + +function getNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function readModelRefList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => typeof item === 'string' ? item.trim() : '') + .filter(Boolean); +} + +async function getConfiguredPrimaryModelRef(): Promise { + const configPath = join(getOpenClawConfigDir(), 'openclaw.json'); + const config = await readJsonFile(configPath); + const agents = asObject(config.agents); + const defaults = asObject(agents.defaults); + const model = asObject(defaults.model); + return getNonEmptyString(model.primary); +} + +function splitProviderKey(modelRef: string): string | undefined { + const separatorIndex = modelRef.indexOf('/'); + if (separatorIndex <= 0) return undefined; + return modelRef.slice(0, separatorIndex); +} + +async function hasYinianModelAuthProfile(providerKey = YINIAN_MODEL_PROVIDER_KEY): Promise { const authProfilesPath = join(getOpenClawConfigDir(), 'agents', 'main', 'agent', 'auth-profiles.json'); const store = await readJsonFile(authProfilesPath); const profiles = asObject(store.profiles); - return Object.values(profiles).some((profile) => isUsableMinimaxAuthProfile(asObject(profile))); + return Object.values(profiles).some((profile) => isUsableModelApiKeyProfile(asObject(profile), providerKey)); } -function isUsableMinimaxAuthProfile(profile: JsonObject): boolean { +function isUsableModelApiKeyProfile(profile: JsonObject, providerKey?: string): boolean { return profile.type === 'api_key' - && profile.provider === INTERNAL_PROVIDER_KEY + && (!providerKey || profile.provider === providerKey) && typeof profile.key === 'string' && profile.key.trim().length >= 8; } diff --git a/findings.md b/findings.md index d8965bb..7fe0449 100644 --- a/findings.md +++ b/findings.md @@ -1,5 +1,22 @@ # Findings +## Agent 系统级文档管理 2026-06-03 + +- OpenClaw 的系统级 workspace 文档包括 `AGENTS.md`、`SOUL.md`、`TOOLS.md`;官方文档定义为操作指令、人格/边界/语气、本地工具约定。 +- 这些文件按 Agent workspace 生效,路径来自 `openclaw.json` 的 `agents.defaults.workspace` 或 `agents.list[].workspace`,默认是 `~/.openclaw/workspace`。 +- 当前项目已有 `listAgentsSnapshot()` 可给设置页列出 Agent、workspace 和默认 Agent,无需新增配置解析逻辑。 +- 默认模板可从当前 OpenClaw runtime 的 `docs/reference/templates/{AGENTS,SOUL,TOOLS}.md` 读取;这能兼容开发态 `node_modules/openclaw`、打包态 bundled runtime 和 managed runtime。 +- Host API 已是设置页访问本地能力的统一入口,适合新增 `/api/agent-system-documents` 路由。 + +## Customer Install Initialization Failure Findings 2026-05-14 + +- The customer screenshot matches a non-pilot package: local `build/yinian-internal/model-auth-profiles.json` currently says `bundled=false` because `pnpm run package` was used without `YINIAN_BUNDLE_MODEL_AUTH=1`. +- First-run setup currently requires a usable `minimax` auth profile before it proceeds to the document runtime step, so a non-pilot package fails at `写入内测模型配置`. +- The renderer then maps every non-completed step to `failed` when `initialized=false`; this makes the later `准备文档处理环境` step look failed even when it was only still pending and never executed. +- The customer-facing error currently says only `初始化未完成,请查看失败项后重试。`, which hides the actionable package/credential cause. +- Fix applied: pending setup steps remain pending on early failure, failed-step details are shown in the error panel, and non-pilot packages now report that the installer does not contain internal model credentials. +- The rebuilt Apple Silicon pilot DMG now contains `bundled=true` internal model auth material and passed resource, codesign, and DMG verification. + ## macOS DMG 打包 - 使用 `pnpm run package:mac` 才会走完整正式打包链路,包括 `bundle-preinstalled-skills.mjs`、OpenClaw bundle、插件 bundle、内置应用 bundle 和 Electron Builder。 @@ -681,6 +698,16 @@ - The template catalog currently provides 32 templates. OpenClaw sees both `design` and `html-slides` as eligible and enabled after local app-managed sync. - Preinstalled local sync must preserve `.clawx-preinstalled.json` markers so future app startup updates these skills instead of treating them as user-managed. +## Channel Plugin Packaging Findings 2026-05-14 + +- 客户侧微信渠道失败的根因成立:`@tencent-weixin/openclaw-weixin@2.1.10` 发布包声明 `openclaw.extensions: ["./index.ts"]`,并只包含 `index.ts/src/**/*.ts`,没有 JS 运行产物。 +- 我们之前的 bundled plugin mirror 原样复制了 TS 源码包,所以客户目录里即使版本号正确,也会因为缺少可加载 JS 入口被 Gateway 忽略。 +- OpenClaw 插件扫描可以加载转译后的微信插件:`dist/index.js` + `openclaw.extensions=["./dist/index.js"]`。 +- 官方飞书插件已发布在 npm:`@larksuite/openclaw-lark@2026.5.13`,manifest id 为 `openclaw-lark`,channel id 为 `feishu`。 +- 官方飞书包实际可用入口是根目录 `index.js`;其 `package.json#main` 指向不存在的 `./dist/index.js`,打包/安装时需要修正 entry hint。 +- OpenClaw 当前 bundled extensions 中没有内置 `feishu` 目录;我们之前代码把 `feishu` 明文放在禁用集合,导致 UI/API 直接返回“当前内测版本未启用该渠道”。 +- 临时 HOME 的 OpenClaw 插件扫描已验证:`openclaw-weixin` 与 `openclaw-lark` 都能从 `.openclaw/extensions` 加载,状态为 `loaded`,无缺失依赖和插件诊断。 + ## Task And Skill Management Findings 2026-05-13 - Current `src/pages/Tasks/index.tsx` duplicates quick-task creation/editing under the Task Center, which makes "任务" ambiguous versus ability-pack-owned quick tasks. @@ -761,3 +788,25 @@ - 2026.5.7 的 diagnostic heartbeat 已新增 stalled embedded run 的 `allowActiveAbort: true` 分支;YINIAN 仍需要在普通 stuck recovery 分支上保留 `YINIAN_OPENCLAW_STUCK_ACTIVE_ABORT_MS` 阈值,避免过早主动中止活跃 run。 - 2026.5.7 的嵌入式 run 已有 `resolveEmbeddedAttemptToolConstructionPlan`,`isRawModelRun` 会阻止构建工具、bundle MCP 和 bundle LSP;这覆盖了旧版 fast chat disable-tools 补丁的主要目的。 - 2026.5.7 的子进程执行入口已迁移到 `exec-*`/Windows helper,并在关键 spawn 位置包含 `windowsHide`;旧的 workspace command runner 与 PTY 补丁片段不再匹配,但已核对新版产物中的隐藏窗口逻辑。 + +## Model Provider Runtime Findings 2026-06-03 + +- 用户将默认模型换成 DeepSeek 后,`clawx-providers.json` 的 `defaultProvider/defaultProviderAccountId` 已经是 `deepseek-a1e23f39-4296-4762-8bdf-184ca677cce6`,账号模型为 `deepseek-v4-pro`。 +- 但 `~/.openclaw/openclaw.json` 仍然是 `agents.defaults.model.primary = minimax-portal/MiniMax-M3`,`models.providers` 还包含旧 `yinian-model/custom-model` 占位 provider。 +- 这说明当前设计仍有配置源漂移:设置 store 是 DeepSeek,OpenClaw runtime 输出仍是 MiniMax/旧占位。必须让 provider account store 成为唯一配置源,并在保存/启动/诊断修复时同步到 runtime。 +- 直接 Gateway reload 不足以应用模型切换;reload/reconnect 会用 Gateway 进程内的旧 config 覆盖刚写入的 runtime 文件。模型 provider 保存、更新和默认切换必须触发 Gateway restart。 +- `model-diagnostics` 不能再承担“补一个默认 MiniMax 模型 provider”的职责;否则启动修复会和设置中的自定义模型 API 互相打架。它现在只做结构清理、心跳关闭、技能默认值和旧占位 provider/auth 清除。 +- `defaultProviderAccountId` 是当前 provider account store 的真实默认指针;legacy `defaultProvider` 只能作为兼容回退。 +- 本机最终 runtime 验证:默认模型为 `deepseek/deepseek-v4-pro`,`models.providers.deepseek` 指向 `https://api.deepseek.com/v1`,`yinian-model` provider/auth 残留已删除。`minimax-portal` 条目仍可存在,但只是已配置账号,不是默认模型。 +- 设置页仍显示 `yinian-model` / MiniMax 缺失的根因是 diagnostics 列表生成函数无条件合并 `YINIAN_INTERNAL_PROVIDER_KEYS`,即使当前默认 provider 是 DeepSeek,也会把历史内部 provider 当作检查对象输出。 +- 模型配置诊断应回答“当前默认模型引用的 provider 是否完整”,而不是扫描或固定展示历史内置 provider。provider 与 auth profile 的设置页诊断列表现在都按 `primary + fallbacks` 的 provider refs 过滤。 + +## App Center Cleanup Audit Findings 2026-06-04 + +- Production-path residual scan across `src`, `electron`, `tests`, `package.json`, `electron-builder.yml`, `scripts`, `README.md`, `vite.config.ts`, and `resources/readme` found no remaining NianxxPlay/Product Center app integrations. +- The only remaining `product-center` / `nianxx-play` matches are negative assertions in `tests/unit/app-center.test.tsx` and `tests/e2e/yinian-delivery-smoke.spec.ts`, which intentionally guard against the two deleted app items returning. +- `rg --files` found no tracked files matching `NianxxPlay`, `ProductCenter`, `product-center`, `nianxx`, the removed readme screenshots, deleted `electron/api/routes/apps.ts`, or deleted `scripts/prepare-nianxx-play-bundle.mjs`. +- `build/apps/nianxx-play` is gone; only the empty `build/apps` directory remains. +- The dirty worktree contains many pre-existing model/provider/system-document/channel-plugin changes. App Center cleanup should be reviewed as a slice, not as the whole diff. +- `selectedItemId/selectItem` in `src/stores/app-center.ts` had no production consumer after the built-in apps were removed; it was safe to delete with the stale test assertion. +- README screenshot section had an empty App Center heading after removing the two obsolete screenshot images; removing the empty heading keeps docs aligned with the new app-center state. diff --git a/package.json b/package.json index c28f17a..6010b0f 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,10 @@ "predev": "node scripts/generate-ext-bridge.mjs && zx scripts/prepare-preinstalled-skills-dev.mjs", "dev": "vite", "ext:bridge": "node scripts/generate-ext-bridge.mjs", - "build": "node scripts/generate-ext-bridge.mjs && vite build && node scripts/assert-electron-runtime-deps.mjs && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && node scripts/prepare-internal-model-auth.mjs && node scripts/prepare-nianxx-play-bundle.mjs && electron-builder", + "build": "node scripts/generate-ext-bridge.mjs && vite build && node scripts/assert-electron-runtime-deps.mjs && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && node scripts/prepare-internal-model-auth.mjs && electron-builder", "build:vite": "vite build", "bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs", "bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs", - "prepare:nianxx-play": "node scripts/prepare-nianxx-play-bundle.mjs", - "prepare:nianxx-play:pilot": "NIANXX_PLAY_BUNDLE_ENV=1 node scripts/prepare-nianxx-play-bundle.mjs", "lint": "eslint . --fix", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -64,8 +62,8 @@ "prep:mac-binaries:arm64": "pnpm run uv:download:mac:arm64 && pnpm run node:download:mac:arm64", "prep:win-binaries": "pnpm run uv:download:win && pnpm run node:download:win", "icons": "zx scripts/generate-icons.mjs", - "package": "node scripts/generate-ext-bridge.mjs && vite build && node scripts/assert-electron-runtime-deps.mjs && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && node scripts/prepare-internal-model-auth.mjs && node scripts/prepare-nianxx-play-bundle.mjs", - "package:pilot": "node scripts/generate-ext-bridge.mjs && vite build && node scripts/assert-electron-runtime-deps.mjs && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && YINIAN_BUNDLE_MODEL_AUTH=1 node scripts/prepare-internal-model-auth.mjs && NIANXX_PLAY_BUNDLE_ENV=1 node scripts/prepare-nianxx-play-bundle.mjs", + "package": "node scripts/generate-ext-bridge.mjs && vite build && node scripts/assert-electron-runtime-deps.mjs && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && node scripts/prepare-internal-model-auth.mjs", + "package:pilot": "node scripts/generate-ext-bridge.mjs && vite build && node scripts/assert-electron-runtime-deps.mjs && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && YINIAN_BUNDLE_MODEL_AUTH=1 node scripts/prepare-internal-model-auth.mjs", "package:mac": "pnpm run prep:mac-binaries && pnpm run package && electron-builder --mac --publish never", "package:mac:pilot": "pnpm run prep:mac-binaries && pnpm run package:pilot && electron-builder --mac --publish never", "package:mac:pilot:arm64": "pnpm run prep:mac-binaries:arm64 && pnpm run package:pilot && electron-builder --mac --arm64 --publish never", @@ -110,6 +108,7 @@ "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.8", "@lancedb/lancedb": "^0.27.2", + "@larksuite/openclaw-lark": "2026.5.13", "@larksuiteoapi/node-sdk": "^1.62.1", "@line/bot-sdk": "^11.0.0", "@lydell/node-pty": "1.2.0-beta.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0acd05e..feea24f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@lancedb/lancedb': specifier: ^0.27.2 version: 0.27.2(apache-arrow@18.1.0) + '@larksuite/openclaw-lark': + specifier: 2026.5.13 + version: 2026.5.13(openclaw@2026.5.7(@types/express@5.0.6)(encoding@0.1.13)) '@larksuiteoapi/node-sdk': specifier: ^1.62.1 version: 1.62.1 @@ -1926,6 +1929,16 @@ packages: peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' + '@larksuite/openclaw-lark@2026.5.13': + resolution: {integrity: sha512-n1o8MyD9FfguemGQPvQ3ZweX7RU0/TZpI/Mwc8UKIzuCr7Vt9oYh4yB2rKnbleS4X8qEUtY+5BnM1bX/df+gHg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + openclaw: '>=2026.5.4' + peerDependenciesMeta: + openclaw: + optional: true + '@larksuiteoapi/node-sdk@1.62.1': resolution: {integrity: sha512-o9oAjv5Ffnp/6iXIJLHrO6N0US/r2ZZy3xmO6ylGegjuVSC05cx0fADA38Dc1h0FV8T9BDK+ariWk84TNMGbKg==} @@ -5597,6 +5610,11 @@ packages: engines: {node: '>=16.x'} hasBin: true + image-size@2.0.2: + resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} + engines: {node: '>=16.x'} + hasBin: true + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -7822,6 +7840,9 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@8.2.0: + resolution: {integrity: sha512-uciYZ5yCmf+QJb18kJw10HjquzM7K0z992vWcI+84KeBpTfXT4hfgfGJ5DQbf/mCBPACofkrjvqgcjZfuujjFA==} + undici@7.24.6: resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} engines: {node: '>=20.18.1'} @@ -10547,6 +10568,20 @@ snapshots: '@lancedb/lancedb-win32-arm64-msvc': 0.27.2 '@lancedb/lancedb-win32-x64-msvc': 0.27.2 + '@larksuite/openclaw-lark@2026.5.13(openclaw@2026.5.7(@types/express@5.0.6)(encoding@0.1.13))': + dependencies: + '@larksuiteoapi/node-sdk': 1.62.1 + '@sinclair/typebox': 0.34.48 + image-size: 2.0.2 + undici-types: 8.2.0 + zod: 4.4.3 + optionalDependencies: + openclaw: 2026.5.7(@types/express@5.0.6)(encoding@0.1.13) + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@larksuiteoapi/node-sdk@1.62.1': dependencies: axios: 1.13.6 @@ -14888,6 +14923,8 @@ snapshots: dependencies: queue: 6.0.2 + image-size@2.0.2: {} + immediate@3.0.6: {} import-in-the-middle@3.0.1: @@ -17549,6 +17586,8 @@ snapshots: undici-types@7.19.2: {} + undici-types@8.2.0: {} + undici@7.24.6: {} undici@8.1.0: {} diff --git a/progress.md b/progress.md index 05969cb..38a42a4 100644 --- a/progress.md +++ b/progress.md @@ -1,5 +1,62 @@ # Progress Log +## 2026-06-03 Model Provider Runtime Sync + +- 用户反馈:设置默认模型改为 DeepSeek 后,实际运行仍使用 MiniMax。 +- 已确认本机 provider account store 默认值为 DeepSeek:`deepseek-a1e23f39-4296-4762-8bdf-184ca677cce6` / `deepseek-v4-pro`。 +- 已确认 OpenClaw runtime 仍为 `minimax-portal/MiniMax-M3`,并带有旧 `yinian-model/custom-model` 占位 provider。 +- 日志显示 DeepSeek 保存和默认切换后只触发 Gateway reload;Gateway reload/reconnect 随后写回旧内存 config,覆盖了刚写入的 DeepSeek runtime 配置。 +- 已将 provider 保存、更新、默认切换后的 Gateway 刷新策略改为 restart,避免 reload 用旧内存状态覆盖 `openclaw.json`。 +- 已将 legacy `getDefaultProvider()` 改为优先读取 `defaultProviderAccountId`,并让 `getAllProviders()` 合并 legacy provider 与 account store,account store 作为新配置源可参与 agent model 同步。 +- 已更新 `model-diagnostics`:启动/诊断修复不再把 legacy MiniMax 默认迁移成 `yinian-model/custom-model`,只清理 `https://api.example.com/v1` 这类旧占位 provider,并删除对应 `yinian-model:*` auth 残留。 +- 已手动修复本机 `~/.openclaw/openclaw.json`,当前默认模型为 `deepseek/deepseek-v4-pro`,provider 配置为 `https://api.deepseek.com/v1` + `openai-completions`;Gateway 重启日志确认启动前写入 DeepSeek 默认。 +- 当前运行态保留 `minimax-portal` provider 条目是因为本机仍有 MiniMax 账号配置,但它不再是默认模型;`yinian-model` provider/auth 残留已清除。 +- 用户随后反馈:设置页“模型服务”仍显示 `yinian-model` / MiniMax 固定缺失提示。 +- 已修复 diagnostics 数据源:`buildProviderDiagnostics()` 改为只展示当前默认模型和 fallbacks 实际引用的 provider;auth profiles 也按这些 provider 过滤,不再把 `yinian-model`、`minimax`、`minimax-portal` 当作固定诊断项。 +- 已更新设置页文案为空态:`模型服务` 改为 `当前模型服务`,`调用凭据` 改为 `当前调用凭据`。 +- 真实 diagnostics 函数输出确认:`providers=[deepseek]`,`authProviders=[deepseek]`,没有 `yinian-model` / MiniMax 固定缺失项。 +- Verification passed: + - `pnpm exec vitest run tests/unit/model-diagnostics.test.ts tests/unit/provider-runtime-sync.test.ts` + - `pnpm exec vitest run tests/unit/model-diagnostics.test.ts tests/unit/provider-runtime-sync.test.ts tests/unit/provider-service-stale-cleanup.test.ts tests/unit/yinian-initializer.test.ts tests/unit/settings-advanced-model-config.test.tsx` + - `pnpm exec vitest run tests/unit/model-diagnostics.test.ts tests/unit/settings-advanced-model-config.test.tsx` + - `pnpm exec vitest run tests/unit/model-diagnostics.test.ts tests/unit/settings-advanced-model-config.test.tsx tests/unit/provider-runtime-sync.test.ts tests/unit/provider-service-stale-cleanup.test.ts tests/unit/yinian-initializer.test.ts` + - `pnpm exec tsc --noEmit --pretty false` + - `pnpm run build:vite` + - `git diff --check` + +## 2026-05-14 渠道插件运行产物与飞书加固 + +- 客户日志确认:微信渠道失败不是配置本身,而是 `~/.openclaw/extensions/openclaw-weixin` 被安装成 TypeScript 源码包,缺少 Gateway 可直接加载的 JS 运行入口。 +- 已新增插件运行入口检测:同版本插件如果已安装但缺 `openclaw.extensions/main/module` 指向的 JS 文件,会自动从 bundled mirror 重装。 +- 已修改插件打包链路: + - `@tencent-weixin/openclaw-weixin` 复制后自动转译 `index.ts/src/**/*.ts` 到 `dist/`,并把 `openclaw.extensions` 改为 `./dist/index.js`。 + - `@larksuite/openclaw-lark@2026.5.13` 加入依赖和 bundled plugin 列表。 + - 修正官方飞书包 `main` 指向缺失 `dist/index.js` 的问题,使用实际存在的 `./index.js`。 + - 给缺少 `channelConfigs` 的微信 manifest 补兼容 channel schema,避免 OpenClaw 扫描 warning。 +- 已解除飞书硬禁用:API route、IPC handler、channel-config、openclaw-auth 都不再把 `feishu` 当作内测禁用渠道。 +- 保存飞书配置前会安装 `openclaw-lark` 插件;Gateway 启动前也会按已配置渠道自动安装/修复。 +- 已运行 `pnpm run bundle:openclaw-plugins`,生成: + - `build/openclaw-plugins/openclaw-weixin/dist/index.js` + - `build/openclaw-plugins/openclaw-lark/index.js` +- 使用临时 HOME 把两个插件复制到 `.openclaw/extensions` 后执行 OpenClaw 插件扫描,结果: + - `openclaw-weixin`: `status=loaded`, `channels=["openclaw-weixin"]`, `missing=[]` + - `openclaw-lark`: `status=loaded`, `channels=["feishu"]`, `missing=[]` + - diagnostics 为空。 +- Verification passed: + - `pnpm run bundle:openclaw-plugins` + - `pnpm vitest run tests/unit/plugin-install.test.ts tests/unit/channel-config.test.ts tests/unit/channel-routes.test.ts tests/unit/config-sync.test.ts` + - `pnpm run typecheck` + - `pnpm run package:mac:pilot:arm64` + - Packaged resource check for `release/mac-arm64/智念助手.app/Contents/Resources/openclaw-plugins/*` + - `codesign --verify --deep --strict --verbose=2 release/mac-arm64/智念助手.app` + - `hdiutil verify release/智念助手-0.1.0-mac-arm64.dmg` +- Packaging note: first packaging attempt failed because `@larksuite/openclaw-lark` was added under runtime `dependencies`; `scripts/assert-electron-runtime-deps.mjs` correctly flagged it as a double-pack risk. Moved it to `devDependencies`, while the plugin bundle script still copies it into app resources. +- New pilot artifacts: + - `release/智念助手-0.1.0-mac-arm64.dmg` (1.6G) + - `release/智念助手-0.1.0-mac-arm64.zip` (1.5G) + - `release/latest-mac.yml` +- macOS notarization remains skipped by existing builder config: `notarize` options were unable to be generated. + ## 2026-05-13 macOS DMG 打包 - 用户要求打 DMG 安装包,并确保新增/预置 skill 默认放进去。 @@ -1056,6 +1113,21 @@ - `pnpm vitest run tests/unit/cron-desktop-reminder.test.ts tests/unit/chat-message.test.tsx tests/unit/tasks-page.test.tsx tests/unit/business-guidance.test.ts` - `pnpm run typecheck` - `pnpm run build:vite` +# 2026-06-03 Agent 系统级文档管理 + +- 用户要求在设置中增加系统级文档管理模块,覆盖 soul、agent、tool 这类文档。 +- 已确认项目根目录为 `/Users/inmanx/Documents/念/yinian-desktop`,工作树存在多项既有未提交改动;本次会限定在系统文档管理相关文件。 +- 已在 `task_plan.md` 添加本次阶段,当前进入路径/API/设置页结构调研。 +- 已确认 OpenClaw 实际使用的文档文件是 per-agent workspace 下的 `SOUL.md`、`AGENTS.md`、`TOOLS.md`,默认模板位于 OpenClaw runtime 的 `docs/reference/templates/`。 +- 新增 `electron/utils/agent-system-documents.ts` 和 Host API 路由 `/api/agent-system-documents`,支持按 Agent 读取、保存、恢复模板。 +- 新增设置页 `系统文档` tab 和 `AgentSystemDocumentsSettings` 组件,支持选择 Agent、切换 soul/agent/tool、编辑、保存、恢复模板。 +- 新增测试并通过: + - `pnpm exec vitest run tests/unit/agent-system-documents.test.ts tests/unit/agent-system-documents-routes.test.ts tests/unit/agent-system-documents-settings.test.tsx` +- 完整相关验证通过: + - `pnpm exec vitest run tests/unit/agent-system-documents.test.ts tests/unit/agent-system-documents-routes.test.ts tests/unit/agent-system-documents-settings.test.tsx tests/unit/settings-advanced-model-config.test.tsx` + - `pnpm exec tsc --noEmit --pretty false` + - `pnpm run build:vite`(仅保留既有 dynamic-import/chunk-size warnings) + - `pnpm exec playwright test tests/e2e/yinian-delivery-smoke.spec.ts` - Note: an initial E2E attempt timed out because it launched Electron while `build:vite` was rewriting `dist`; a clean rerun passed. @@ -1074,6 +1146,29 @@ - `pnpm exec playwright test tests/e2e/yinian-delivery-smoke.spec.ts` - `pnpm exec playwright test --config=playwright.legacy.config.ts tests/e2e/yinian-visual-smoke.spec.ts` +# 2026-05-14 客户安装初始化凭据失败加固 + +- Reviewed the customer setup screenshot and traced it to `electron/utils/yinian-initializer.ts` plus `src/pages/Setup/index.tsx`. +- Confirmed local `build/yinian-internal/model-auth-profiles.json` is a benign non-pilot manifest (`bundled=false`), while the machine does have usable local `minimax:default` auth material for rebuilding a pilot package. +- Identified a renderer bug: when initialization returns `initialized=false`, pending steps are turned into failed steps, so `准备文档处理环境` can be shown as failed even when initialization stopped earlier at model auth. +- Added `src/pages/Setup/initialization.ts` so first-run result mapping keeps pending steps pending, calculates partial progress from completed steps, and shows failed-step details in the red error panel. +- Updated `electron/utils/yinian-initializer.ts` so missing/disabled internal model auth manifests produce a clear package/credential error instead of the generic “凭据未配置”. +- Updated `scripts/prepare-internal-model-auth.mjs` so ordinary non-pilot packaging logs that customer pilot installers must use `package:pilot` or `YINIAN_BUNDLE_MODEL_AUTH=1`. +- Added tests: + - `tests/unit/setup-initialization.test.ts` + - `tests/unit/yinian-initializer.test.ts` +- Verification passed: + - `pnpm vitest run tests/unit/setup-initialization.test.ts tests/unit/yinian-initializer.test.ts` + - `pnpm run typecheck` +- Rebuilt the Apple Silicon customer pilot package with bundled internal model auth: + - `pnpm run package:mac:pilot:arm64` + - `release/智念助手-0.1.0-mac-arm64.dmg` + - `release/智念助手-0.1.0-mac-arm64.zip` +- Packaged resource verification confirmed `release/mac-arm64/智念助手.app/Contents/Resources/resources/yinian-internal/model-auth-profiles.json` has `bundled=true` and two MiniMax profile ids. +- Release verification passed: + - `codesign --verify --deep --strict --verbose=2 release/mac-arm64/智念助手.app` + - `hdiutil verify release/智念助手-0.1.0-mac-arm64.dmg` + ## 2026-05-13 旅游资源订购 Login-State Follow-Up - Renamed the Product Center surface to “旅游资源订购” in zh copy and “Travel Resource Ordering” in en copy. @@ -1153,3 +1248,36 @@ - `pnpm run build:vite` - `pnpm test` (106 files / 675 tests; existing MaxListeners warnings remain) - `pnpm exec playwright test tests/e2e/yinian-delivery-smoke.spec.ts` +## 2026-06-04 应用中心清理后项目体检 + +- Started a focused post-cleanup audit after removing the two App Center built-in apps. +- Scope: App Center registry/routes, NianxxPlay/Product Center residues, Host API route list, packaging resources, README/i18n/tests, and current dirty worktree separation. +- Residual scan result: no production references to the removed apps remain; only negative test assertions mention `app-center-item-product-center` and `app-center-item-nianxx-play`. +- `build/apps/nianxx-play` has been removed; `build/apps` is currently empty. +- Current dev service is still running through Vite/Electron from `pnpm dev`. +- Removed stale `selectedItemId/selectItem` state from the App Center store and removed the empty README App Center screenshot heading. +- Verification passed: + - `pnpm exec vitest run tests/unit/app-center.test.tsx tests/unit/app-routes.test.ts` + - `pnpm exec tsc --noEmit --pretty false` + - `pnpm run build:vite` + - `git diff --check` + - `pnpm test` (111 files / 712 tests; existing MaxListeners warnings only) + +## 2026-06-04 Git Push Preparation + +- User requested pushing the current project to the git remote while avoiding secrets/tokens/API/env material and keeping customer quick-start usability. +- Remote target: `origin` -> `https://git.nianxx.cn/wangxuming/NianToB.git`; current branch: `main`. +- `.env.example` now contains placeholders only and explicitly tells users to keep real values in `.env.local`, CI secrets, local shell, or private deployment channels. +- Added `*.docx` to `.gitignore` so generated local report documents are not accidentally committed. +- Confirmed ignored: + - `.env.local` + - `build/yinian-internal/model-auth-profiles.json` + - `release/智念助手-0.1.0-mac-arm64.zip` + - generated `.docx` reports +- Secret scans: + - tracked file scan for common key/token patterns: no matches. + - untracked new source file scan for common key/token patterns: no matches. +- Final pre-stage verification passed: + - `git diff --check` + - `pnpm exec tsc --noEmit --pretty false` + - `pnpm test` (111 files / 712 tests; existing MaxListeners warnings only) diff --git a/resources/readme/05-app-center.png b/resources/readme/05-app-center.png deleted file mode 100644 index 35206c7..0000000 Binary files a/resources/readme/05-app-center.png and /dev/null differ diff --git a/resources/readme/06-nianxx-play.png b/resources/readme/06-nianxx-play.png deleted file mode 100644 index c37a4d2..0000000 Binary files a/resources/readme/06-nianxx-play.png and /dev/null differ diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 505c1ae..9b7a1ec 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -19,8 +19,9 @@ * @mariozechner/clipboard). */ -const { cpSync, existsSync, readdirSync, rmSync, statSync, mkdirSync, realpathSync } = require('fs'); +const { cpSync, existsSync, readdirSync, rmSync, statSync, mkdirSync, realpathSync, readFileSync, writeFileSync } = require('fs'); const { join, dirname, basename, relative } = require('path'); +const ts = require('typescript'); // On Windows, paths in pnpm's virtual store can exceed the default MAX_PATH // limit (260 chars). Node.js 18.17+ respects the system LongPathsEnabled @@ -280,30 +281,6 @@ function removeOptionalNativeClipboard(nodeModulesDir) { return removed; } -function copyNianxxPlayNodeModules(resourcesDir, platform, arch) { - const src = join(__dirname, '..', 'build', 'apps', 'nianxx-play', 'node_modules'); - const nianxxPlayRoot = join(resourcesDir, 'resources', 'nianxx-play'); - const dest = join(nianxxPlayRoot, 'node_modules'); - - if (!existsSync(nianxxPlayRoot)) return; - if (!existsSync(src)) { - console.warn('[after-pack] ⚠️ build/apps/nianxx-play/node_modules not found. Run prepare:nianxx-play first.'); - return; - } - - const depCount = readdirSync(src, { withFileTypes: true }) - .filter(d => d.isDirectory() && d.name !== '.bin') - .length; - - console.log(`[after-pack] Copying ${depCount} NianxxPlay dependencies to ${dest} ...`); - rmSync(dest, { recursive: true, force: true }); - cpSync(src, dest, { recursive: true, dereference: true }); - cleanupUnnecessaryFiles(dest); - cleanupKoffi(dest, platform, arch); - cleanupNativePlatformPackages(dest, platform, arch); - console.log('[after-pack] ✅ NianxxPlay node_modules copied.'); -} - // ── Broken module patcher ───────────────────────────────────────────────────── // Some bundled packages have transpiled CJS that sets `module.exports = exports.default` // without ever assigning `exports.default`, leaving module.exports === undefined. @@ -488,7 +465,8 @@ function patchPluginIds(pluginDir, expectedId) { if (!existsSync(pkgJsonPath)) return; const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); - const entryFiles = [pkg.main, pkg.module].filter(Boolean); + const extensionEntries = Array.isArray(pkg.openclaw?.extensions) ? pkg.openclaw.extensions : []; + const entryFiles = [...new Set([pkg.main, pkg.module, ...extensionEntries].filter(Boolean))]; for (const entry of entryFiles) { const entryPath = join(pluginDir, entry); @@ -520,6 +498,194 @@ function patchPluginIds(pluginDir, expectedId) { // bundle-openclaw-plugins.mjs so the packaged app is self-contained even when // build/openclaw-plugins/ was not pre-generated. +function readJsonFile(filePath) { + return JSON.parse(readFileSync(filePath, 'utf8')); +} + +function writeJsonFile(filePath, value) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function normalizeEntryPath(entry) { + if (typeof entry !== 'string') return null; + const trimmed = entry.trim(); + if (!trimmed || require('path').isAbsolute(trimmed)) return null; + return trimmed.replace(/^\.\//, ''); +} + +function toPackageEntry(entry) { + return entry.startsWith('.') ? entry : `./${entry}`; +} + +function isJavaScriptEntry(entry) { + return /\.(?:cjs|mjs|js)$/i.test(entry); +} + +function entryExists(pluginDir, entry) { + const normalized = normalizeEntryPath(entry); + return Boolean(normalized) && existsSync(join(pluginDir, normalized)); +} + +function collectRuntimeEntryHints(pkg) { + const hints = []; + const extensions = pkg.openclaw?.extensions; + if (Array.isArray(extensions)) hints.push(...extensions); + if (typeof pkg.main === 'string') hints.push(pkg.main); + if (typeof pkg.module === 'string') hints.push(pkg.module); + hints.push('./dist/index.js', './index.js'); + return [...new Set(hints.map(normalizeEntryPath).filter(Boolean))]; +} + +function findExistingRuntimeEntry(pluginDir, pkg) { + for (const hint of collectRuntimeEntryHints(pkg)) { + if (isJavaScriptEntry(hint) && existsSync(join(pluginDir, hint))) { + return hint; + } + } + return null; +} + +function patchRuntimeEntryHints(pluginDir) { + const pkgJsonPath = join(pluginDir, 'package.json'); + if (!existsSync(pkgJsonPath)) return null; + + const pkg = readJsonFile(pkgJsonPath); + let modified = false; + + const extensions = pkg.openclaw?.extensions; + if (Array.isArray(extensions)) { + const patchedExtensions = extensions.map((entry) => { + const normalized = normalizeEntryPath(entry); + if (!normalized?.endsWith('.ts')) return entry; + const jsEntry = `dist/${normalized.replace(/\.ts$/i, '.js')}`; + return existsSync(join(pluginDir, jsEntry)) ? toPackageEntry(jsEntry) : entry; + }); + if (JSON.stringify(patchedExtensions) !== JSON.stringify(extensions)) { + pkg.openclaw.extensions = patchedExtensions; + modified = true; + } + } + + const existingRuntimeEntry = findExistingRuntimeEntry(pluginDir, pkg); + if (existingRuntimeEntry) { + if (typeof pkg.main !== 'string' || !entryExists(pluginDir, pkg.main)) { + pkg.main = toPackageEntry(existingRuntimeEntry); + modified = true; + } + if (typeof pkg.module === 'string' && !entryExists(pluginDir, pkg.module)) { + pkg.module = toPackageEntry(existingRuntimeEntry); + modified = true; + } + } + + if (modified) { + writeJsonFile(pkgJsonPath, pkg); + } + + return existingRuntimeEntry; +} + +function patchManifestChannelConfigs(pluginDir) { + const manifestPath = join(pluginDir, 'openclaw.plugin.json'); + if (!existsSync(manifestPath)) return; + + const manifest = readJsonFile(manifestPath); + if (manifest.channelConfigs || !Array.isArray(manifest.channels)) return; + + const schema = { type: 'object' }; + manifest.channelConfigs = Object.fromEntries( + manifest.channels + .filter((channelId) => typeof channelId === 'string' && channelId.trim().length > 0) + .map((channelId) => [channelId, { schema }]), + ); + writeJsonFile(manifestPath, manifest); +} + +function collectTypeScriptFiles(pluginDir) { + const result = []; + const skipDirs = new Set(['node_modules', 'dist', '.git']); + + function walk(currentDir) { + for (const entry of readdirSync(currentDir, { withFileTypes: true })) { + if (skipDirs.has(entry.name)) continue; + const fullPath = join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.ts')) continue; + if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.test.ts')) continue; + result.push(fullPath); + } + } + + walk(pluginDir); + return result; +} + +function compileTypeScriptPluginIfNeeded(pluginDir, pluginId) { + const pkgJsonPath = join(pluginDir, 'package.json'); + if (!existsSync(pkgJsonPath)) return; + + const pkg = readJsonFile(pkgJsonPath); + const extensionEntries = Array.isArray(pkg.openclaw?.extensions) ? pkg.openclaw.extensions : []; + const hasTypeScriptEntry = extensionEntries.some((entry) => normalizeEntryPath(entry)?.endsWith('.ts')); + if (!hasTypeScriptEntry) { + const runtimeEntry = patchRuntimeEntryHints(pluginDir); + if (runtimeEntry) { + console.log(`[after-pack] Runtime entry: ${runtimeEntry}`); + } + return; + } + + const tsFiles = collectTypeScriptFiles(pluginDir); + if (tsFiles.length === 0) { + throw new Error(`Plugin ${pluginId} declares TypeScript entries but no .ts source files were found.`); + } + + const distDir = join(pluginDir, 'dist'); + rmSync(distDir, { recursive: true, force: true }); + + for (const sourcePath of tsFiles) { + const source = readFileSync(sourcePath, 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + sourceMap: false, + inlineSources: false, + }, + fileName: sourcePath, + reportDiagnostics: true, + }); + + const diagnostics = output.diagnostics ?? []; + const blocking = diagnostics.filter((diag) => diag.category === ts.DiagnosticCategory.Error); + if (blocking.length > 0) { + const message = blocking + .map((diag) => ts.flattenDiagnosticMessageText(diag.messageText, '\n')) + .join('\n'); + throw new Error(`Failed to transpile ${relative(pluginDir, sourcePath)}:\n${message}`); + } + + const rel = relative(pluginDir, sourcePath).replace(/\.ts$/i, '.js'); + const outputPath = join(distDir, rel); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, output.outputText, 'utf8'); + } + + patchRuntimeEntryHints(pluginDir); + const runtimeEntry = findExistingRuntimeEntry(pluginDir, readJsonFile(pkgJsonPath)); + if (!runtimeEntry) { + throw new Error(`Plugin ${pluginId} did not produce a loadable JavaScript runtime entry.`); + } + + console.log(`[after-pack] Compiled ${tsFiles.length} TypeScript files -> dist/ (${runtimeEntry})`); +} + function getVirtualStoreNodeModules(realPkgPath) { let dir = realPkgPath; while (dir !== dirname(dir)) { @@ -670,10 +836,6 @@ exports.default = async function afterPack(context) { console.log(`[after-pack] ✅ Removed optional native clipboard packages (${clipboardRemoved}) to avoid macOS Gatekeeper prompts.`); } - // 1.0 Copy bundled large-app runtime deps that electron-builder skips because - // node_modules/ is ignored globally. - copyNianxxPlayNodeModules(resourcesDir, platform, arch); - // Patch broken modules whose CJS transpiled output sets module.exports = undefined, // causing TypeError in Node.js 22+ ESM interop. patchBrokenModules(dest); @@ -685,6 +847,7 @@ exports.default = async function afterPack(context) { // - node_modules/ is excluded by .gitignore so the deps copy must be manual const BUNDLED_PLUGINS = [ { npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' }, + { npmName: '@larksuite/openclaw-lark', pluginId: 'openclaw-lark' }, ]; mkdirSync(pluginsDestRoot, { recursive: true }); @@ -700,6 +863,8 @@ exports.default = async function afterPack(context) { cleanupNativePlatformPackages(pluginNM, platform, arch); } // Fix hardcoded plugin ID mismatches in compiled JS + patchManifestChannelConfigs(pluginDestDir); + compileTypeScriptPluginIfNeeded(pluginDestDir, pluginId); patchPluginIds(pluginDestDir, pluginId); } } @@ -715,6 +880,7 @@ exports.default = async function afterPack(context) { rmSync(pluginDestDir, { recursive: true, force: true }); cpSync(sourceDir, pluginDestDir, { recursive: true, dereference: true }); cleanupUnnecessaryFiles(pluginDestDir); + patchManifestChannelConfigs(pluginDestDir); patchPluginIds(pluginDestDir, entry.name); } } diff --git a/scripts/bundle-openclaw-plugins.mjs b/scripts/bundle-openclaw-plugins.mjs index 5badbc5..1a05006 100644 --- a/scripts/bundle-openclaw-plugins.mjs +++ b/scripts/bundle-openclaw-plugins.mjs @@ -6,9 +6,10 @@ * Build a self-contained mirror of OpenClaw third-party plugins for packaging. * Current plugins: * - @tencent-weixin/openclaw-weixin -> build/openclaw-plugins/openclaw-weixin + * - @larksuite/openclaw-lark -> build/openclaw-plugins/openclaw-lark * * The output plugin directory contains: - * - plugin source files (index.ts, openclaw.plugin.json, package.json, ...) + * - plugin runtime files (dist/index.js or index.js, openclaw.plugin.json, package.json, ...) * - plugin runtime node_modules/ (flattened direct + transitive deps) */ @@ -16,6 +17,7 @@ import 'zx/globals'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); @@ -36,8 +38,197 @@ function normWin(p) { const PLUGINS = [ { npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' }, + { npmName: '@larksuite/openclaw-lark', pluginId: 'openclaw-lark' }, ]; +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJsonFile(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function normalizeEntryPath(entry) { + if (typeof entry !== 'string') return null; + const trimmed = entry.trim(); + if (!trimmed || path.isAbsolute(trimmed)) return null; + return trimmed.replace(/^\.\//, ''); +} + +function toPackageEntry(entry) { + return entry.startsWith('.') ? entry : `./${entry}`; +} + +function isJavaScriptEntry(entry) { + return /\.(?:cjs|mjs|js)$/i.test(entry); +} + +function entryExists(pluginDir, entry) { + const normalized = normalizeEntryPath(entry); + return Boolean(normalized) && fs.existsSync(path.join(pluginDir, normalized)); +} + +function collectRuntimeEntryHints(pkg) { + const hints = []; + const extensions = pkg.openclaw?.extensions; + if (Array.isArray(extensions)) hints.push(...extensions); + if (typeof pkg.main === 'string') hints.push(pkg.main); + if (typeof pkg.module === 'string') hints.push(pkg.module); + hints.push('./dist/index.js', './index.js'); + return [...new Set(hints.map(normalizeEntryPath).filter(Boolean))]; +} + +function findExistingRuntimeEntry(pluginDir, pkg) { + for (const hint of collectRuntimeEntryHints(pkg)) { + if (isJavaScriptEntry(hint) && fs.existsSync(path.join(pluginDir, hint))) { + return hint; + } + } + return null; +} + +function patchRuntimeEntryHints(pluginDir) { + const pkgJsonPath = path.join(pluginDir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) return null; + + const pkg = readJsonFile(pkgJsonPath); + let modified = false; + + const extensions = pkg.openclaw?.extensions; + if (Array.isArray(extensions)) { + const patchedExtensions = extensions.map((entry) => { + const normalized = normalizeEntryPath(entry); + if (!normalized?.endsWith('.ts')) return entry; + const jsEntry = `dist/${normalized.replace(/\.ts$/i, '.js')}`; + return fs.existsSync(path.join(pluginDir, jsEntry)) ? toPackageEntry(jsEntry) : entry; + }); + if (JSON.stringify(patchedExtensions) !== JSON.stringify(extensions)) { + pkg.openclaw.extensions = patchedExtensions; + modified = true; + } + } + + const existingRuntimeEntry = findExistingRuntimeEntry(pluginDir, pkg); + if (existingRuntimeEntry) { + if (typeof pkg.main !== 'string' || !entryExists(pluginDir, pkg.main)) { + pkg.main = toPackageEntry(existingRuntimeEntry); + modified = true; + } + if (typeof pkg.module === 'string' && !entryExists(pluginDir, pkg.module)) { + pkg.module = toPackageEntry(existingRuntimeEntry); + modified = true; + } + } + + if (modified) { + writeJsonFile(pkgJsonPath, pkg); + } + + return existingRuntimeEntry; +} + +function patchManifestChannelConfigs(pluginDir) { + const manifestPath = path.join(pluginDir, 'openclaw.plugin.json'); + if (!fs.existsSync(manifestPath)) return; + + const manifest = readJsonFile(manifestPath); + if (manifest.channelConfigs || !Array.isArray(manifest.channels)) return; + + const schema = { type: 'object' }; + manifest.channelConfigs = Object.fromEntries( + manifest.channels + .filter((channelId) => typeof channelId === 'string' && channelId.trim().length > 0) + .map((channelId) => [channelId, { schema }]), + ); + writeJsonFile(manifestPath, manifest); +} + +function collectTypeScriptFiles(pluginDir) { + const result = []; + const skipDirs = new Set(['node_modules', 'dist', '.git']); + + function walk(currentDir) { + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + if (skipDirs.has(entry.name)) continue; + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.ts')) continue; + if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.test.ts')) continue; + result.push(fullPath); + } + } + + walk(pluginDir); + return result; +} + +function compileTypeScriptPluginIfNeeded(pluginDir, pluginId) { + const pkgJsonPath = path.join(pluginDir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) return; + + const pkg = readJsonFile(pkgJsonPath); + const extensionEntries = Array.isArray(pkg.openclaw?.extensions) ? pkg.openclaw.extensions : []; + const hasTypeScriptEntry = extensionEntries.some((entry) => normalizeEntryPath(entry)?.endsWith('.ts')); + if (!hasTypeScriptEntry) { + const runtimeEntry = patchRuntimeEntryHints(pluginDir); + if (runtimeEntry) { + echo` 🔗 Runtime entry: ${runtimeEntry}`; + } + return; + } + + const tsFiles = collectTypeScriptFiles(pluginDir); + if (tsFiles.length === 0) { + throw new Error(`Plugin ${pluginId} declares TypeScript entries but no .ts source files were found.`); + } + + const distDir = path.join(pluginDir, 'dist'); + fs.rmSync(distDir, { recursive: true, force: true }); + + for (const sourcePath of tsFiles) { + const source = fs.readFileSync(sourcePath, 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + sourceMap: false, + inlineSources: false, + }, + fileName: sourcePath, + reportDiagnostics: true, + }); + + const diagnostics = output.diagnostics ?? []; + const blocking = diagnostics.filter((diag) => diag.category === ts.DiagnosticCategory.Error); + if (blocking.length > 0) { + const message = blocking + .map((diag) => ts.flattenDiagnosticMessageText(diag.messageText, '\n')) + .join('\n'); + throw new Error(`Failed to transpile ${path.relative(pluginDir, sourcePath)}:\n${message}`); + } + + const rel = path.relative(pluginDir, sourcePath).replace(/\.ts$/i, '.js'); + const outputPath = path.join(distDir, rel); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, output.outputText, 'utf8'); + } + + patchRuntimeEntryHints(pluginDir); + const runtimeEntry = findExistingRuntimeEntry(pluginDir, readJsonFile(pkgJsonPath)); + if (!runtimeEntry) { + throw new Error(`Plugin ${pluginId} did not produce a loadable JavaScript runtime entry.`); + } + + echo` 🛠️ Compiled ${tsFiles.length} TypeScript files -> dist/ (${runtimeEntry})`; +} + function getVirtualStoreNodeModules(realPkgPath) { let dir = realPkgPath; while (dir !== path.dirname(dir)) { @@ -171,6 +362,8 @@ function bundleOnePlugin({ npmName, pluginId }) { // 4) Patch plugin ID mismatch: some npm packages hardcode a different ID in // their JS output than what openclaw.plugin.json declares. The Gateway // validates that these match, so we fix it post-copy. + patchManifestChannelConfigs(outputDir); + compileTypeScriptPluginIfNeeded(outputDir, pluginId); patchPluginId(outputDir, pluginId); echo` ✅ ${pluginId}: copied ${copiedCount} deps (skipped dupes: ${skippedDupes})`; @@ -196,7 +389,8 @@ function patchPluginId(pluginDir, expectedId) { if (!fs.existsSync(pkgJsonPath)) return; const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); - const entryFiles = [pkg.main, pkg.module].filter(Boolean); + const extensionEntries = Array.isArray(pkg.openclaw?.extensions) ? pkg.openclaw.extensions : []; + const entryFiles = [...new Set([pkg.main, pkg.module, ...extensionEntries].filter(Boolean))]; // Known ID mismatches to patch. Keys are the wrong ID found in compiled JS, // values are the correct ID (must match openclaw.plugin.json). diff --git a/scripts/bundle-openclaw.mjs b/scripts/bundle-openclaw.mjs index 9677380..41141b3 100644 --- a/scripts/bundle-openclaw.mjs +++ b/scripts/bundle-openclaw.mjs @@ -1315,7 +1315,7 @@ echo` 🧭 Wrote Yinian runtime patch marker ${YINIAN_RUNTIME_PATCH_MARKER.ver // 8. Verify the bundle const entryExists = fs.existsSync(path.join(OUTPUT, 'openclaw.mjs')); const distExists = fs.existsSync(path.join(OUTPUT, 'dist', 'entry.js')); -const requiredTemplateFiles = ['AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md']; +const requiredTemplateFiles = ['SOUL.md', 'IDENTITY.md', 'USER.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', 'BOOT.md']; const missingTemplateFiles = requiredTemplateFiles.filter((fileName) => ( !fs.existsSync(path.join(OUTPUT, 'docs', 'reference', 'templates', fileName)) )); diff --git a/scripts/prepare-internal-model-auth.mjs b/scripts/prepare-internal-model-auth.mjs index 0387c26..8348fdb 100644 --- a/scripts/prepare-internal-model-auth.mjs +++ b/scripts/prepare-internal-model-auth.mjs @@ -13,7 +13,18 @@ const SHOULD_BUNDLE = process.env.YINIAN_BUNDLE_MODEL_AUTH === '1'; const SOURCE_AUTH_PROFILES = process.env.YINIAN_MODEL_AUTH_SOURCE ? resolve(process.env.YINIAN_MODEL_AUTH_SOURCE) : join(homedir(), '.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json'); -const PROFILE_IDS = ['minimax:default', 'minimax:cn']; +const MODEL_PROVIDER_KEY = (process.env.YINIAN_MODEL_PROVIDER_KEY || 'yinian-model').trim(); +const MODEL_ID = (process.env.YINIAN_MODEL_ID || '').trim(); +const MODEL_NAME = (process.env.YINIAN_MODEL_NAME || MODEL_ID).trim(); +const MODEL_BASE_URL = (process.env.YINIAN_MODEL_BASE_URL || '').trim(); +const MODEL_API = (process.env.YINIAN_MODEL_API || 'openai-completions').trim(); +const MODEL_AUTH_PROFILE_ID = (process.env.YINIAN_MODEL_AUTH_PROFILE_ID || `${MODEL_PROVIDER_KEY}:default`).trim(); +const SOURCE_PROFILE_ID = (process.env.YINIAN_MODEL_AUTH_SOURCE_PROFILE_ID || '').trim(); +const MODEL_FALLBACKS = (process.env.YINIAN_MODEL_FALLBACKS || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +const LEGACY_SOURCE_PROFILE_IDS = ['minimax:default', 'minimax:cn']; function log(message) { console.log(`[yinian-model-auth] ${message}`); @@ -44,7 +55,7 @@ if (!SHOULD_BUNDLE) { bundled: false, reason: 'YINIAN_BUNDLE_MODEL_AUTH is not enabled', }); - log('pilot model auth bundling disabled.'); + log('pilot model auth bundling disabled. Customer pilot installers must use package:pilot or set YINIAN_BUNDLE_MODEL_AUTH=1.'); process.exit(0); } @@ -52,26 +63,54 @@ if (!existsSync(SOURCE_AUTH_PROFILES)) { fail(`source auth profiles not found: ${SOURCE_AUTH_PROFILES}`); } +if (!MODEL_PROVIDER_KEY) { + fail('YINIAN_MODEL_PROVIDER_KEY is required for model auth bundling.'); +} + +if (!MODEL_ID) { + fail('YINIAN_MODEL_ID is required for model auth bundling.'); +} + +if (!MODEL_BASE_URL) { + fail('YINIAN_MODEL_BASE_URL is required for model auth bundling.'); +} + const source = readJson(SOURCE_AUTH_PROFILES); const sourceProfiles = source && typeof source === 'object' && source.profiles && typeof source.profiles === 'object' ? source.profiles : {}; const profiles = {}; +const sourceProfileIds = [ + SOURCE_PROFILE_ID, + MODEL_AUTH_PROFILE_ID, + `${MODEL_PROVIDER_KEY}:default`, + ...LEGACY_SOURCE_PROFILE_IDS, +].filter(Boolean); +const candidateProfileIds = Array.from(new Set([ + ...sourceProfileIds, + ...Object.keys(sourceProfiles), +])); +let bundledKey = ''; -for (const profileId of PROFILE_IDS) { +for (const profileId of candidateProfileIds) { const profile = sourceProfiles[profileId]; if (!profile || typeof profile !== 'object') continue; - if (profile.type !== 'api_key' || profile.provider !== 'minimax') continue; + if (profile.type !== 'api_key') continue; if (typeof profile.key !== 'string' || profile.key.trim().length < 8) continue; - profiles[profileId] = { + bundledKey = profile.key.trim(); + break; +} + +if (bundledKey) { + profiles[MODEL_AUTH_PROFILE_ID] = { type: 'api_key', - provider: 'minimax', - key: profile.key, + provider: MODEL_PROVIDER_KEY, + key: bundledKey, }; } -if (!profiles['minimax:default']) { - fail('minimax:default API key profile is required for pilot model auth bundling.'); +if (!profiles[MODEL_AUTH_PROFILE_ID]) { + fail(`API key profile is required for pilot model auth bundling. Set YINIAN_MODEL_AUTH_SOURCE_PROFILE_ID or create ${MODEL_AUTH_PROFILE_ID}.`); } writeManifest({ @@ -79,14 +118,23 @@ writeManifest({ purpose: 'internal-pilot-only', source: 'local-openclaw-auth-profiles', profileIds: Object.keys(profiles), + model: { + providerKey: MODEL_PROVIDER_KEY, + modelId: MODEL_ID, + modelName: MODEL_NAME || MODEL_ID, + baseUrl: MODEL_BASE_URL, + api: MODEL_API, + authProfileId: MODEL_AUTH_PROFILE_ID, + fallbackModelRefs: MODEL_FALLBACKS, + }, store: { version: 1, profiles, order: { - minimax: Object.keys(profiles), + [MODEL_PROVIDER_KEY]: Object.keys(profiles), }, lastGood: { - minimax: 'minimax:default', + [MODEL_PROVIDER_KEY]: MODEL_AUTH_PROFILE_ID, }, }, }); diff --git a/scripts/prepare-nianxx-play-bundle.mjs b/scripts/prepare-nianxx-play-bundle.mjs deleted file mode 100644 index de5ddcf..0000000 --- a/scripts/prepare-nianxx-play-bundle.mjs +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { - chmodSync, - cpSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; -import { basename, dirname, join, resolve, relative } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '..'); -const DEFAULT_SOURCE = resolve(ROOT, '..', '..', 'NianxxPlay'); -const SOURCE_DIR = resolve(process.env.NIANXX_PLAY_DIR || DEFAULT_SOURCE); -const OUTPUT_DIR = resolve(ROOT, 'build', 'apps', 'nianxx-play'); -const BUNDLE_RUNTIME_ENV = process.env.NIANXX_PLAY_BUNDLE_ENV === '1'; -const RUNTIME_ENV_FILE_NAME = '.env.runtime'; - -function log(message) { - console.log(`[nianxx-play-bundle] ${message}`); -} - -function fail(message) { - console.error(`[nianxx-play-bundle] ${message}`); - process.exit(1); -} - -function run(command, args, cwd) { - const result = spawnSync(command, args, { - cwd, - env: { - ...process.env, - NEXT_TELEMETRY_DISABLED: '1', - }, - stdio: 'inherit', - shell: process.platform === 'win32', - }); - if (result.status !== 0) { - fail(`${command} ${args.join(' ')} failed with exit code ${result.status ?? 'unknown'}`); - } -} - -function readPackageVersion(packageJsonPath) { - try { - const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - return typeof pkg.version === 'string' ? pkg.version : '0.0.0'; - } catch { - return '0.0.0'; - } -} - -function parseEnvFile(envPath) { - if (!existsSync(envPath)) return []; - const entries = []; - const raw = readFileSync(envPath, '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; - const key = match[1]; - let value = match[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - entries.push({ key, value }); - } - return entries; -} - -function selectRuntimeEnvFile(sourceDir) { - const explicit = process.env.NIANXX_PLAY_ENV_FILE?.trim(); - if (explicit) { - const resolved = resolve(explicit); - return existsSync(resolved) ? resolved : undefined; - } - for (const fileName of ['.env.local', '.env.production.local', '.env.production', '.env']) { - const candidate = join(sourceDir, fileName); - if (existsSync(candidate)) return candidate; - } - return undefined; -} - -function encodeEnvValue(value) { - return JSON.stringify(value); -} - -function writeRuntimeEnvFile(sourceDir, outputDir) { - if (!BUNDLE_RUNTIME_ENV) return { bundled: false, values: 0 }; - const envFile = selectRuntimeEnvFile(sourceDir); - if (!envFile) { - fail('NIANXX_PLAY_BUNDLE_ENV=1 set, but no NianxxPlay env file was found.'); - } - const entries = parseEnvFile(envFile); - if (!entries.length) { - fail(`NIANXX_PLAY_BUNDLE_ENV=1 set, but env file has no usable entries: ${envFile}`); - } - const runtimeEnvPath = join(outputDir, RUNTIME_ENV_FILE_NAME); - const text = [ - '# Bundled only for internal testing. Do not use in production builds.', - ...entries.map((entry) => `${entry.key}=${encodeEnvValue(entry.value)}`), - '', - ].join('\n'); - writeFileSync(runtimeEnvPath, text, 'utf8'); - return { bundled: true, values: entries.length }; -} - -function collectSecretLikeEnvValues(sourceDir) { - const envFileNames = [ - '.env', - '.env.local', - '.env.production', - '.env.production.local', - ]; - const sensitiveKeyPattern = /(SECRET|TOKEN|PASSWORD|PRIVATE|AUTH|API_KEY|ACCESS_KEY|KEY_ID|KEY_SECRET|SERVICE_ROLE)/i; - const ignoredValues = new Set(['true', 'false', 'null', 'undefined', 'development', 'production']); - const values = []; - for (const fileName of envFileNames) { - for (const entry of parseEnvFile(join(sourceDir, fileName))) { - if (!sensitiveKeyPattern.test(entry.key)) continue; - if (!entry.value || entry.value.length < 8) continue; - if (ignoredValues.has(entry.value.toLowerCase())) continue; - values.push(entry); - } - } - return values; -} - -function shouldCopyPublic(src) { - const name = basename(src); - if (name === 'uploads') return false; - const publicRelative = relative(publicDir, src).split('\\').join('/'); - if (publicRelative === 'generated-results' || publicRelative.startsWith('generated-results/')) return false; - if (name.startsWith('.env')) return false; - return true; -} - -function shouldCopyRuntime(src) { - const name = basename(src); - if (name.startsWith('.env')) return false; - if (name === '.data' || name === '.git' || name === '.next-cache') return false; - const runtimeRelative = relative(standaloneDir, src).split('\\').join('/'); - if (runtimeRelative === 'public/uploads' || runtimeRelative.startsWith('public/uploads/')) return false; - if (runtimeRelative === 'public/generated-results' || runtimeRelative.startsWith('public/generated-results/')) return false; - if (runtimeRelative === 'uploads' || runtimeRelative.startsWith('uploads/')) return false; - if (runtimeRelative === 'generated-results' || runtimeRelative.startsWith('generated-results/')) return false; - return true; -} - -function copyDir(from, to, filter = () => true) { - if (!existsSync(from)) return false; - mkdirSync(dirname(to), { recursive: true }); - cpSync(from, to, { - recursive: true, - dereference: true, - filter, - }); - return true; -} - -function normalizeBundlePermissions(dir) { - if (!existsSync(dir)) return; - const stats = statSync(dir); - try { - if (stats.isDirectory()) { - chmodSync(dir, 0o755); - for (const entry of readdirSync(dir)) { - normalizeBundlePermissions(join(dir, entry)); - } - return; - } - if (stats.isFile()) { - const executable = (stats.mode & 0o111) !== 0; - chmodSync(dir, executable ? 0o755 : 0o644); - } - } catch (error) { - fail(`Unable to normalize bundle permissions for ${relative(ROOT, dir)}: ${error.message}`); - } -} - -function dirSizeBytes(dir) { - if (!existsSync(dir)) return 0; - const stats = statSync(dir); - if (stats.isFile()) return stats.size; - let size = 0; - for (const entry of readdirSync(dir)) { - size += dirSizeBytes(join(dir, entry)); - } - return size; -} - -function assertNoEnvFiles(dir) { - const allowedRuntimeEnvPath = BUNDLE_RUNTIME_ENV - ? join(OUTPUT_DIR, RUNTIME_ENV_FILE_NAME) - : undefined; - const stack = [dir]; - while (stack.length) { - const current = stack.pop(); - for (const entry of readdirSync(current)) { - const fullPath = join(current, entry); - if (allowedRuntimeEnvPath && fullPath === allowedRuntimeEnvPath) { - continue; - } - if (basename(entry).startsWith('.env')) { - fail(`Refusing to ship env file: ${relative(ROOT, fullPath)}`); - } - if (statSync(fullPath).isDirectory()) stack.push(fullPath); - } - } -} - -function assertNoForbiddenBundlePaths(outputDir) { - const forbidden = [ - join(outputDir, 'public', 'uploads'), - join(outputDir, 'public', 'generated-results'), - join(outputDir, '.next', 'cache'), - ]; - for (const target of forbidden) { - if (existsSync(target)) { - fail(`Refusing to ship development/user data path: ${relative(ROOT, target)}`); - } - } -} - -function shouldScanForSecrets(filePath) { - const stats = statSync(filePath); - if (!stats.isFile()) return false; - if (stats.size > 5 * 1024 * 1024) return false; - return /\.(?:js|json|html|css|txt|mjs|cjs|map)$/i.test(filePath); -} - -function assertNoSecretValues(dir, secretEntries) { - if (!secretEntries.length) return; - const stack = [dir]; - while (stack.length) { - const current = stack.pop(); - for (const entry of readdirSync(current)) { - const fullPath = join(current, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) { - stack.push(fullPath); - continue; - } - if (BUNDLE_RUNTIME_ENV && fullPath === join(OUTPUT_DIR, RUNTIME_ENV_FILE_NAME)) continue; - if (!shouldScanForSecrets(fullPath)) continue; - const content = readFileSync(fullPath, 'utf8'); - for (const secret of secretEntries) { - if (content.includes(secret.value)) { - fail(`Refusing to ship bundle: secret-like env value "${secret.key}" appears in ${relative(ROOT, fullPath)}`); - } - } - } - } -} - -if (process.env.SKIP_NIANXX_PLAY_BUNDLE === '1') { - log('SKIP_NIANXX_PLAY_BUNDLE=1 set, skipping.'); - process.exit(0); -} - -if (!existsSync(join(SOURCE_DIR, 'package.json'))) { - fail(`NianxxPlay source not found: ${SOURCE_DIR}`); -} - -const sourceVersion = readPackageVersion(join(SOURCE_DIR, 'package.json')); -const secretLikeEnvValues = collectSecretLikeEnvValues(SOURCE_DIR); -log(`source: ${SOURCE_DIR}`); -log(`version: ${sourceVersion}`); - -if (process.env.NIANXX_PLAY_SKIP_BUILD !== '1') { - log('building Next.js standalone runtime...'); - run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build'], SOURCE_DIR); -} else { - log('NIANXX_PLAY_SKIP_BUILD=1 set, reusing existing .next output.'); -} - -const standaloneDir = join(SOURCE_DIR, '.next', 'standalone'); -const staticDir = join(SOURCE_DIR, '.next', 'static'); -const publicDir = join(SOURCE_DIR, 'public'); -const contentDir = join(SOURCE_DIR, 'content'); - -if (!existsSync(join(standaloneDir, 'server.js'))) { - fail(`Missing Next.js standalone server: ${join(standaloneDir, 'server.js')}`); -} -if (!existsSync(staticDir)) { - fail(`Missing Next.js static output: ${staticDir}`); -} - -rmSync(OUTPUT_DIR, { recursive: true, force: true }); -mkdirSync(OUTPUT_DIR, { recursive: true }); - -log(`copying standalone runtime -> ${OUTPUT_DIR}`); -copyDir(standaloneDir, OUTPUT_DIR, shouldCopyRuntime); -copyDir(staticDir, join(OUTPUT_DIR, '.next', 'static'), shouldCopyRuntime); -copyDir(publicDir, join(OUTPUT_DIR, 'public'), shouldCopyPublic); -copyDir(contentDir, join(OUTPUT_DIR, 'content'), shouldCopyRuntime); -const runtimeEnv = writeRuntimeEnvFile(SOURCE_DIR, OUTPUT_DIR); - -normalizeBundlePermissions(OUTPUT_DIR); -assertNoEnvFiles(OUTPUT_DIR); -assertNoForbiddenBundlePaths(OUTPUT_DIR); -assertNoSecretValues(OUTPUT_DIR, secretLikeEnvValues); - -const manifest = { - appId: 'nianxx-play', - name: 'NianxxPlay', - version: sourceVersion, - bundledAt: new Date().toISOString(), - runtime: 'next-standalone', - entry: 'server.js', - excludes: ['.env*', '.data', 'public/uploads', 'public/generated-results', 'development caches'], - secretScan: { - checked: true, - sourceEnvValues: secretLikeEnvValues.length, - }, - runtimeEnv: runtimeEnv.bundled - ? { - bundled: true, - file: RUNTIME_ENV_FILE_NAME, - values: runtimeEnv.values, - purpose: 'internal-testing-only', - } - : { - bundled: false, - }, - sizeBytes: dirSizeBytes(OUTPUT_DIR), -}; - -writeFileSync(join(OUTPUT_DIR, 'bundle-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); -log(`ready: ${OUTPUT_DIR}`); -log(`size: ${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB`); diff --git a/shared/yinian-model.ts b/shared/yinian-model.ts new file mode 100644 index 0000000..0eacf8f --- /dev/null +++ b/shared/yinian-model.ts @@ -0,0 +1,31 @@ +export const YINIAN_MODEL_PROVIDER_KEY = 'yinian-model'; +export const YINIAN_MODEL_DEFAULT_ID = 'custom-model'; +export const YINIAN_MODEL_DEFAULT_NAME = 'Custom Model'; +export const YINIAN_MODEL_DEFAULT_BASE_URL = 'https://api.example.com/v1'; +export const YINIAN_MODEL_DEFAULT_API = 'openai-completions'; +export const YINIAN_MODEL_AUTH_PROFILE_ID = `${YINIAN_MODEL_PROVIDER_KEY}:default`; +export const YINIAN_MODEL_REF = `${YINIAN_MODEL_PROVIDER_KEY}/${YINIAN_MODEL_DEFAULT_ID}`; + +export const YINIAN_MODEL_ENTRY = { + id: YINIAN_MODEL_DEFAULT_ID, + name: YINIAN_MODEL_DEFAULT_NAME, + input: ['text'], +} as const; + +export const YINIAN_LEGACY_MODEL_PROVIDER_KEYS = [ + 'minimax', + 'minimax-portal', +] as const; + +export const YINIAN_LEGACY_MODEL_REFS = [ + 'minimax/MiniMax-M2.7', + 'minimax/MiniMax-M3', +] as const; + +export const YINIAN_LEGACY_MODEL_AUTH_PROFILE_IDS = [ + 'minimax:default', + 'minimax:cn', + 'minimax-cn:default', + 'minimax-portal-cn:default', + 'minimax-portal:default', +] as const; diff --git a/src/App.tsx b/src/App.tsx index b02d269..9c79f67 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,8 +20,6 @@ import { Settings } from './pages/Settings'; import { Setup } from './pages/Setup'; import { Knowledge } from './pages/Knowledge'; import { AppCenter } from './pages/AppCenter'; -import { NianxxPlay } from './pages/NianxxPlay'; -import { ProductCenter } from './pages/ProductCenter'; import { YinianLogin } from './pages/YinianLogin'; import { useSettingsStore } from './stores/settings'; import { useGatewayStore } from './stores/gateway'; @@ -433,8 +431,6 @@ function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/components/settings/AgentSystemDocumentsSettings.tsx b/src/components/settings/AgentSystemDocumentsSettings.tsx new file mode 100644 index 0000000..0996995 --- /dev/null +++ b/src/components/settings/AgentSystemDocumentsSettings.tsx @@ -0,0 +1,391 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + AlertTriangle, + CheckCircle2, + FileText, + RefreshCw, + RotateCcw, + Save, + UserRound, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { hostApiFetch } from '@/lib/host-api'; +import { toUserMessage } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; + +const DOCUMENT_ORDER = ['soul', 'identity', 'user', 'agent', 'tool', 'heartbeat', 'boot'] as const; + +type AgentSystemDocumentKind = typeof DOCUMENT_ORDER[number]; +type AgentSystemDocumentSource = 'workspace' | 'template' | 'empty'; + +type AgentSystemDocumentAgent = { + id: string; + name: string; + isDefault: boolean; + workspace: string; +}; + +type AgentSystemDocument = { + kind: AgentSystemDocumentKind; + fileName: string; + path: string; + exists: boolean; + source: AgentSystemDocumentSource; + content: string; + size: number; + updatedAt: number | null; + templateAvailable: boolean; + templatePath: string; +}; + +type AgentSystemDocumentsSnapshot = { + success: true; + selectedAgentId: string; + defaultAgentId: string; + agents: AgentSystemDocumentAgent[]; + documents: AgentSystemDocument[]; + paths: { + workspace: string; + templateDir: string; + }; +}; + +const EMPTY_DRAFTS: Record = { + soul: '', + identity: '', + user: '', + agent: '', + tool: '', + heartbeat: '', + boot: '', +}; + +function formatSize(bytes: number): string { + if (bytes <= 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +} + +function formatUpdatedAt(value: number | null): string { + if (!value) return '-'; + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'short', + timeStyle: 'short', + }).format(new Date(value)); + } catch { + return new Date(value).toLocaleString(); + } +} + +function documentMapFromSnapshot(snapshot: AgentSystemDocumentsSnapshot | null): Map { + return new Map((snapshot?.documents ?? []).map((document) => [document.kind, document])); +} + +export function AgentSystemDocumentsSettings() { + const { t } = useTranslation('settings'); + const [snapshot, setSnapshot] = useState(null); + const [selectedAgentId, setSelectedAgentId] = useState(''); + const [selectedKind, setSelectedKind] = useState('soul'); + const [drafts, setDrafts] = useState>(EMPTY_DRAFTS); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [resetting, setResetting] = useState(false); + const [error, setError] = useState(null); + + const documentMap = useMemo(() => documentMapFromSnapshot(snapshot), [snapshot]); + const selectedDocument = documentMap.get(selectedKind) ?? null; + const selectedAgent = snapshot?.agents.find((agent) => agent.id === selectedAgentId) ?? null; + const currentDraft = drafts[selectedKind] ?? selectedDocument?.content ?? ''; + const isDirty = selectedDocument ? currentDraft !== selectedDocument.content : false; + + const applySnapshot = (nextSnapshot: AgentSystemDocumentsSnapshot) => { + const nextDrafts = { ...EMPTY_DRAFTS }; + for (const document of nextSnapshot.documents) { + nextDrafts[document.kind] = document.content; + } + setSnapshot(nextSnapshot); + setSelectedAgentId(nextSnapshot.selectedAgentId); + setDrafts(nextDrafts); + if (!nextSnapshot.documents.some((document) => document.kind === selectedKind)) { + setSelectedKind('soul'); + } + }; + + const loadDocuments = async (agentId?: string) => { + setLoading(true); + setError(null); + try { + const query = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ''; + const nextSnapshot = await hostApiFetch(`/api/agent-system-documents${query}`); + applySnapshot(nextSnapshot); + } catch (loadError) { + const message = toUserMessage(loadError); + setError(message); + toast.error(t('systemDocuments.toast.loadFailed', { message })); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadDocuments(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleAgentChange = (agentId: string) => { + setSelectedAgentId(agentId); + void loadDocuments(agentId); + }; + + const handleSave = async () => { + if (!selectedDocument) return; + setSaving(true); + setError(null); + try { + const nextSnapshot = await hostApiFetch( + `/api/agent-system-documents/${selectedKind}`, + { + method: 'PUT', + body: JSON.stringify({ + agentId: selectedAgentId, + content: currentDraft, + }), + }, + ); + applySnapshot(nextSnapshot); + toast.success(t('systemDocuments.toast.saved', { fileName: selectedDocument.fileName })); + } catch (saveError) { + const message = toUserMessage(saveError); + setError(message); + toast.error(t('systemDocuments.toast.saveFailed', { message })); + } finally { + setSaving(false); + } + }; + + const handleReset = async () => { + if (!selectedDocument) return; + setResetting(true); + setError(null); + try { + const nextSnapshot = await hostApiFetch( + `/api/agent-system-documents/${selectedKind}/reset`, + { + method: 'POST', + body: JSON.stringify({ agentId: selectedAgentId }), + }, + ); + applySnapshot(nextSnapshot); + toast.success(t('systemDocuments.toast.reset', { fileName: selectedDocument.fileName })); + } catch (resetError) { + const message = toUserMessage(resetError); + setError(message); + toast.error(t('systemDocuments.toast.resetFailed', { message })); + } finally { + setResetting(false); + } + }; + + return ( +
+
+
+
+
+

+ {t('systemDocuments.title')} +

+

+ {t('systemDocuments.description')} +

+
+ +
+ +
+ + +
+
+
+ + {selectedAgent && ( +
+ + + {selectedAgent.id} + + {snapshot?.paths.workspace || selectedAgent.workspace} +
+ )} +
+ + {error && ( +
+ + {error} +
+ )} + +
+
+
+ {DOCUMENT_ORDER.map((kind) => { + const document = documentMap.get(kind); + const active = selectedKind === kind; + return ( + + ); + })} +
+
+ +
+ {selectedDocument ? ( +
+
+
+
+

+ {selectedDocument.fileName} +

+ + {t(`systemDocuments.source.${selectedDocument.source}`)} + + {isDirty && ( + + {t('systemDocuments.unsaved')} + + )} +
+

+ {t(`systemDocuments.documents.${selectedKind}.description`)} +

+
+ +
+ + +
+
+ +