v2.9.1 — Config centralization, settings route extraction, web-ui split
- tools/*.ts: Replace fs.readFileSync config reads with getConfig() + resolveSecret() (pubmed, legal, scholar, weather, email, web, files — 7 files) - tools: Remove direct getVault() calls in email.ts and weather.ts, use resolveSecret() - gateway: Extract 21 /api/settings/* routes from server-v2.ts into routes/settings.ts (server-v2.ts reduced by ~375 lines) - types: Add missing config fields (legal, semantic_scholar, openweather, weather, email, search.pubmed_api_key) - web-ui: Split index.html (13,879 lines) into index.html (1,934) + styles.css (2,530) + app.js (9,416) - web-ui: Allow static assets to bypass auth middleware - Remove empty index2.html template stub Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
import { Express, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getConfig, ConfigManager } from '../../config/config.js';
|
||||
import { resetProvider } from '../../providers/factory.js';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SENSITIVE_KEY_PATTERNS = /api[_-]?key|apikey|token|secret|password|passwd|credential/i;
|
||||
|
||||
function sanitizeLLMConfig(llm: any): any {
|
||||
if (!llm || typeof llm !== 'object') return llm;
|
||||
const copy = JSON.parse(JSON.stringify(llm));
|
||||
const codexModel = copy?.providers?.openai_codex?.model;
|
||||
if (typeof codexModel === 'string' && codexModel.trim() === 'codex-davinci-002') {
|
||||
copy.providers.openai_codex.model = 'gpt-4o';
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function redactConfigForUI(obj: any, depth = 0): any {
|
||||
if (depth > 8 || obj === null || typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(v => redactConfigForUI(v, depth + 1));
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (SENSITIVE_KEY_PATTERNS.test(k) && typeof v === 'string' && v.length > 0) {
|
||||
out[k] = '••••••••';
|
||||
} else {
|
||||
out[k] = redactConfigForUI(v, depth + 1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Route Registration ────────────────────────────────────────────────────────
|
||||
|
||||
export function registerSettingsRoutes(app: Express): void {
|
||||
const cm = getConfig();
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/search', (_req, res) => {
|
||||
const cfg = (cm.getConfig() as any).search || {};
|
||||
const maskIfSet = (val: string | undefined): string => {
|
||||
if (!val) return '';
|
||||
const resolved = cm.resolveSecret(val);
|
||||
return resolved ? '••••••••' : '';
|
||||
};
|
||||
res.json({
|
||||
preferred_provider: cfg.preferred_provider || 'ddg',
|
||||
search_rigor: cfg.search_rigor || 'verified',
|
||||
tavily_api_key: maskIfSet(cfg.tavily_api_key),
|
||||
google_api_key: maskIfSet(cfg.google_api_key),
|
||||
google_cx: cfg.google_cx || '',
|
||||
brave_api_key: maskIfSet(cfg.brave_api_key),
|
||||
ollama_model: cfg.ollama_model || '',
|
||||
searxng_url: cfg.searxng_url || '',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/search', (req, res) => {
|
||||
const { preferred_provider, search_rigor, tavily_api_key, google_api_key, google_cx, brave_api_key, ollama_model, searxng_url } = req.body;
|
||||
const current = cm.getConfig() as any;
|
||||
const isNew = (v: any) => v !== undefined && v !== '' && v !== '••••••••';
|
||||
const newSearch = {
|
||||
...((current.search || {})),
|
||||
...(preferred_provider && { preferred_provider }),
|
||||
...(search_rigor !== undefined && { search_rigor }),
|
||||
...(isNew(tavily_api_key) && { tavily_api_key }),
|
||||
...(isNew(google_api_key) && { google_api_key }),
|
||||
...(google_cx !== undefined && { google_cx }),
|
||||
...(isNew(brave_api_key) && { brave_api_key }),
|
||||
...(ollama_model !== undefined && { ollama_model }),
|
||||
...(searxng_url && { searxng_url }),
|
||||
};
|
||||
cm.updateConfig({ search: newSearch } as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Paths ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/paths', (_req, res) => {
|
||||
const cfg = cm.getConfig();
|
||||
res.json({
|
||||
workspace_path: (cfg as any).workspace?.path || '',
|
||||
allowed_paths: (cfg as any).tools?.permissions?.files?.allowed_paths || [],
|
||||
blocked_paths: (cfg as any).tools?.permissions?.files?.blocked_paths || [],
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/paths', (req, res) => {
|
||||
const { workspace_path, allowed_paths, blocked_paths } = req.body;
|
||||
const current = cm.getConfig() as any;
|
||||
const tools = {
|
||||
...current.tools,
|
||||
permissions: {
|
||||
...current.tools?.permissions,
|
||||
files: {
|
||||
...(current.tools?.permissions?.files || {}),
|
||||
...(Array.isArray(allowed_paths) && { allowed_paths }),
|
||||
...(Array.isArray(blocked_paths) && { blocked_paths }),
|
||||
},
|
||||
},
|
||||
};
|
||||
const workspacePath = typeof workspace_path === 'string' ? workspace_path.trim() : '';
|
||||
if (workspacePath) {
|
||||
try { fs.mkdirSync(workspacePath, { recursive: true }); } catch {}
|
||||
}
|
||||
cm.updateConfig({
|
||||
tools,
|
||||
...(workspacePath ? { workspace: { ...(current.workspace || {}), path: workspacePath } } : {}),
|
||||
} as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Agent ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/agent', (_req, res) => {
|
||||
const cfg = (cm.getConfig() as any).agent_policy || {};
|
||||
res.json({
|
||||
force_web_for_fresh: cfg.force_web_for_fresh !== false,
|
||||
memory_fallback_on_search_failure: cfg.memory_fallback_on_search_failure !== false,
|
||||
auto_store_web_facts: cfg.auto_store_web_facts !== false,
|
||||
natural_language_tool_router: cfg.natural_language_tool_router !== false,
|
||||
retrieval_mode: cfg.retrieval_mode || 'standard',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/agent', (req, res) => {
|
||||
const { force_web_for_fresh, memory_fallback_on_search_failure, auto_store_web_facts, natural_language_tool_router, retrieval_mode } = req.body;
|
||||
const current = cm.getConfig() as any;
|
||||
const newPolicy = {
|
||||
...(current.agent_policy || {}),
|
||||
...(force_web_for_fresh !== undefined && { force_web_for_fresh }),
|
||||
...(memory_fallback_on_search_failure !== undefined && { memory_fallback_on_search_failure }),
|
||||
...(auto_store_web_facts !== undefined && { auto_store_web_facts }),
|
||||
...(natural_language_tool_router !== undefined && { natural_language_tool_router }),
|
||||
...(retrieval_mode !== undefined && { retrieval_mode }),
|
||||
};
|
||||
cm.updateConfig({ agent_policy: newPolicy } as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Model ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/model', (_req, res) => {
|
||||
const cfg = cm.getConfig();
|
||||
res.json({
|
||||
primary: cfg.models.primary,
|
||||
roles: cfg.models.roles,
|
||||
ollama_endpoint: (cfg as any).ollama?.endpoint || 'http://localhost:11434',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/model', (req, res) => {
|
||||
const { primary, roles, ollama_endpoint } = req.body;
|
||||
const current = cm.getConfig();
|
||||
if (primary || roles) {
|
||||
const newPrimary = primary || current.models.primary;
|
||||
const filteredRoles = Object.fromEntries(
|
||||
Object.entries(roles || {}).filter(([, v]) => v !== '')
|
||||
);
|
||||
cm.updateConfig({
|
||||
models: {
|
||||
primary: newPrimary,
|
||||
roles: { ...current.models.roles, ...filteredRoles },
|
||||
}
|
||||
});
|
||||
const llmCurrent = (cm.getConfig() as any).llm || {};
|
||||
const activeProv = llmCurrent.provider || 'ollama';
|
||||
if (newPrimary && llmCurrent.providers?.[activeProv] !== undefined) {
|
||||
const updatedLlm = { ...llmCurrent, providers: { ...llmCurrent.providers, [activeProv]: { ...llmCurrent.providers[activeProv], model: newPrimary } } };
|
||||
cm.updateConfig({ llm: updatedLlm } as any);
|
||||
}
|
||||
}
|
||||
if (ollama_endpoint) {
|
||||
cm.updateConfig({
|
||||
ollama: { ...(current as any).ollama, endpoint: ollama_endpoint }
|
||||
} as any);
|
||||
}
|
||||
resetProvider();
|
||||
res.json({ success: true, model: cm.getConfig().models.primary });
|
||||
});
|
||||
|
||||
// ── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/provider', (_req, res) => {
|
||||
const raw = cm.getConfig() as any;
|
||||
const llmRaw = raw.llm || {
|
||||
provider: 'ollama',
|
||||
providers: { ollama: { endpoint: raw.ollama?.endpoint || 'http://localhost:11434', model: raw.models?.primary || '' } },
|
||||
};
|
||||
const llm = redactConfigForUI(sanitizeLLMConfig(llmRaw));
|
||||
res.json({ success: true, llm, models_primary: raw.models?.primary || '' });
|
||||
});
|
||||
|
||||
app.post('/api/settings/provider', (req, res) => {
|
||||
try {
|
||||
const llm = sanitizeLLMConfig(req.body?.llm);
|
||||
if (!llm?.provider) { res.status(400).json({ success: false, error: 'Missing llm.provider' }); return; }
|
||||
const stored = (cm.getConfig() as any)?.llm || {};
|
||||
const storedProviders: Record<string, any> = stored.providers || {};
|
||||
const mergedProviders: Record<string, any> = { ...storedProviders };
|
||||
for (const [provId, provConf] of Object.entries((llm.providers || {}) as Record<string, any>)) {
|
||||
const existing: Record<string, any> = storedProviders[provId] || {};
|
||||
const merged: Record<string, any> = { ...existing, ...provConf };
|
||||
if ((provConf as any)?.api_key === '••••••••' && existing.api_key) merged.api_key = existing.api_key;
|
||||
if ((provConf as any)?.token === '••••••••' && existing.token) merged.token = existing.token;
|
||||
if (!(provConf as any)?.model && existing.model) merged.model = existing.model;
|
||||
if (!(provConf as any)?.endpoint && existing.endpoint) merged.endpoint = existing.endpoint;
|
||||
mergedProviders[provId] = merged;
|
||||
}
|
||||
const mergedLlm = { ...stored, ...llm, providers: mergedProviders };
|
||||
cm.updateConfig({ llm: mergedLlm } as any);
|
||||
const activeProviderModel = llm.providers?.[llm.provider]?.model;
|
||||
if (activeProviderModel) {
|
||||
const current = cm.getConfig();
|
||||
cm.updateConfig({
|
||||
models: { ...current.models, primary: activeProviderModel }
|
||||
});
|
||||
}
|
||||
resetProvider();
|
||||
res.json({ success: true, models_primary: cm.getConfig().models?.primary || '' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Hooks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/hooks', (_req, res) => {
|
||||
const cfg = (cm.getConfig() as any).hooks || {};
|
||||
res.json({
|
||||
success: true,
|
||||
hooks: {
|
||||
enabled: cfg.enabled === true,
|
||||
token: cfg.token ? '••••••••' : '',
|
||||
tokenSet: !!cfg.token,
|
||||
path: cfg.path || '/hooks',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/hooks', (req, res) => {
|
||||
try {
|
||||
const { enabled, token, path: hookPath } = req.body || {};
|
||||
const current = (cm.getConfig() as any).hooks || {};
|
||||
const updated = {
|
||||
enabled: enabled === true,
|
||||
token: token && token !== '••••••••' ? String(token).trim() : (current.token || ''),
|
||||
path: hookPath ? String(hookPath).trim() : (current.path || '/hooks'),
|
||||
};
|
||||
cm.updateConfig({ hooks: updated } as any);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/settings/hooks/test', async (_req, res) => {
|
||||
try {
|
||||
const cfg = (cm.getConfig() as any).hooks || {};
|
||||
if (!cfg.enabled) { res.json({ success: false, error: 'Webhooks are disabled' }); return; }
|
||||
if (!cfg.token) { res.json({ success: false, error: 'No token configured' }); return; }
|
||||
res.json({ success: true, message: 'Webhook endpoint is active', path: cfg.path || '/hooks' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── PPT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/ppt', (_req, res) => {
|
||||
try {
|
||||
const cfg = cm.getConfig() as any;
|
||||
res.json({ template: cfg.ppt?.template || 'business', skin: cfg.ppt?.skin || '', engine: cfg.ppt?.engine || 'python' });
|
||||
} catch {
|
||||
res.json({ template: 'business', skin: '', engine: 'python' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/settings/ppt', (req, res) => {
|
||||
try {
|
||||
const updates: any = { ppt: {} };
|
||||
if (req.body.template !== undefined) updates.ppt.template = String(req.body.template);
|
||||
if (req.body.skin !== undefined) updates.ppt.skin = String(req.body.skin);
|
||||
if (req.body.engine !== undefined) {
|
||||
const engine = String(req.body.engine).toLowerCase();
|
||||
if (engine === 'python') {
|
||||
updates.ppt.engine = engine;
|
||||
} else {
|
||||
res.status(400).json({ error: 'engine must be "python"' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const existing = (cm.getConfig() as any).ppt || {};
|
||||
updates.ppt = { ...existing, ...updates.ppt };
|
||||
cm.updateConfig(updates);
|
||||
res.json({ success: true, ppt: updates.ppt });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── OpenWeather ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/openweather', (_req, res) => {
|
||||
const cfg = (cm.getConfig() as any).openweather || {};
|
||||
const maskIfSet = (val: string | undefined): string => {
|
||||
if (!val) return '';
|
||||
const resolved = cm.resolveSecret(val);
|
||||
return resolved ? '••••••••' : '';
|
||||
};
|
||||
res.json({ api_key: maskIfSet(cfg.api_key) });
|
||||
});
|
||||
|
||||
app.post('/api/settings/openweather', (req, res) => {
|
||||
const { api_key } = req.body;
|
||||
const current = cm.getConfig() as any;
|
||||
if (api_key !== undefined && api_key !== '' && api_key !== '••••••••') {
|
||||
cm.updateConfig({ openweather: { ...(current.openweather || {}), api_key } } as any);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Voice ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/voice', (_req, res) => {
|
||||
const cfg = (cm.getConfig() as any).voice || {};
|
||||
res.json({
|
||||
enabled: cfg.enabled === true,
|
||||
stt: {
|
||||
provider: cfg.stt?.provider || 'whisper_cpp',
|
||||
model: cfg.stt?.model || 'ggml-base.bin',
|
||||
language: cfg.stt?.language || 'ko',
|
||||
},
|
||||
tts: {
|
||||
provider: cfg.tts?.provider || 'piper',
|
||||
model: cfg.tts?.model || 'ko_KR-kss-medium',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/voice', (req, res) => {
|
||||
const { enabled, stt, tts } = req.body;
|
||||
const current = (cm.getConfig() as any).voice || {};
|
||||
const updated = {
|
||||
...current,
|
||||
...(enabled !== undefined && { enabled: Boolean(enabled) }),
|
||||
...(stt && {
|
||||
stt: {
|
||||
...(current.stt || {}),
|
||||
...(stt.provider !== undefined && { provider: stt.provider }),
|
||||
...(stt.model !== undefined && { model: stt.model }),
|
||||
...(stt.language !== undefined && { language: stt.language }),
|
||||
},
|
||||
}),
|
||||
...(tts && {
|
||||
tts: {
|
||||
...(current.tts || {}),
|
||||
...(tts.provider !== undefined && { provider: tts.provider }),
|
||||
...(tts.model !== undefined && { model: tts.model }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
cm.updateConfig({ voice: updated } as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Session ─────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/session', (_req, res) => {
|
||||
const cfg = (cm.getConfig() as any).session || {};
|
||||
res.json({
|
||||
maxMessages: cfg.maxMessages ?? 120,
|
||||
compactionThreshold: cfg.compactionThreshold ?? 0.7,
|
||||
memoryFlushThreshold: cfg.memoryFlushThreshold ?? 0.75,
|
||||
historyTurns: cfg.historyTurns ?? 8,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/session', (req, res) => {
|
||||
const { maxMessages, compactionThreshold, memoryFlushThreshold, historyTurns } = req.body;
|
||||
const current = (cm.getConfig() as any).session || {};
|
||||
const updated = {
|
||||
...current,
|
||||
...(maxMessages !== undefined && { maxMessages: Number(maxMessages) }),
|
||||
...(compactionThreshold !== undefined && { compactionThreshold: Number(compactionThreshold) }),
|
||||
...(memoryFlushThreshold !== undefined && { memoryFlushThreshold: Number(memoryFlushThreshold) }),
|
||||
...(historyTurns !== undefined && { historyTurns: Number(historyTurns) }),
|
||||
};
|
||||
cm.updateConfig({ session: updated } as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
}
|
||||
+6
-376
@@ -46,6 +46,7 @@ import { SkillsManager } from './skills-manager';
|
||||
import { writeSkillPackFromContent } from '../skills/processor.js';
|
||||
import { summarizeSkillForApi } from '../tools/skills.js';
|
||||
import { getToolRegistry } from '../tools/registry.js';
|
||||
import { registerSettingsRoutes } from './routes/settings.js';
|
||||
import {
|
||||
browserOpen,
|
||||
browserSnapshot,
|
||||
@@ -8185,8 +8186,10 @@ app.use((req, _res, next) => {
|
||||
const auth = getAuthConfig();
|
||||
if (!auth.enabled) return next();
|
||||
|
||||
// Allow auth endpoints and login page without authentication
|
||||
// Allow auth endpoints, login page, and static assets without authentication
|
||||
if (req.path.startsWith('/api/auth/') || req.path === '/login.html') return next();
|
||||
// Static assets (CSS, JS, images, fonts) are always public
|
||||
if (/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(req.path)) return next();
|
||||
|
||||
const token = extractAuthToken(req);
|
||||
const session = token ? activeSessions.get(token) : undefined;
|
||||
@@ -10909,55 +10912,8 @@ app.post('/api/schedules/parse', (req: any, res: any) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.get('/api/settings/search', (_req, res) => {
|
||||
const cm = getConfig();
|
||||
const cfg = (cm.getConfig() as any).search || {};
|
||||
// Resolve then mask — never send actual key values to the browser.
|
||||
// Returns '••••••••' if a key is present (vault or plaintext), '' if not set.
|
||||
const maskIfSet = (val: string | undefined): string => {
|
||||
if (!val) return '';
|
||||
const resolved = cm.resolveSecret(val);
|
||||
return resolved ? '••••••••' : '';
|
||||
};
|
||||
res.json({
|
||||
preferred_provider: cfg.preferred_provider || 'ddg',
|
||||
search_rigor: cfg.search_rigor || 'verified',
|
||||
tavily_api_key: maskIfSet(cfg.tavily_api_key),
|
||||
google_api_key: maskIfSet(cfg.google_api_key),
|
||||
google_cx: cfg.google_cx || '',
|
||||
brave_api_key: maskIfSet(cfg.brave_api_key),
|
||||
ollama_model: cfg.ollama_model || '',
|
||||
searxng_url: cfg.searxng_url || '',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/search', (req, res) => {
|
||||
const { preferred_provider, search_rigor, tavily_api_key, google_api_key, google_cx, brave_api_key, ollama_model, searxng_url } = req.body;
|
||||
const cm = getConfig();
|
||||
const current = cm.getConfig() as any;
|
||||
// Only write a key field if the user actually entered a new value.
|
||||
// '••••••••' means "leave existing vault entry alone".
|
||||
const isNew = (v: any) => v !== undefined && v !== '' && v !== '••••••••';
|
||||
const newSearch = {
|
||||
...((current.search || {})),
|
||||
// preferred_provider: only write if non-empty (empty = tab never opened, keep existing)
|
||||
...(preferred_provider && { preferred_provider }),
|
||||
...(search_rigor !== undefined && { search_rigor }),
|
||||
...(isNew(tavily_api_key) && { tavily_api_key }),
|
||||
...(isNew(google_api_key) && { google_api_key }),
|
||||
...(google_cx !== undefined && { google_cx }),
|
||||
...(isNew(brave_api_key) && { brave_api_key }),
|
||||
...(ollama_model !== undefined && { ollama_model }),
|
||||
// searxng_url: only write if non-empty (empty = tab never opened, keep existing)
|
||||
...(searxng_url && { searxng_url }),
|
||||
};
|
||||
cm.updateConfig({ search: newSearch } as any);
|
||||
// migrateSecretsToVault() runs inside updateConfig → saveConfig, so any
|
||||
// plaintext key just entered is automatically encrypted and replaced with
|
||||
// a "vault:<key>" reference before hitting disk.
|
||||
res.json({ success: true });
|
||||
});
|
||||
// ── Settings routes (extracted to routes/settings.ts) ──────────────────────────
|
||||
registerSettingsRoutes(app);
|
||||
|
||||
// GET /api/credentials/status — list which vault keys are currently stored (names only, no values)
|
||||
app.get('/api/credentials/status', (_req, res) => {
|
||||
@@ -10983,116 +10939,6 @@ app.get('/api/credentials/audit', (_req, res) => {
|
||||
res.json({ success: false, lines: [], error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/settings/paths', (_req, res) => {
|
||||
const cfg = getConfig().getConfig();
|
||||
res.json({
|
||||
workspace_path: (cfg as any).workspace?.path || '',
|
||||
allowed_paths: (cfg as any).tools?.permissions?.files?.allowed_paths || [],
|
||||
blocked_paths: (cfg as any).tools?.permissions?.files?.blocked_paths || [],
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/paths', (req, res) => {
|
||||
const { workspace_path, allowed_paths, blocked_paths } = req.body;
|
||||
const cm = getConfig();
|
||||
const current = cm.getConfig() as any;
|
||||
const tools = {
|
||||
...current.tools,
|
||||
permissions: {
|
||||
...current.tools?.permissions,
|
||||
files: {
|
||||
...(current.tools?.permissions?.files || {}),
|
||||
...(Array.isArray(allowed_paths) && { allowed_paths }),
|
||||
...(Array.isArray(blocked_paths) && { blocked_paths }),
|
||||
},
|
||||
},
|
||||
};
|
||||
const workspacePath = typeof workspace_path === 'string' ? workspace_path.trim() : '';
|
||||
if (workspacePath) {
|
||||
try { fs.mkdirSync(workspacePath, { recursive: true }); } catch {}
|
||||
}
|
||||
cm.updateConfig({
|
||||
tools,
|
||||
...(workspacePath ? { workspace: { ...(current.workspace || {}), path: workspacePath } } : {}),
|
||||
} as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/settings/agent', (_req, res) => {
|
||||
const cfg = (getConfig().getConfig() as any).agent_policy || {};
|
||||
res.json({
|
||||
force_web_for_fresh: cfg.force_web_for_fresh !== false,
|
||||
memory_fallback_on_search_failure: cfg.memory_fallback_on_search_failure !== false,
|
||||
auto_store_web_facts: cfg.auto_store_web_facts !== false,
|
||||
natural_language_tool_router: cfg.natural_language_tool_router !== false,
|
||||
retrieval_mode: cfg.retrieval_mode || 'standard',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/agent', (req, res) => {
|
||||
const { force_web_for_fresh, memory_fallback_on_search_failure, auto_store_web_facts, natural_language_tool_router, retrieval_mode } = req.body;
|
||||
const cm = getConfig();
|
||||
const current = cm.getConfig() as any;
|
||||
const newPolicy = {
|
||||
...(current.agent_policy || {}),
|
||||
...(force_web_for_fresh !== undefined && { force_web_for_fresh }),
|
||||
...(memory_fallback_on_search_failure !== undefined && { memory_fallback_on_search_failure }),
|
||||
...(auto_store_web_facts !== undefined && { auto_store_web_facts }),
|
||||
...(natural_language_tool_router !== undefined && { natural_language_tool_router }),
|
||||
...(retrieval_mode !== undefined && { retrieval_mode }),
|
||||
};
|
||||
cm.updateConfig({ agent_policy: newPolicy } as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
app.get('/api/settings/model', (_req, res) => {
|
||||
const cfg = getConfig().getConfig();
|
||||
res.json({
|
||||
primary: cfg.models.primary,
|
||||
roles: cfg.models.roles,
|
||||
ollama_endpoint: (cfg as any).ollama?.endpoint || 'http://localhost:11434',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/model', (req, res) => {
|
||||
const { primary, roles, ollama_endpoint } = req.body;
|
||||
const cm = getConfig();
|
||||
const current = cm.getConfig();
|
||||
if (primary || roles) {
|
||||
const newPrimary = primary || current.models.primary;
|
||||
// Filter out empty-string role values — empty means the UI never populated the field,
|
||||
// not that the user intentionally cleared it.
|
||||
const filteredRoles = Object.fromEntries(
|
||||
Object.entries(roles || {}).filter(([, v]) => v !== '')
|
||||
);
|
||||
cm.updateConfig({
|
||||
models: {
|
||||
primary: newPrimary,
|
||||
roles: { ...current.models.roles, ...filteredRoles },
|
||||
}
|
||||
});
|
||||
// Keep llm.providers.[activeProvider].model in sync with models.primary
|
||||
const llmCurrent = (cm.getConfig() as any).llm || {};
|
||||
const activeProv = llmCurrent.provider || 'ollama';
|
||||
if (newPrimary && llmCurrent.providers?.[activeProv] !== undefined) {
|
||||
const updatedLlm = { ...llmCurrent, providers: { ...llmCurrent.providers, [activeProv]: { ...llmCurrent.providers[activeProv], model: newPrimary } } };
|
||||
cm.updateConfig({ llm: updatedLlm } as any);
|
||||
}
|
||||
}
|
||||
if (ollama_endpoint) {
|
||||
cm.updateConfig({
|
||||
ollama: { ...(current as any).ollama, endpoint: ollama_endpoint }
|
||||
} as any);
|
||||
}
|
||||
// Ensure provider cache picks up the new model — otherwise in-flight
|
||||
// requests keep using the stale model and fall back to the hardcoded
|
||||
// default 'qwen3:4b' which may not exist in Ollama.
|
||||
resetProvider();
|
||||
res.json({ success: true, model: getConfig().getConfig().models.primary });
|
||||
});
|
||||
|
||||
// Fetch available Ollama models (proxies Ollama /api/tags)
|
||||
app.get('/api/ollama/models', async (_req, res) => {
|
||||
try {
|
||||
@@ -11563,56 +11409,7 @@ function redactConfigForUI(obj: any, depth = 0): any {
|
||||
}
|
||||
|
||||
// GET /api/settings/provider — return active provider config (keys redacted)
|
||||
app.get('/api/settings/provider', (_req, res) => {
|
||||
const raw = getConfig().getConfig() as any;
|
||||
const llmRaw = raw.llm || {
|
||||
provider: 'ollama',
|
||||
providers: { ollama: { endpoint: raw.ollama?.endpoint || 'http://localhost:11434', model: raw.models?.primary || '' } },
|
||||
};
|
||||
const llm = redactConfigForUI(sanitizeLLMConfig(llmRaw));
|
||||
res.json({ success: true, llm, models_primary: raw.models?.primary || '' });
|
||||
});
|
||||
|
||||
// POST /api/settings/provider — update provider config
|
||||
app.post('/api/settings/provider', (req, res) => {
|
||||
try {
|
||||
const llm = sanitizeLLMConfig(req.body?.llm);
|
||||
if (!llm?.provider) { res.status(400).json({ success: false, error: 'Missing llm.provider' }); return; }
|
||||
const configManager = getConfig();
|
||||
// Merge with existing llm config so fields the UI never touched are preserved.
|
||||
// Restores redacted api_keys (••••••••) from stored values so saves without opening
|
||||
// the models tab don't wipe credentials or revert to default provider.
|
||||
const stored = (configManager.getConfig() as any)?.llm || {};
|
||||
const storedProviders: Record<string, any> = stored.providers || {};
|
||||
const mergedProviders: Record<string, any> = { ...storedProviders };
|
||||
for (const [provId, provConf] of Object.entries((llm.providers || {}) as Record<string, any>)) {
|
||||
const existing: Record<string, any> = storedProviders[provId] || {};
|
||||
const merged: Record<string, any> = { ...existing, ...provConf };
|
||||
if (provConf?.api_key === '••••••••' && existing.api_key) merged.api_key = existing.api_key;
|
||||
if (provConf?.token === '••••••••' && existing.token) merged.token = existing.token;
|
||||
// Don't overwrite stored model/endpoint with empty — empty means the UI field was blank
|
||||
// (e.g. Models tab never opened), not that the user intentionally cleared it.
|
||||
if (!provConf?.model && existing.model) merged.model = existing.model;
|
||||
if (!provConf?.endpoint && existing.endpoint) merged.endpoint = existing.endpoint;
|
||||
mergedProviders[provId] = merged;
|
||||
}
|
||||
const mergedLlm = { ...stored, ...llm, providers: mergedProviders };
|
||||
configManager.updateConfig({ llm: mergedLlm } as any);
|
||||
// Sync models.primary with the active provider's model so both config fields stay consistent
|
||||
const activeProviderModel = llm.providers?.[llm.provider]?.model;
|
||||
if (activeProviderModel) {
|
||||
const current = configManager.getConfig();
|
||||
configManager.updateConfig({
|
||||
models: { ...current.models, primary: activeProviderModel }
|
||||
});
|
||||
}
|
||||
resetProvider();
|
||||
res.json({ success: true, models_primary: configManager.getConfig().models?.primary || '' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/models/test — test connectivity for the active (or a given) provider
|
||||
app.post('/api/models/test', async (req, res) => {
|
||||
try {
|
||||
@@ -11848,50 +11645,6 @@ app.post('/api/auth/openai/disconnect', (_req, res) => {
|
||||
clearTokens(configDir);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
app.get('/api/settings/hooks', (_req, res) => {
|
||||
const cfg = (getConfig().getConfig() as any).hooks || {};
|
||||
res.json({
|
||||
success: true,
|
||||
hooks: {
|
||||
enabled: cfg.enabled === true,
|
||||
token: cfg.token ? '••••••••' : '', // never return the real token
|
||||
tokenSet: !!cfg.token,
|
||||
path: cfg.path || '/hooks',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings/hooks', (req, res) => {
|
||||
try {
|
||||
const { enabled, token, path: hookPath } = req.body || {};
|
||||
const current = (getConfig().getConfig() as any).hooks || {};
|
||||
const updated = {
|
||||
enabled: enabled === true,
|
||||
// If the user sent the masked placeholder, keep the existing token
|
||||
token: token && token !== '••••••••' ? String(token).trim() : (current.token || ''),
|
||||
path: hookPath ? String(hookPath).trim() : (current.path || '/hooks'),
|
||||
};
|
||||
getConfig().updateConfig({ hooks: updated } as any);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/settings/hooks/test', async (req, res) => {
|
||||
try {
|
||||
const cfg = (getConfig().getConfig() as any).hooks || {};
|
||||
if (!cfg.enabled) { res.json({ success: false, error: 'Webhooks are disabled' }); return; }
|
||||
if (!cfg.token) { res.json({ success: false, error: 'No token configured' }); return; }
|
||||
res.json({ success: true, message: 'Webhook endpoint is active', path: cfg.path || '/hooks' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const PPT_SKIN_DIR = path.join(process.cwd(), 'ppt', 'skin');
|
||||
const PPT_TEMPLATE_DIR = path.join(process.cwd(), 'ppt', 'template');
|
||||
|
||||
@@ -11966,135 +11719,12 @@ app.get('/api/ppt/skins/{*skinPath}', (req, res) => {
|
||||
res.sendFile(skinFile);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/settings/ppt', (_req, res) => {
|
||||
try {
|
||||
const cfg = getConfig().getConfig() as any;
|
||||
res.json({ template: cfg.ppt?.template || 'business', skin: cfg.ppt?.skin || '', engine: cfg.ppt?.engine || 'python' });
|
||||
} catch {
|
||||
res.json({ template: 'business', skin: '', engine: 'python' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/settings/ppt', (req, res) => {
|
||||
try {
|
||||
const updates: any = { ppt: {} };
|
||||
if (req.body.template !== undefined) updates.ppt.template = String(req.body.template);
|
||||
if (req.body.skin !== undefined) updates.ppt.skin = String(req.body.skin);
|
||||
if (req.body.engine !== undefined) {
|
||||
const engine = String(req.body.engine).toLowerCase();
|
||||
if (engine === 'python') {
|
||||
updates.ppt.engine = engine;
|
||||
} else {
|
||||
res.status(400).json({ error: 'engine must be "python"' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const existing = (getConfig().getConfig() as any).ppt || {};
|
||||
updates.ppt = { ...existing, ...updates.ppt };
|
||||
getConfig().updateConfig(updates);
|
||||
res.json({ success: true, ppt: updates.ppt });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// GET /api/settings/openweather — return masked OpenWeather key status
|
||||
app.get('/api/settings/openweather', (_req, res) => {
|
||||
const cm = getConfig();
|
||||
const cfg = (cm.getConfig() as any).openweather || {};
|
||||
const maskIfSet = (val: string | undefined): string => {
|
||||
if (!val) return '';
|
||||
const resolved = cm.resolveSecret(val);
|
||||
return resolved ? '••••••••' : '';
|
||||
};
|
||||
res.json({ api_key: maskIfSet(cfg.api_key) });
|
||||
});
|
||||
|
||||
// POST /api/settings/openweather — save OpenWeather API key (vault-encrypted)
|
||||
app.post('/api/settings/openweather', (req, res) => {
|
||||
const { api_key } = req.body;
|
||||
const cm = getConfig();
|
||||
const current = cm.getConfig() as any;
|
||||
if (api_key !== undefined && api_key !== '' && api_key !== '••••••••') {
|
||||
cm.updateConfig({ openweather: { ...(current.openweather || {}), api_key } } as any);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/settings/voice — return voice config
|
||||
app.get('/api/settings/voice', (_req, res) => {
|
||||
const cfg = (getConfig().getConfig() as any).voice || {};
|
||||
res.json({
|
||||
enabled: cfg.enabled === true,
|
||||
stt: {
|
||||
provider: cfg.stt?.provider || 'whisper_cpp',
|
||||
model: cfg.stt?.model || 'ggml-base.bin',
|
||||
language: cfg.stt?.language || 'ko',
|
||||
},
|
||||
tts: {
|
||||
provider: cfg.tts?.provider || 'piper',
|
||||
model: cfg.tts?.model || 'ko_KR-kss-medium',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/settings/voice — save voice config
|
||||
app.post('/api/settings/voice', (req, res) => {
|
||||
const { enabled, stt, tts } = req.body;
|
||||
const cm = getConfig();
|
||||
const current = (cm.getConfig() as any).voice || {};
|
||||
const updated = {
|
||||
...current,
|
||||
...(enabled !== undefined && { enabled: Boolean(enabled) }),
|
||||
...(stt && {
|
||||
stt: {
|
||||
...(current.stt || {}),
|
||||
...(stt.provider !== undefined && { provider: stt.provider }),
|
||||
...(stt.model !== undefined && { model: stt.model }),
|
||||
...(stt.language !== undefined && { language: stt.language }),
|
||||
},
|
||||
}),
|
||||
...(tts && {
|
||||
tts: {
|
||||
...(current.tts || {}),
|
||||
...(tts.provider !== undefined && { provider: tts.provider }),
|
||||
...(tts.model !== undefined && { model: tts.model }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
cm.updateConfig({ voice: updated } as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/settings/session — return session config
|
||||
app.get('/api/settings/session', (_req, res) => {
|
||||
const cfg = (getConfig().getConfig() as any).session || {};
|
||||
res.json({
|
||||
maxMessages: cfg.maxMessages ?? 120,
|
||||
compactionThreshold: cfg.compactionThreshold ?? 0.7,
|
||||
memoryFlushThreshold: cfg.memoryFlushThreshold ?? 0.75,
|
||||
historyTurns: cfg.historyTurns ?? 8,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/settings/session — save session config
|
||||
app.post('/api/settings/session', (req, res) => {
|
||||
const { maxMessages, compactionThreshold, memoryFlushThreshold, historyTurns } = req.body;
|
||||
const cm = getConfig();
|
||||
const current = (cm.getConfig() as any).session || {};
|
||||
const updated = {
|
||||
...current,
|
||||
...(maxMessages !== undefined && { maxMessages: Number(maxMessages) }),
|
||||
...(compactionThreshold !== undefined && { compactionThreshold: Number(compactionThreshold) }),
|
||||
...(memoryFlushThreshold !== undefined && { memoryFlushThreshold: Number(memoryFlushThreshold) }),
|
||||
...(historyTurns !== undefined && { historyTurns: Number(historyTurns) }),
|
||||
};
|
||||
cm.updateConfig({ session: updated } as any);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/mcp/servers', (_req, res) => {
|
||||
try {
|
||||
const mgr = getMCPManager();
|
||||
|
||||
+14
-38
@@ -1,8 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getVault } from '../security/vault.js';
|
||||
import { getConfig } from '../config/config.js';
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,30 +23,10 @@ interface EmailAccount {
|
||||
config: EmailConfig;
|
||||
}
|
||||
|
||||
function resolveConfigPath(workspacePath?: string): string | null {
|
||||
const perUserPath = workspacePath
|
||||
? path.join(workspacePath, '.smallclaw', 'email-config.json')
|
||||
: null;
|
||||
const globalPath = path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
if (perUserPath && fs.existsSync(perUserPath)) return perUserPath;
|
||||
if (fs.existsSync(projectCfg)) return projectCfg;
|
||||
if (fs.existsSync(globalPath)) return globalPath;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAccount(raw: any, cfgPath: string): EmailConfig | null {
|
||||
function parseAccount(raw: any): EmailConfig | null {
|
||||
if (!raw?.imap_host || !raw?.username) return null;
|
||||
let password = raw.password || '';
|
||||
if (password.startsWith('vault:')) {
|
||||
try {
|
||||
const vault = getVault(path.dirname(cfgPath));
|
||||
const secret = vault.get(password.slice(6), 'email:resolve');
|
||||
password = secret ? secret.expose() : '';
|
||||
} catch {
|
||||
password = '';
|
||||
}
|
||||
}
|
||||
const cm = getConfig();
|
||||
let password = cm.resolveSecret(raw.password || '') || '';
|
||||
return {
|
||||
imap_host: raw.imap_host,
|
||||
imap_port: Number(raw.imap_port ?? 993),
|
||||
@@ -64,10 +43,9 @@ function parseAccount(raw: any, cfgPath: string): EmailConfig | null {
|
||||
|
||||
function getEmailConfig(accountId?: string, workspacePath?: string): EmailConfig | null {
|
||||
try {
|
||||
const cfgPath = resolveConfigPath(workspacePath);
|
||||
if (!cfgPath) return null;
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
const emailCfg = data.email;
|
||||
const cm = getConfig();
|
||||
const cfg = cm.getConfig();
|
||||
const emailCfg = cfg.email;
|
||||
if (!emailCfg) return null;
|
||||
|
||||
let e: any;
|
||||
@@ -79,7 +57,7 @@ function getEmailConfig(accountId?: string, workspacePath?: string): EmailConfig
|
||||
} else {
|
||||
e = emailCfg;
|
||||
}
|
||||
return parseAccount(e, cfgPath);
|
||||
return parseAccount(e);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -87,21 +65,20 @@ function getEmailConfig(accountId?: string, workspacePath?: string): EmailConfig
|
||||
|
||||
function getAllEmailConfigs(workspacePath?: string): EmailAccount[] {
|
||||
try {
|
||||
const cfgPath = resolveConfigPath(workspacePath);
|
||||
if (!cfgPath) return [];
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
const emailCfg = data.email;
|
||||
const cm = getConfig();
|
||||
const cfg = cm.getConfig();
|
||||
const emailCfg = cfg.email;
|
||||
if (!emailCfg) return [];
|
||||
|
||||
if (Array.isArray(emailCfg.accounts)) {
|
||||
const accounts: EmailAccount[] = [];
|
||||
for (const a of emailCfg.accounts) {
|
||||
const config = parseAccount(a, cfgPath);
|
||||
const config = parseAccount(a);
|
||||
if (config) accounts.push({ id: a.id, config });
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
const config = parseAccount(emailCfg, cfgPath);
|
||||
const config = parseAccount(emailCfg);
|
||||
const id = emailCfg.username ? emailCfg.username.split('@')[0] : 'default';
|
||||
return config ? [{ id, config }] : [];
|
||||
} catch {
|
||||
@@ -355,8 +332,7 @@ export const emailReadTool = {
|
||||
if (injectedWorkspace) {
|
||||
resolvedWorkspacePath = path.resolve(String(injectedWorkspace));
|
||||
} else {
|
||||
const cfgPath = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
const cfg = getConfig().getConfig();
|
||||
resolvedWorkspacePath = path.resolve(cfg?.workspace?.path || path.join(process.cwd(), 'workspace'));
|
||||
}
|
||||
const relDir = path.join('attachments', `uid-${uid}`);
|
||||
|
||||
+3
-11
@@ -177,11 +177,6 @@ export interface ReadToolArgs {
|
||||
|
||||
type RetrievalMode = 'fast' | 'standard' | 'deep';
|
||||
|
||||
function getLocalConfigFilePath(): string {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
return fsSync.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
}
|
||||
|
||||
let _retrievalModeCache: { mode: RetrievalMode; expiresAt: number } | null = null;
|
||||
|
||||
function getRetrievalMode(): RetrievalMode {
|
||||
@@ -189,12 +184,9 @@ function getRetrievalMode(): RetrievalMode {
|
||||
if (_retrievalModeCache && now < _retrievalModeCache.expiresAt) return _retrievalModeCache.mode;
|
||||
let mode: RetrievalMode = 'standard';
|
||||
try {
|
||||
const p = getLocalConfigFilePath();
|
||||
if (fsSync.existsSync(p)) {
|
||||
const raw = JSON.parse(fsSync.readFileSync(p, 'utf-8'));
|
||||
const m = String(raw?.agent_policy?.retrieval_mode || 'standard').toLowerCase();
|
||||
if (m === 'fast' || m === 'deep') mode = m;
|
||||
}
|
||||
const cfg = getConfig().getConfig();
|
||||
const m = String(cfg?.agent_policy?.retrieval_mode || 'standard').toLowerCase();
|
||||
if (m === 'fast' || m === 'deep') mode = m;
|
||||
} catch {}
|
||||
_retrievalModeCache = { mode, expiresAt: now + 60_000 };
|
||||
return mode;
|
||||
|
||||
+4
-11
@@ -1,21 +1,14 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getConfig } from '../config/config.js';
|
||||
|
||||
const LAW_GO_KR_BASE = 'https://www.law.go.kr/DRF';
|
||||
const COURT_LISTENER_BASE = 'https://www.courtlistener.com/api/rest/v3';
|
||||
|
||||
function getLawGoKrApiKey(): string | undefined {
|
||||
try {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfgPath = fs.existsSync(projectCfg)
|
||||
? projectCfg
|
||||
: path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
if (fs.existsSync(cfgPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
return data.legal?.law_go_kr_api_key;
|
||||
}
|
||||
const cm = getConfig();
|
||||
const cfg = cm.getConfig();
|
||||
return cm.resolveSecret(cfg.legal?.law_go_kr_api_key) || undefined;
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+4
-10
@@ -1,6 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { ToolResult } from '../types.js';
|
||||
@@ -13,14 +12,9 @@ const PMC_OA_BASE = 'https://www.ncbi.nlm.nih.gov/pmc/utils/oa/oa.fcgi';
|
||||
|
||||
function getPubMedApiKey(): string | undefined {
|
||||
try {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfgPath = fs.existsSync(projectCfg)
|
||||
? projectCfg
|
||||
: path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
if (fs.existsSync(cfgPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
return data.pubmed?.api_key || data.search?.pubmed_api_key;
|
||||
}
|
||||
const cm = getConfig();
|
||||
const cfg = cm.getConfig();
|
||||
return cm.resolveSecret(cfg.pubmed?.api_key || cfg.search?.pubmed_api_key) || undefined;
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+5
-11
@@ -1,7 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getConfig } from '../config/config.js';
|
||||
|
||||
const OPENALEX_BASE = 'https://api.openalex.org';
|
||||
const S2_BASE = 'https://api.semanticscholar.org/graph/v1';
|
||||
@@ -9,14 +7,10 @@ const POLITE_EMAIL = 'research@smallclaw.local';
|
||||
|
||||
function getScholarConfig(): { s2Key?: string } {
|
||||
try {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfgPath = fs.existsSync(projectCfg)
|
||||
? projectCfg
|
||||
: path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
if (fs.existsSync(cfgPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
return { s2Key: data.semantic_scholar?.api_key || '' };
|
||||
}
|
||||
const cm = getConfig();
|
||||
const cfg = cm.getConfig();
|
||||
const key = cm.resolveSecret(cfg.semantic_scholar?.api_key);
|
||||
return { s2Key: key || '' };
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
+10
-22
@@ -1,32 +1,20 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { getVault } from '../security/vault.js';
|
||||
|
||||
const OW_BASE = 'https://api.openweathermap.org/data/2.5';
|
||||
|
||||
function getApiKey(): string | undefined {
|
||||
try {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfgPath = fs.existsSync(projectCfg)
|
||||
? projectCfg
|
||||
: path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
const configDir = path.dirname(cfgPath);
|
||||
if (fs.existsSync(cfgPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
let raw = data.openweather?.api_key || data.weather?.api_key;
|
||||
// Resolve vault reference (e.g. "vault:openweather.api_key")
|
||||
if (typeof raw === 'string' && raw.startsWith('vault:')) {
|
||||
const vaultKey = raw.slice('vault:'.length);
|
||||
const entry = getVault(configDir).get(vaultKey, 'weather:getkey');
|
||||
return entry?.expose();
|
||||
}
|
||||
if (raw) return raw;
|
||||
// Key may be in vault without a config reference
|
||||
const entry = getVault(configDir).get('openweather.api_key', 'weather:getkey');
|
||||
return entry?.expose();
|
||||
}
|
||||
const cm = getConfig();
|
||||
const cfg = cm.getConfig();
|
||||
const resolved = cm.resolveSecret(cfg.openweather?.api_key || cfg.weather?.api_key);
|
||||
if (resolved) return resolved;
|
||||
// Fallback: key may be in vault without a config reference
|
||||
const cm2 = getConfig();
|
||||
const vault = getVault(cm2.getConfigDir());
|
||||
const entry = vault.get('openweather.api_key', 'weather:getkey');
|
||||
return entry?.expose();
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+20
-28
@@ -1,7 +1,5 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getConfig } from '../config/config.js';
|
||||
|
||||
type SearchResultItem = { title: string; url: string; snippet: string };
|
||||
|
||||
@@ -463,7 +461,7 @@ function rankResults(query: string, results: SearchResultItem[]) {
|
||||
.map(x => x.r);
|
||||
}
|
||||
|
||||
// ── Load optional API keys from ~/.smallclaw/config.json ─────────────────────
|
||||
// ── Load optional API keys from config ─────────────────────────────────────
|
||||
// Cached with 5-minute TTL so config changes are picked up without restart.
|
||||
type SearchConfig = { preferred: 'tavily' | 'google' | 'brave' | 'ddg' | 'searxng'; tavilyKey?: string; googleKey?: string; googleCx?: string; braveKey?: string; searxngUrl?: string };
|
||||
let _searchConfigCache: { value: SearchConfig; expiresAt: number } | null = null;
|
||||
@@ -473,22 +471,19 @@ function getSearchConfig(): SearchConfig {
|
||||
if (_searchConfigCache && now < _searchConfigCache.expiresAt) return _searchConfigCache.value;
|
||||
let value: SearchConfig = { preferred: 'ddg' };
|
||||
try {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfg = fs.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
if (fs.existsSync(cfg)) {
|
||||
const data = JSON.parse(fs.readFileSync(cfg, 'utf-8'));
|
||||
const preferredRaw = String(data.search?.preferred_provider || 'ddg').toLowerCase();
|
||||
const preferred = (['tavily', 'google', 'brave', 'ddg', 'searxng'].includes(preferredRaw) ? preferredRaw : 'ddg') as SearchConfig['preferred'];
|
||||
const searxngRaw = typeof data.search?.searxng_url === 'string' ? data.search.searxng_url.trim().replace(/\/+$/, '') : '';
|
||||
value = {
|
||||
preferred,
|
||||
tavilyKey: data.search?.tavily_api_key,
|
||||
googleKey: data.search?.google_api_key,
|
||||
googleCx: data.search?.google_cx,
|
||||
braveKey: data.search?.brave_api_key,
|
||||
searxngUrl: searxngRaw || undefined,
|
||||
};
|
||||
}
|
||||
const cm = getConfig();
|
||||
const data = cm.getConfig();
|
||||
const preferredRaw = String(data.search?.preferred_provider || 'ddg').toLowerCase();
|
||||
const preferred = (['tavily', 'google', 'brave', 'ddg', 'searxng'].includes(preferredRaw) ? preferredRaw : 'ddg') as SearchConfig['preferred'];
|
||||
const searxngRaw = typeof data.search?.searxng_url === 'string' ? data.search.searxng_url.trim().replace(/\/+$/, '') : '';
|
||||
value = {
|
||||
preferred,
|
||||
tavilyKey: cm.resolveSecret(data.search?.tavily_api_key),
|
||||
googleKey: cm.resolveSecret(data.search?.google_api_key),
|
||||
googleCx: data.search?.google_cx,
|
||||
braveKey: cm.resolveSecret(data.search?.brave_api_key),
|
||||
searxngUrl: searxngRaw || undefined,
|
||||
};
|
||||
} catch {}
|
||||
_searchConfigCache = { value, expiresAt: now + 5 * 60_000 };
|
||||
return value;
|
||||
@@ -998,14 +993,11 @@ export const webFetchTool = {
|
||||
|
||||
function getOllamaConfig(): { endpoint: string; model: string } {
|
||||
try {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfgPath = fs.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
if (fs.existsSync(cfgPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
||||
const endpoint = String(data.ollama?.endpoint || 'http://localhost:11434');
|
||||
const model = String(data.llm?.providers?.ollama?.model || data.models?.primary || 'llama3');
|
||||
return { endpoint, model };
|
||||
}
|
||||
const cm = getConfig();
|
||||
const data = cm.getConfig();
|
||||
const endpoint = String(data.ollama?.endpoint || 'http://localhost:11434');
|
||||
const model = String(data.llm?.providers?.ollama?.model || data.models?.primary || 'llama3');
|
||||
return { endpoint, model };
|
||||
} catch {}
|
||||
return { endpoint: 'http://localhost:11434', model: 'llama3' };
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ export interface SmallClawConfig {
|
||||
brave_api_key?: string;
|
||||
searxng_url?: string;
|
||||
search_rigor?: string;
|
||||
pubmed_api_key?: string;
|
||||
};
|
||||
voice?: {
|
||||
enabled: boolean;
|
||||
@@ -374,6 +375,19 @@ export interface SmallClawConfig {
|
||||
pubmed?: {
|
||||
api_key?: string;
|
||||
};
|
||||
legal?: {
|
||||
law_go_kr_api_key?: string;
|
||||
};
|
||||
semantic_scholar?: {
|
||||
api_key?: string;
|
||||
};
|
||||
openweather?: {
|
||||
api_key?: string;
|
||||
};
|
||||
weather?: {
|
||||
api_key?: string;
|
||||
};
|
||||
email?: any;
|
||||
llm?: LLMConfig;
|
||||
orchestration?: {
|
||||
enabled: boolean;
|
||||
|
||||
+9416
File diff suppressed because it is too large
Load Diff
+2
-11946
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
<?python
|
||||
import sys
|
||||
with open('/home/claude/index.html', 'r', encoding='utf-8') as f:
|
||||
print(f.read(), end='')
|
||||
?>
|
||||
+2530
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user