config.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import express from 'express';
  2. import fsPromises from 'fs/promises';
  3. import path from 'path';
  4. import { spawn } from 'child_process';
  5. import { parse as parseYaml } from 'yaml';
  6. import {
  7. buildDefaultPilotDeckConfig,
  8. configToYaml,
  9. getPilotDeckConfigPath,
  10. maskSecrets,
  11. parseConfigYaml,
  12. preserveMaskedSecrets,
  13. rawYamlToMaskedString,
  14. readPilotDeckConfigFile,
  15. validatePilotDeckConfig,
  16. writePilotDeckConfig,
  17. writeRawPilotDeckYaml,
  18. } from '../services/pilotdeckConfig.js';
  19. import { reloadPilotDeckConfig } from '../services/pilotdeckConfigReloader.js';
  20. import { suppressNextWatchEvent } from '../services/pilotdeckConfigWatcher.js';
  21. import { getPilotDeckGateway } from '../pilotdeck-bridge.js';
  22. async function notifyGatewayConfigReload() {
  23. try {
  24. const gw = await getPilotDeckGateway();
  25. if (gw?.reloadConfig) await gw.reloadConfig();
  26. } catch { /* gateway unreachable — self-watch will pick up the change */ }
  27. }
  28. const router = express.Router();
  29. function serializeConfigResponse(record, reloadResult = null) {
  30. const validation = validatePilotDeckConfig(record.config);
  31. const maskedConfig = maskSecrets(record.config);
  32. // Prefer the disk's actual YAML for the "raw" view so non-ui-internal
  33. // top-level segments (router/gateway/adapters/extension/cron/alwaysOn)
  34. // survive the trip from disk → UI. Fall back to the lossy template
  35. // only when there's no disk file yet (fresh install), so the editor
  36. // still has something editable to render.
  37. const hasDiskYaml = record.rawYaml && typeof record.rawYaml === 'object' && Object.keys(record.rawYaml).length > 0;
  38. const raw = hasDiskYaml ? rawYamlToMaskedString(record.rawYaml) : configToYaml(maskedConfig);
  39. return {
  40. exists: record.exists,
  41. path: record.configPath,
  42. raw,
  43. config: maskedConfig,
  44. validation: {
  45. valid: validation.valid,
  46. errors: validation.errors,
  47. warnings: validation.warnings,
  48. },
  49. ...(reloadResult ? { reload: reloadResult } : {}),
  50. };
  51. }
  52. function broadcastConfigEvent(payload) {
  53. process.emit('pilotdeck:config-broadcast', payload);
  54. }
  55. router.get('/', (_req, res) => {
  56. try {
  57. const record = readPilotDeckConfigFile();
  58. res.json(serializeConfigResponse(record));
  59. } catch (error) {
  60. res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
  61. }
  62. });
  63. router.post('/validate', (req, res) => {
  64. try {
  65. const raw = typeof req.body?.raw === 'string' ? req.body.raw : '';
  66. const config = raw ? parseConfigYaml(raw) : req.body?.config;
  67. const validation = validatePilotDeckConfig(config);
  68. res.status(validation.valid ? 200 : 400).json(validation);
  69. } catch (error) {
  70. res.status(400).json({ valid: false, errors: [error instanceof Error ? error.message : String(error)], warnings: [] });
  71. }
  72. });
  73. router.put('/', async (req, res) => {
  74. try {
  75. // Two submission shapes coexist:
  76. //
  77. // • `{ raw: "..." }` from the Raw YAML editor → write the
  78. // parsed YAML object to disk verbatim via
  79. // writeRawPilotDeckYaml. This is the only path that preserves
  80. // router/gateway/adapters/extension/cron/alwaysOn edits,
  81. // because the ui-internal schema doesn't model them.
  82. //
  83. // • `{ config: {...} }` from structured editors (provider
  84. // picker, memory editor, onboarding LLM step) → run through
  85. // writePilotDeckConfig, which round-trips through
  86. // ui-internal but read-modify-writes the rest from disk so
  87. // non-ui segments aren't dropped.
  88. //
  89. // Removing the `config` branch is what got 5ad9f29 reverted;
  90. // never collapse the two paths into one — they have different
  91. // semantics and different callers.
  92. const diskRecord = readPilotDeckConfigFile();
  93. const rawString = typeof req.body?.raw === 'string' ? req.body.raw : null;
  94. let saved;
  95. if (rawString !== null) {
  96. let parsed;
  97. try {
  98. parsed = parseYaml(rawString);
  99. } catch (parseErr) {
  100. return res.status(400).json({
  101. error: `Invalid YAML: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
  102. });
  103. }
  104. if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
  105. return res.status(400).json({ error: 'raw YAML must parse to an object' });
  106. }
  107. // Re-hydrate any field the UI received as "********" with the
  108. // original disk value so saving the masked view back is a no-op
  109. // for secrets the user didn't actually touch.
  110. const restored = preserveMaskedSecrets(parsed, diskRecord.rawYaml ?? {});
  111. suppressNextWatchEvent();
  112. saved = await writeRawPilotDeckYaml(restored);
  113. } else if (req.body?.config && typeof req.body.config === 'object') {
  114. const restored = preserveMaskedSecrets(req.body.config, diskRecord.config);
  115. suppressNextWatchEvent();
  116. saved = await writePilotDeckConfig(restored);
  117. } else {
  118. return res.status(400).json({ error: 'raw YAML or config object is required' });
  119. }
  120. const reloadResult = await reloadPilotDeckConfig(saved.config);
  121. void notifyGatewayConfigReload();
  122. // Re-read disk so the response's `raw` field comes from the actual
  123. // (lossless) file rather than the lossy round-trip output, and so
  124. // `serializeConfigResponse` has a `rawYaml` to render the full view.
  125. const freshRecord = readPilotDeckConfigFile();
  126. const response = serializeConfigResponse(freshRecord, reloadResult);
  127. broadcastConfigEvent({ source: 'ui-save', ...response, timestamp: new Date().toISOString() });
  128. res.json(response);
  129. } catch (error) {
  130. if (error?.validation) {
  131. return res.status(400).json({ error: error.message, validation: error.validation });
  132. }
  133. res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
  134. }
  135. });
  136. router.post('/reload', async (_req, res) => {
  137. try {
  138. const record = readPilotDeckConfigFile();
  139. const validation = validatePilotDeckConfig(record.config);
  140. if (!validation.valid) {
  141. return res.status(400).json({ error: 'Invalid config', validation });
  142. }
  143. const reloadResult = await reloadPilotDeckConfig(record.config);
  144. void notifyGatewayConfigReload();
  145. const response = serializeConfigResponse(record, reloadResult);
  146. broadcastConfigEvent({ source: 'ui-reload', ...response, timestamp: new Date().toISOString() });
  147. res.json(response);
  148. } catch (error) {
  149. res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
  150. }
  151. });
  152. router.get('/provider', (_req, res) => {
  153. try {
  154. const record = readPilotDeckConfigFile();
  155. const providers = record.config?.model?.providers;
  156. if (!providers || typeof providers !== 'object') {
  157. return res.json({ exists: false, provider: null });
  158. }
  159. const mainRef = typeof record.config?.agent?.model === 'string'
  160. ? record.config.agent.model.trim()
  161. : '';
  162. let providerId = '';
  163. let modelId = '';
  164. if (mainRef) {
  165. const slash = mainRef.indexOf('/');
  166. if (slash > 0 && slash < mainRef.length - 1) {
  167. providerId = mainRef.slice(0, slash);
  168. modelId = mainRef.slice(slash + 1);
  169. }
  170. }
  171. if (!providerId) {
  172. providerId = Object.keys(providers)[0] || '';
  173. if (providerId) {
  174. const firstModels = providers[providerId]?.models;
  175. modelId = firstModels && typeof firstModels === 'object'
  176. ? (Object.keys(firstModels)[0] || '')
  177. : '';
  178. }
  179. }
  180. if (!providerId) return res.json({ exists: false, provider: null });
  181. const provider = providers[providerId] || {};
  182. res.json({
  183. exists: true,
  184. provider: {
  185. type: provider.protocol || '',
  186. baseUrl: provider.url || '',
  187. apiKey: provider.apiKey || '',
  188. model: modelId,
  189. },
  190. });
  191. } catch (error) {
  192. res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
  193. }
  194. });
  195. router.post('/test-connection', async (req, res) => {
  196. const { providerType, baseUrl, apiKey, model } = req.body || {};
  197. if (!baseUrl || !apiKey || !model) {
  198. return res.status(400).json({ ok: false, error: 'baseUrl, apiKey, and model are required' });
  199. }
  200. // Accept V2 protocols ('openai' | 'anthropic') as well as the legacy
  201. // onboarding values ('openai-chat' | 'anthropic') for compatibility.
  202. const normalizedType = String(providerType || '').toLowerCase();
  203. const isAnthropic = normalizedType === 'anthropic';
  204. const normalizedBaseUrl = String(baseUrl).trim().replace(/\/+$/, '');
  205. const timeout = 10_000;
  206. const controller = new AbortController();
  207. const timer = setTimeout(() => controller.abort(), timeout);
  208. try {
  209. let url;
  210. let fetchOptions;
  211. if (isAnthropic) {
  212. url = `${normalizedBaseUrl}/v1/messages`;
  213. fetchOptions = {
  214. method: 'POST',
  215. headers: {
  216. 'x-api-key': apiKey,
  217. 'anthropic-version': '2023-06-01',
  218. 'content-type': 'application/json',
  219. },
  220. body: JSON.stringify({
  221. model,
  222. max_tokens: 1,
  223. messages: [{ role: 'user', content: 'Hi' }],
  224. }),
  225. signal: controller.signal,
  226. };
  227. } else {
  228. url = `${normalizedBaseUrl}/chat/completions`;
  229. fetchOptions = {
  230. method: 'POST',
  231. headers: {
  232. Authorization: `Bearer ${apiKey}`,
  233. 'content-type': 'application/json',
  234. },
  235. body: JSON.stringify({
  236. model,
  237. max_tokens: 1,
  238. messages: [{ role: 'user', content: 'Hi' }],
  239. }),
  240. signal: controller.signal,
  241. };
  242. }
  243. const response = await fetch(url, fetchOptions);
  244. clearTimeout(timer);
  245. const responseText = await response.text();
  246. if (response.ok) {
  247. let body;
  248. try {
  249. body = JSON.parse(responseText);
  250. } catch {
  251. return res.json({
  252. ok: false,
  253. error: `Expected a JSON completion response but received non-JSON content from ${url}. For OpenAI-compatible endpoints, the base URL usually ends with /v1.`,
  254. });
  255. }
  256. const hasCompletionShape = isAnthropic
  257. ? Array.isArray(body?.content) || body?.type === 'message'
  258. : Array.isArray(body?.choices);
  259. if (!hasCompletionShape) {
  260. return res.json({
  261. ok: false,
  262. error: `Endpoint returned HTTP ${response.status}, but the response was not a valid ${isAnthropic ? 'Anthropic message' : 'OpenAI chat completion'}. Check the base URL path.`,
  263. });
  264. }
  265. return res.json({ ok: true, message: `Connected successfully — Model ${model} is available.` });
  266. }
  267. let detail = `${response.status} ${response.statusText}`;
  268. try {
  269. const body = JSON.parse(responseText);
  270. if (body?.error?.message) detail = body.error.message;
  271. else if (body?.error?.type) detail = `${body.error.type}: ${body.error.message || ''}`;
  272. } catch { /* ignore parse errors */ }
  273. return res.json({ ok: false, error: `${detail}` });
  274. } catch (err) {
  275. clearTimeout(timer);
  276. if (err.name === 'AbortError') {
  277. return res.json({ ok: false, error: `Connection timed out after ${timeout / 1000}s. Check your network and API URL.` });
  278. }
  279. return res.json({ ok: false, error: err.message || String(err) });
  280. }
  281. });
  282. /**
  283. * Probe the configured web-search provider. Mirrors
  284. * `src/tool/builtin/webSearch.ts`'s GLM/Tavily/custom request shape. Returns:
  285. * `{ ok, error?, latencyMs?, organicCount? }` to match the convention
  286. * established by `/test-connection`.
  287. */
  288. router.post('/test-web-search', async (req, res) => {
  289. const { provider, apiKey, endpoint, customProvider } = req.body || {};
  290. const selectedProvider = provider === 'tavily' || provider === 'custom' ? provider : 'glm';
  291. const custom = customProvider && typeof customProvider === 'object' ? customProvider : {};
  292. const customAuth = typeof custom.auth === 'string' ? custom.auth : 'bearer';
  293. const customMethod = custom.method === 'GET' ? 'GET' : 'POST';
  294. const queryParam = typeof custom.queryParam === 'string' && custom.queryParam.trim() ? custom.queryParam.trim() : 'query';
  295. const apiKeyParam = typeof custom.apiKeyParam === 'string' && custom.apiKeyParam.trim() ? custom.apiKeyParam.trim() : 'api_key';
  296. const resultsPath = typeof custom.resultsPath === 'string' ? custom.resultsPath.trim() : '';
  297. const trimmedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
  298. if (!trimmedKey && !(selectedProvider === 'custom' && customAuth === 'none')) {
  299. return res.status(400).json({ ok: false, error: 'API key is required.' });
  300. }
  301. const trimmedEndpoint = typeof endpoint === 'string' ? endpoint.trim() : '';
  302. if (selectedProvider === 'custom' && !trimmedEndpoint) {
  303. return res.status(400).json({ ok: false, error: 'Custom provider endpoint is required.' });
  304. }
  305. const effectiveEndpoint = trimmedEndpoint || (
  306. selectedProvider === 'tavily'
  307. ? 'https://api.tavily.com/search'
  308. : 'https://api.z.ai/api/paas/v4/web_search'
  309. );
  310. let requestUrl;
  311. let requestInit;
  312. try {
  313. const url = new URL(effectiveEndpoint);
  314. if (selectedProvider === 'tavily') {
  315. requestUrl = effectiveEndpoint;
  316. requestInit = {
  317. method: 'POST',
  318. headers: {
  319. 'Content-Type': 'application/json',
  320. Accept: 'application/json',
  321. },
  322. body: JSON.stringify({
  323. api_key: trimmedKey,
  324. query: 'hello',
  325. max_results: 3,
  326. include_answer: true,
  327. search_depth: 'basic',
  328. }),
  329. };
  330. } else if (selectedProvider === 'custom') {
  331. const headers = { Accept: 'application/json' };
  332. const body = {};
  333. if (customMethod === 'GET') {
  334. url.searchParams.set(queryParam, 'hello');
  335. } else {
  336. headers['Content-Type'] = 'application/json';
  337. body[queryParam] = 'hello';
  338. }
  339. if (customAuth === 'bearer' && trimmedKey) {
  340. headers.Authorization = `Bearer ${trimmedKey}`;
  341. } else if (customAuth === 'queryApiKey' && trimmedKey) {
  342. url.searchParams.set(apiKeyParam, trimmedKey);
  343. } else if (customAuth === 'bodyApiKey' && trimmedKey) {
  344. if (customMethod === 'GET') url.searchParams.set(apiKeyParam, trimmedKey);
  345. else body[apiKeyParam] = trimmedKey;
  346. }
  347. requestUrl = url.toString();
  348. requestInit = {
  349. method: customMethod,
  350. headers,
  351. ...(customMethod === 'POST' ? { body: JSON.stringify(body) } : {}),
  352. };
  353. } else {
  354. requestUrl = effectiveEndpoint;
  355. requestInit = {
  356. method: 'POST',
  357. headers: {
  358. Authorization: `Bearer ${trimmedKey}`,
  359. 'Content-Type': 'application/json',
  360. Accept: 'application/json',
  361. },
  362. body: JSON.stringify({
  363. search_engine: 'search-prime',
  364. search_query: 'hello',
  365. count: 3,
  366. search_recency_filter: 'noLimit',
  367. }),
  368. };
  369. }
  370. } catch {
  371. return res.status(400).json({ ok: false, error: `Invalid endpoint URL: ${effectiveEndpoint}` });
  372. }
  373. const timeout = 15_000;
  374. const controller = new AbortController();
  375. const timer = setTimeout(() => controller.abort(), timeout);
  376. const t0 = Date.now();
  377. try {
  378. const response = await fetch(requestUrl, { ...requestInit, signal: controller.signal });
  379. clearTimeout(timer);
  380. const latencyMs = Date.now() - t0;
  381. let raw = null;
  382. try {
  383. raw = await response.json();
  384. } catch { /* not JSON */ }
  385. if (!response.ok) {
  386. const detail = (raw && (raw.error || raw.msg)) || `${response.status} ${response.statusText}`;
  387. return res.json({ ok: false, error: String(detail), latencyMs });
  388. }
  389. if (raw && typeof raw.error === 'string' && raw.error.length > 0) {
  390. return res.json({ ok: false, error: raw.error, latencyMs });
  391. }
  392. if (raw && typeof raw.code === 'number' && raw.code !== 0) {
  393. const msg = typeof raw.msg === 'string' ? raw.msg : 'proxy error';
  394. return res.json({ ok: false, error: `code=${raw.code}: ${msg}`, latencyMs });
  395. }
  396. const organic = selectedProvider === 'tavily'
  397. ? raw?.results
  398. : selectedProvider === 'custom' && resultsPath
  399. ? readPath(raw, resultsPath)
  400. : (raw?.search_result ?? raw?.results ?? raw?.items ?? raw?.data);
  401. const organicCount = Array.isArray(organic) ? organic.length : 0;
  402. return res.json({ ok: true, latencyMs, organicCount });
  403. } catch (err) {
  404. clearTimeout(timer);
  405. if (err.name === 'AbortError') {
  406. return res.json({ ok: false, error: `Connection timed out after ${timeout / 1000}s.` });
  407. }
  408. return res.json({ ok: false, error: err.message || String(err) });
  409. }
  410. });
  411. function readPath(value, pathValue) {
  412. return pathValue.split('.').reduce((current, segment) => {
  413. if (!current || typeof current !== 'object' || Array.isArray(current)) return undefined;
  414. return current[segment];
  415. }, value);
  416. }
  417. router.post('/open', async (_req, res) => {
  418. const configPath = getPilotDeckConfigPath();
  419. try {
  420. await fsPromises.mkdir(path.dirname(configPath), { recursive: true });
  421. try {
  422. await fsPromises.access(configPath);
  423. } catch {
  424. await fsPromises.writeFile(configPath, configToYaml(buildDefaultPilotDeckConfig()), 'utf8');
  425. }
  426. const command = process.platform === 'darwin'
  427. ? 'open'
  428. : process.platform === 'win32'
  429. ? 'cmd'
  430. : 'xdg-open';
  431. const args = process.platform === 'darwin'
  432. ? ['-R', configPath]
  433. : process.platform === 'win32'
  434. ? ['/c', 'start', '', configPath]
  435. : [path.dirname(configPath)];
  436. const child = spawn(command, args, { stdio: 'ignore', detached: true });
  437. child.unref();
  438. res.json({ success: true, path: configPath });
  439. } catch (error) {
  440. res.json({ success: false, path: configPath, error: error instanceof Error ? error.message : String(error) });
  441. }
  442. });
  443. export default router;