504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { getOpenClawConfigDir } from './paths';
|
|
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
|
import { logger } from './logger';
|
|
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_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<string, unknown>;
|
|
|
|
type AuthProfileEntry = {
|
|
type?: unknown;
|
|
provider?: unknown;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type AuthProfilesStore = {
|
|
version?: unknown;
|
|
profiles?: Record<string, AuthProfileEntry>;
|
|
order?: Record<string, string[]>;
|
|
lastGood?: Record<string, string>;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type ModelDiagnosticStatus = 'ok' | 'warning' | 'error';
|
|
|
|
export interface YinianModelConfigCheck {
|
|
id: string;
|
|
label: string;
|
|
status: ModelDiagnosticStatus;
|
|
detail: string;
|
|
}
|
|
|
|
export interface YinianModelConfigDiagnostics {
|
|
capturedAt: number;
|
|
ok: boolean;
|
|
model: {
|
|
primary: string | null;
|
|
fallbacks: string[];
|
|
providerKey: string | null;
|
|
modelId: string | null;
|
|
};
|
|
runtime: {
|
|
heartbeatEvery: string | null;
|
|
heartbeatDisabled: boolean;
|
|
};
|
|
providers: Array<{
|
|
key: string;
|
|
configured: boolean;
|
|
baseUrl: string | null;
|
|
api: string | null;
|
|
timeoutSeconds: number | null;
|
|
authHeader: boolean | null;
|
|
modelCount: number;
|
|
}>;
|
|
authProfiles: {
|
|
path: string;
|
|
exists: boolean;
|
|
providers: Array<{
|
|
provider: string;
|
|
profileCount: number;
|
|
profileIds: string[];
|
|
types: string[];
|
|
hasDefaultProfile: boolean;
|
|
lastGoodProfileId: string | null;
|
|
}>;
|
|
};
|
|
checks: YinianModelConfigCheck[];
|
|
paths: {
|
|
openclawConfig: string;
|
|
authProfiles: string;
|
|
};
|
|
}
|
|
|
|
function isPlainRecord(value: unknown): value is JsonObject {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function arraysEqual(left: string[], right: string[]): boolean {
|
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
}
|
|
|
|
function resolveYinianEnabledSkillIds(config: JsonObject): string[] {
|
|
const skills = isPlainRecord(config.skills) ? config.skills : {};
|
|
const entries = isPlainRecord(skills.entries) ? skills.entries : {};
|
|
const enabled = Object.entries(entries)
|
|
.filter(([, value]) => !isPlainRecord(value) || value.enabled !== false)
|
|
.map(([id]) => id.trim())
|
|
.filter(Boolean);
|
|
const unique = [...new Set(enabled)];
|
|
const ordered = [
|
|
...YINIAN_FALLBACK_SKILL_IDS.filter((id) => unique.includes(id)),
|
|
...unique.filter((id) => !YINIAN_FALLBACK_SKILL_IDS.includes(id)).sort((left, right) => left.localeCompare(right)),
|
|
];
|
|
return ordered.length > 0 ? ordered : [...YINIAN_FALLBACK_SKILL_IDS];
|
|
}
|
|
|
|
function getAuthProfilesPath(agentId = 'main'): string {
|
|
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
|
|
}
|
|
|
|
async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
|
try {
|
|
const raw = await readFile(filePath, 'utf8');
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
}
|
|
|
|
function readAuthProfilesStore(filePath: string): Promise<AuthProfilesStore> {
|
|
return readJsonFile<AuthProfilesStore>(filePath).then((store) => {
|
|
if (store && isPlainRecord(store.profiles)) return store;
|
|
return { version: 1, profiles: {} };
|
|
});
|
|
}
|
|
|
|
function getPrimaryModel(config: JsonObject): string | null {
|
|
const agents = isPlainRecord(config.agents) ? config.agents : {};
|
|
const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
|
|
const model = isPlainRecord(defaults.model) ? defaults.model : {};
|
|
return typeof model.primary === 'string' && model.primary.trim() ? model.primary : null;
|
|
}
|
|
|
|
function getFallbackModels(config: JsonObject): string[] {
|
|
const agents = isPlainRecord(config.agents) ? config.agents : {};
|
|
const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
|
|
const model = isPlainRecord(defaults.model) ? defaults.model : {};
|
|
return Array.isArray(model.fallbacks)
|
|
? model.fallbacks.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
|
: [];
|
|
}
|
|
|
|
function splitModelRef(modelRef: string | null): { providerKey: string | null; modelId: string | null } {
|
|
if (!modelRef) return { providerKey: null, modelId: null };
|
|
const slashIndex = modelRef.indexOf('/');
|
|
if (slashIndex <= 0 || slashIndex >= modelRef.length - 1) {
|
|
return { providerKey: null, modelId: modelRef };
|
|
}
|
|
return {
|
|
providerKey: modelRef.slice(0, slashIndex),
|
|
modelId: modelRef.slice(slashIndex + 1),
|
|
};
|
|
}
|
|
|
|
function groupAuthProfiles(
|
|
store: AuthProfilesStore,
|
|
referencedProviderKeys?: Set<string>,
|
|
): YinianModelConfigDiagnostics['authProfiles']['providers'] {
|
|
const profiles = isPlainRecord(store.profiles) ? store.profiles : {};
|
|
const groups = new Map<string, { ids: string[]; types: Set<string> }>();
|
|
|
|
for (const [profileId, profile] of Object.entries(profiles)) {
|
|
if (!isPlainRecord(profile)) continue;
|
|
const provider = typeof profile.provider === 'string' && profile.provider.trim()
|
|
? profile.provider
|
|
: profileId.split(':')[0] || 'unknown';
|
|
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<string>() };
|
|
group.ids.push(profileId);
|
|
group.types.add(type);
|
|
groups.set(provider, group);
|
|
}
|
|
|
|
const lastGood = isPlainRecord(store.lastGood) ? store.lastGood as Record<string, unknown> : {};
|
|
return Array.from(groups.entries())
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([provider, group]) => ({
|
|
provider,
|
|
profileCount: group.ids.length,
|
|
profileIds: group.ids.sort(),
|
|
types: Array.from(group.types).sort(),
|
|
hasDefaultProfile: group.ids.includes(`${provider}:default`),
|
|
lastGoodProfileId: typeof lastGood[provider] === 'string' ? lastGood[provider] as string : null,
|
|
}));
|
|
}
|
|
|
|
function hasMatchingAuthProfile(store: AuthProfilesStore, providerKey: string | null): boolean {
|
|
if (!providerKey || !isPlainRecord(store.profiles)) return false;
|
|
return Object.values(store.profiles).some((profile) => (
|
|
isPlainRecord(profile) && profile.provider === providerKey
|
|
));
|
|
}
|
|
|
|
function hasConfiguredProvider(config: JsonObject, providerKey: string | null): boolean {
|
|
if (!providerKey) return false;
|
|
const models = isPlainRecord(config.models) ? config.models : {};
|
|
const providers = isPlainRecord(models.providers) ? models.providers : {};
|
|
return isPlainRecord(providers[providerKey]);
|
|
}
|
|
|
|
function 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 : {};
|
|
return isPlainRecord(providers[providerKey]) ? providers[providerKey] : null;
|
|
}
|
|
|
|
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 {
|
|
key,
|
|
configured: isPlainRecord(providers[key]),
|
|
baseUrl: typeof entry.baseUrl === 'string' ? entry.baseUrl : null,
|
|
api: typeof entry.api === 'string' ? entry.api : null,
|
|
timeoutSeconds: typeof entry.timeoutSeconds === 'number' ? entry.timeoutSeconds : null,
|
|
authHeader: typeof entry.authHeader === 'boolean' ? entry.authHeader : null,
|
|
modelCount: modelsList.length,
|
|
};
|
|
});
|
|
}
|
|
|
|
function getHeartbeatEvery(config: JsonObject): string | null {
|
|
const agents = isPlainRecord(config.agents) ? config.agents : {};
|
|
const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
|
|
const heartbeat = isPlainRecord(defaults.heartbeat) ? defaults.heartbeat : {};
|
|
return typeof heartbeat.every === 'string' ? heartbeat.every : null;
|
|
}
|
|
|
|
export async function ensureYinianModelRuntimeConfigured(): Promise<void> {
|
|
const config = await readOpenClawConfig() as JsonObject;
|
|
const models = isPlainRecord(config.models) ? { ...config.models } : {};
|
|
const providers = isPlainRecord(models.providers) ? { ...models.providers } : {};
|
|
let changed = false;
|
|
|
|
if ('pricing' in models) {
|
|
delete models.pricing;
|
|
changed = true;
|
|
}
|
|
|
|
for (const providerKey of YINIAN_INTERNAL_PROVIDER_KEYS) {
|
|
const currentProvider = providers[providerKey];
|
|
if (!isPlainRecord(currentProvider)) continue;
|
|
|
|
if ('timeoutSeconds' in currentProvider) {
|
|
const nextProvider = { ...currentProvider };
|
|
delete nextProvider.timeoutSeconds;
|
|
providers[providerKey] = nextProvider;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (models.mode !== 'merge') {
|
|
models.mode = 'merge';
|
|
changed = true;
|
|
}
|
|
|
|
const agents = isPlainRecord(config.agents) ? { ...config.agents } : {};
|
|
const defaults = isPlainRecord(agents.defaults) ? { ...agents.defaults } : {};
|
|
const defaultModel = isPlainRecord(defaults.model) ? { ...defaults.model } : {};
|
|
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 = [];
|
|
changed = true;
|
|
}
|
|
defaults.model = defaultModel;
|
|
const enabledSkillIds = resolveYinianEnabledSkillIds(config);
|
|
const currentDefaultSkills = Array.isArray(defaults.skills)
|
|
? defaults.skills.filter((value): value is string => typeof value === 'string')
|
|
: [];
|
|
if (!arraysEqual(currentDefaultSkills, enabledSkillIds)) {
|
|
defaults.skills = enabledSkillIds;
|
|
changed = true;
|
|
}
|
|
const heartbeat = isPlainRecord(defaults.heartbeat) ? { ...defaults.heartbeat } : {};
|
|
if (heartbeat.every !== '0m') {
|
|
heartbeat.every = '0m';
|
|
defaults.heartbeat = heartbeat;
|
|
changed = true;
|
|
}
|
|
defaults.workspace = typeof defaults.workspace === 'string' && defaults.workspace.trim()
|
|
? defaults.workspace
|
|
: join(homedir(), '.openclaw', 'workspace');
|
|
agents.defaults = defaults;
|
|
|
|
const list = Array.isArray(agents.list) ? agents.list : [];
|
|
const normalizedList = list.map((item) => {
|
|
if (!isPlainRecord(item) || item.id !== 'main') return item;
|
|
const tools = isPlainRecord(item.tools) ? item.tools : {};
|
|
const currentSkills = Array.isArray(item.skills)
|
|
? item.skills.filter((value): value is string => typeof value === 'string')
|
|
: [];
|
|
const nextSkills = arraysEqual(currentSkills, enabledSkillIds) ? currentSkills : enabledSkillIds;
|
|
return {
|
|
...item,
|
|
skills: nextSkills,
|
|
tools: {
|
|
...tools,
|
|
profile: 'coding',
|
|
},
|
|
};
|
|
});
|
|
if (JSON.stringify(normalizedList) !== JSON.stringify(list)) {
|
|
agents.list = normalizedList;
|
|
changed = true;
|
|
}
|
|
const activeList = Array.isArray(agents.list) ? agents.list : list;
|
|
const hasMainAgent = activeList.some((item) => isPlainRecord(item) && item.id === 'main');
|
|
if (!hasMainAgent) {
|
|
agents.list = [
|
|
...activeList,
|
|
{
|
|
id: 'main',
|
|
name: '智念助手',
|
|
default: true,
|
|
workspace: join(homedir(), '.openclaw', 'workspace'),
|
|
agentDir: '~/.openclaw/agents/main/agent',
|
|
skills: enabledSkillIds,
|
|
tools: { profile: 'coding' },
|
|
},
|
|
];
|
|
changed = true;
|
|
}
|
|
if (JSON.stringify(config.agents) !== JSON.stringify(agents)) {
|
|
config.agents = agents;
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
models.providers = providers;
|
|
config.models = models;
|
|
await writeOpenClawConfig(config);
|
|
logger.info('[provider-sync] Applied Yinian model runtime defaults');
|
|
}
|
|
|
|
if (shouldCleanYinianModelAuth) {
|
|
await removeYinianModelAuthProfileLeftovers();
|
|
}
|
|
}
|
|
|
|
export async function removeYinianModelAuthProfileLeftovers(agentId = 'main'): Promise<void> {
|
|
const authPath = getAuthProfilesPath(agentId);
|
|
const store = await readAuthProfilesStore(authPath);
|
|
store.version = typeof store.version === 'number' ? store.version : 1;
|
|
store.profiles = isPlainRecord(store.profiles) ? store.profiles : {};
|
|
|
|
let changed = false;
|
|
|
|
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<string, string[]>;
|
|
delete order[YINIAN_MODEL_PROVIDER_KEY];
|
|
store.order = order;
|
|
changed = true;
|
|
}
|
|
|
|
if (isPlainRecord(store.lastGood) && YINIAN_MODEL_PROVIDER_KEY in store.lastGood) {
|
|
const lastGood = { ...store.lastGood } as Record<string, string>;
|
|
delete lastGood[YINIAN_MODEL_PROVIDER_KEY];
|
|
store.lastGood = lastGood;
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
await writeJsonFile(authPath, store);
|
|
logger.info('[provider-sync] Removed legacy Yinian model auth profile leftovers');
|
|
}
|
|
}
|
|
|
|
export async function buildYinianModelConfigDiagnostics(): Promise<YinianModelConfigDiagnostics> {
|
|
const openclawConfigPath = join(getOpenClawConfigDir(), 'openclaw.json');
|
|
const authProfilesPath = getAuthProfilesPath('main');
|
|
const config = await readOpenClawConfig() as JsonObject;
|
|
const authStore = await readAuthProfilesStore(authProfilesPath);
|
|
const primary = getPrimaryModel(config);
|
|
const fallbacks = getFallbackModels(config);
|
|
const referencedProviderKeys = collectModelProviderKeys(primary, fallbacks);
|
|
const referencedProviderKeySet = new Set(referencedProviderKeys);
|
|
const { providerKey, modelId } = splitModelRef(primary);
|
|
const heartbeatEvery = getHeartbeatEvery(config);
|
|
const providerConfigured = hasConfiguredProvider(config, providerKey);
|
|
const providerEntry = getProviderEntry(config, providerKey);
|
|
const placeholderProvider = isPlaceholderModelApiProvider(providerEntry);
|
|
const authProfileConfigured = hasMatchingAuthProfile(authStore, providerKey);
|
|
const providers = buildProviderDiagnostics(config, referencedProviderKeys);
|
|
const checks: YinianModelConfigCheck[] = [
|
|
{
|
|
id: 'primary-model',
|
|
label: '默认模型',
|
|
status: primary ? 'ok' : 'error',
|
|
detail: primary ?? '未配置默认模型',
|
|
},
|
|
{
|
|
id: 'provider-entry',
|
|
label: '模型服务',
|
|
status: providerConfigured && !placeholderProvider ? 'ok' : 'error',
|
|
detail: providerKey
|
|
? (
|
|
!providerConfigured
|
|
? `缺少 ${providerKey} 服务配置`
|
|
: placeholderProvider
|
|
? `${providerKey} 仍使用示例 API 地址,请在设置中重新保存默认模型服务`
|
|
: `已配置 ${providerKey}`
|
|
)
|
|
: '无法从默认模型识别服务',
|
|
},
|
|
{
|
|
id: 'auth-profile',
|
|
label: '调用凭据',
|
|
status: authProfileConfigured ? 'ok' : 'error',
|
|
detail: providerKey
|
|
? (authProfileConfigured ? `已找到 ${providerKey} 的本地凭据` : `未找到 ${providerKey} 的本地凭据`)
|
|
: '无法检查调用凭据',
|
|
},
|
|
{
|
|
id: 'heartbeat',
|
|
label: '后台心跳',
|
|
status: heartbeatEvery === '0m' ? 'ok' : 'warning',
|
|
detail: heartbeatEvery === '0m'
|
|
? '已关闭,不会把心跳任务混入普通会话'
|
|
: `当前间隔 ${heartbeatEvery ?? '默认'},可能产生后台心跳会话`,
|
|
},
|
|
];
|
|
|
|
return {
|
|
capturedAt: Date.now(),
|
|
ok: checks.every((check) => check.status !== 'error'),
|
|
model: {
|
|
primary,
|
|
fallbacks,
|
|
providerKey,
|
|
modelId,
|
|
},
|
|
runtime: {
|
|
heartbeatEvery,
|
|
heartbeatDisabled: heartbeatEvery === '0m',
|
|
},
|
|
providers,
|
|
authProfiles: {
|
|
path: authProfilesPath,
|
|
exists: existsSync(authProfilesPath),
|
|
providers: groupAuthProfiles(authStore, referencedProviderKeySet),
|
|
},
|
|
checks,
|
|
paths: {
|
|
openclawConfig: openclawConfigPath,
|
|
authProfiles: authProfilesPath,
|
|
},
|
|
};
|
|
}
|