@yeaft/webchat-agent 0.1.667 → 0.1.669

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ import {
35
35
  import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
- import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
38
+ import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
39
  import { handleUnifyChat, handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
@@ -355,6 +355,29 @@ export async function handleMessage(msg) {
355
355
  break;
356
356
  }
357
357
 
358
+ // Search settings (web-search backend + Tavily key) — read/write the
359
+ // `search` section of config.json. `get_tavily_usage` hits Tavily's
360
+ // /usage endpoint with the saved key and is fired from the UI only
361
+ // when the Search tab opens or the user clicks "Refresh" (no polling
362
+ // — the user explicitly asked for live read on open).
363
+ case 'get_search_settings': {
364
+ const settings = getSearchSettings(ctx.CONFIG?.yeaftDir);
365
+ sendToServer({ type: 'search_settings', ...settings });
366
+ break;
367
+ }
368
+
369
+ case 'update_search_settings': {
370
+ const result = updateSearchSettings(msg.settings || msg.config || {}, ctx.CONFIG?.yeaftDir);
371
+ sendToServer({ type: 'search_settings_updated', ...result });
372
+ break;
373
+ }
374
+
375
+ case 'get_tavily_usage': {
376
+ const usage = await fetchTavilyUsage(ctx.CONFIG?.yeaftDir);
377
+ sendToServer({ type: 'tavily_usage', ...usage });
378
+ break;
379
+ }
380
+
358
381
  // Unify — independent chat via Engine
359
382
  case 'unify_chat':
360
383
  await handleUnifyChat(msg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.667",
3
+ "version": "0.1.669",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "node index.js",
13
- "dev": "nodemon index.js"
13
+ "dev": "nodemon index.js",
14
+ "postinstall": "node scripts/check-pty.js"
14
15
  },
15
16
  "engines": {
16
17
  "node": ">=22.5.0"
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent/scripts/check-pty.js — postinstall sanity check for node-pty.
4
+ *
5
+ * Why this exists:
6
+ * `node-pty` is an optionalDependency. npm install swallows install
7
+ * failures silently — when the native module fails to build (e.g.
8
+ * Linux x64, where node-pty's tarball ships no prebuilds and the
9
+ * host g++ is too old to compile C++20), the agent loses its
10
+ * `terminal` capability with zero user-facing signal. The web UI's
11
+ * Terminal tab silently disappears.
12
+ *
13
+ * This postinstall script makes that failure visible: if we're on a
14
+ * platform where node-pty was supposed to install but didn't, print
15
+ * a clear warning with the recovery command. We never fail the
16
+ * install — pty is genuinely optional, and required-feature mode
17
+ * would block users who don't need a Terminal tab.
18
+ *
19
+ * Exit code is always 0 — we only print, never abort.
20
+ */
21
+
22
+ import { existsSync } from 'fs';
23
+ import { createRequire } from 'module';
24
+ import { platform } from 'os';
25
+
26
+ const require = createRequire(import.meta.url);
27
+
28
+ function tryResolveNodePty() {
29
+ try {
30
+ return require.resolve('node-pty');
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function tryLoadBinary() {
37
+ // node-pty's lib/index.js does `require('../build/Release/pty.node')`
38
+ // — if the binary is missing or ABI-incompatible, that throws. We
39
+ // mimic the load eagerly so the warning fires at install time, not
40
+ // at agent startup.
41
+ try {
42
+ require('node-pty');
43
+ return { ok: true };
44
+ } catch (e) {
45
+ return { ok: false, error: e.message };
46
+ }
47
+ }
48
+
49
+ const resolved = tryResolveNodePty();
50
+ if (!resolved) {
51
+ // node-pty was never installed at all — npm skipped it for the
52
+ // current platform/engine combination.
53
+ const plat = platform();
54
+ if (plat === 'linux' || plat === 'darwin' || plat === 'win32') {
55
+ console.warn('');
56
+ console.warn(' ⚠ node-pty was not installed on this host.');
57
+ console.warn(' The agent will run, but the web UI Terminal tab');
58
+ console.warn(' will be hidden (terminal capability missing).');
59
+ if (plat === 'linux') {
60
+ console.warn('');
61
+ console.warn(' Linux fix: install a C++20-capable compiler');
62
+ console.warn(' (g++-10 or newer) and rebuild:');
63
+ console.warn('');
64
+ console.warn(' sudo apt-get install -y g++-10');
65
+ console.warn(' CXX=g++-10 npm install node-pty --include=optional');
66
+ } else {
67
+ console.warn('');
68
+ console.warn(' Reinstall to retry: npm install node-pty');
69
+ }
70
+ console.warn('');
71
+ }
72
+ process.exit(0);
73
+ }
74
+
75
+ const loaded = tryLoadBinary();
76
+ if (!loaded.ok) {
77
+ console.warn('');
78
+ console.warn(' ⚠ node-pty resolved but failed to load native binary.');
79
+ console.warn(` Error: ${loaded.error}`);
80
+ console.warn(' The agent will run without Terminal tab support.');
81
+ console.warn(' Recover: npm rebuild node-pty');
82
+ console.warn('');
83
+ process.exit(0);
84
+ }
85
+
86
+ // Silent on success — clean install output.
87
+ process.exit(0);
@@ -215,3 +215,154 @@ export function updateUnifySettings(update, dir) {
215
215
 
216
216
  return merged;
217
217
  }
218
+
219
+ // ─── Search settings (web-search backend selection + Tavily key) ────
220
+
221
+ /**
222
+ * Valid backend values. `playwright` is reserved for the upcoming
223
+ * playwright-service tool — its UI option is currently disabled, but we
224
+ * accept the literal so a hand-edited config doesn't trip validation.
225
+ * Anything else is rejected on write and normalized to `tavily` on read.
226
+ */
227
+ const VALID_BACKENDS = ['tavily', 'playwright'];
228
+
229
+ function maskKey(key) {
230
+ if (!key || typeof key !== 'string') return null;
231
+ if (key.length <= 10) return '***';
232
+ return `${key.slice(0, 6)}...${key.slice(-4)}`;
233
+ }
234
+
235
+ /**
236
+ * Read the `search` section of config.json. Tavily key is returned in
237
+ * masked form (`tvly-d...j3dgV`) — the raw key never leaves the agent.
238
+ * UI uses `tavilyKeyConfigured` to decide whether the input shows a
239
+ * "(unchanged)" placeholder vs an empty box.
240
+ *
241
+ * @param {string} [dir]
242
+ * @returns {{ backend: string, tavilyKeyConfigured: boolean, tavilyKeyMasked: string|null, disableHtmlFallback: boolean } | { error: string }}
243
+ */
244
+ export function getSearchSettings(dir) {
245
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
246
+ const configPath = join(root, 'config.json');
247
+ const defaults = {
248
+ backend: 'tavily',
249
+ tavilyKeyConfigured: false,
250
+ tavilyKeyMasked: null,
251
+ disableHtmlFallback: false,
252
+ };
253
+ if (!existsSync(configPath)) return defaults;
254
+ try {
255
+ const json = JSON.parse(readFileSync(configPath, 'utf8'));
256
+ const s = (json && typeof json.search === 'object' && json.search) || {};
257
+ const backend = VALID_BACKENDS.includes(s.backend) ? s.backend : 'tavily';
258
+ const key = typeof s.tavilyApiKey === 'string' ? s.tavilyApiKey : '';
259
+ return {
260
+ backend,
261
+ tavilyKeyConfigured: !!key,
262
+ tavilyKeyMasked: key ? maskKey(key) : null,
263
+ disableHtmlFallback: !!s.disableHtmlFallback,
264
+ };
265
+ } catch (e) {
266
+ return { error: `Failed to read config.json: ${e.message}` };
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Update the `search` section of config.json. Update is shallow-merged:
272
+ * any field omitted from `update` keeps its previous value. Pass
273
+ * `tavilyApiKey: ''` explicitly to clear the key; pass `undefined` (or
274
+ * omit) to keep it unchanged — this is what the UI relies on so the
275
+ * "(unchanged)" placeholder doesn't accidentally wipe a saved key when
276
+ * the user only touches the backend radio.
277
+ *
278
+ * @param {{ backend?: string, tavilyApiKey?: string, disableHtmlFallback?: boolean }} update
279
+ * @param {string} [dir]
280
+ * @returns {ReturnType<typeof getSearchSettings>}
281
+ */
282
+ export function updateSearchSettings(update, dir) {
283
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
284
+ const configPath = join(root, 'config.json');
285
+
286
+ if (!update || typeof update !== 'object') {
287
+ return { error: 'update payload required' };
288
+ }
289
+ if (update.backend !== undefined && !VALID_BACKENDS.includes(update.backend)) {
290
+ return { error: `backend must be one of: ${VALID_BACKENDS.join(', ')}` };
291
+ }
292
+ if (update.tavilyApiKey !== undefined && typeof update.tavilyApiKey !== 'string') {
293
+ return { error: 'tavilyApiKey must be a string' };
294
+ }
295
+
296
+ let existing = {};
297
+ if (existsSync(configPath)) {
298
+ try {
299
+ existing = JSON.parse(readFileSync(configPath, 'utf8'));
300
+ } catch {
301
+ existing = {};
302
+ }
303
+ }
304
+ const prev = (existing && typeof existing.search === 'object' && existing.search) || {};
305
+ const merged = { ...prev };
306
+ if (update.backend !== undefined) merged.backend = update.backend;
307
+ if (update.tavilyApiKey !== undefined) merged.tavilyApiKey = update.tavilyApiKey;
308
+ if (update.disableHtmlFallback !== undefined) merged.disableHtmlFallback = !!update.disableHtmlFallback;
309
+ existing.search = merged;
310
+
311
+ try {
312
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
313
+ } catch (e) {
314
+ return { error: `Failed to write config.json: ${e.message}` };
315
+ }
316
+ return getSearchSettings(root);
317
+ }
318
+
319
+ /**
320
+ * Probe Tavily's `/usage` endpoint with the currently-saved key. Returns
321
+ * the plan + usage fields the UI shows, or `{ error }` for any of:
322
+ * - no key configured
323
+ * - HTTP error from Tavily (401 = bad key, etc.)
324
+ * - network failure
325
+ *
326
+ * Called only when the user opens the Search settings tab (the user
327
+ * explicitly asked for "open settings → live read, don't poll"). No
328
+ * caching here — a stale display is more confusing than a fresh probe.
329
+ *
330
+ * @param {string} [dir]
331
+ * @returns {Promise<{ plan: string, used: number, limit: number|null, paygoUsed: number, paygoLimit: number|null } | { error: string }>}
332
+ */
333
+ export async function fetchTavilyUsage(dir) {
334
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
335
+ const configPath = join(root, 'config.json');
336
+ if (!existsSync(configPath)) return { error: 'config.json not found' };
337
+ let key;
338
+ try {
339
+ const json = JSON.parse(readFileSync(configPath, 'utf8'));
340
+ key = json?.search?.tavilyApiKey;
341
+ } catch (e) {
342
+ return { error: `Failed to read config.json: ${e.message}` };
343
+ }
344
+ if (!key) return { error: 'Tavily API key not configured' };
345
+
346
+ try {
347
+ const res = await fetch('https://api.tavily.com/usage', {
348
+ method: 'GET',
349
+ headers: { Authorization: `Bearer ${key}` },
350
+ });
351
+ if (!res.ok) {
352
+ const text = await res.text().catch(() => '');
353
+ return { error: `${res.status} ${res.statusText} ${text.slice(0, 200)}` };
354
+ }
355
+ const data = await res.json();
356
+ const account = data?.account || {};
357
+ return {
358
+ plan: account.current_plan || 'unknown',
359
+ used: Number(account.plan_usage) || 0,
360
+ limit: account.plan_limit ?? null,
361
+ paygoUsed: Number(account.paygo_usage) || 0,
362
+ paygoLimit: account.paygo_limit ?? null,
363
+ };
364
+ } catch (e) {
365
+ return { error: e.message || String(e) };
366
+ }
367
+ }
368
+
@@ -60,6 +60,14 @@ Guidelines:
60
60
  const signal = ctx?.signal;
61
61
  const errors = [];
62
62
 
63
+ // Backend preference set in UnifySettings → Search tab. When the
64
+ // user picks `playwright` we'd normally call the playwright-service;
65
+ // that service is not yet shipped (next PR), so the preference is
66
+ // recorded for forward-compat and we transparently fall through to
67
+ // Tavily / HTML scrape. Once the service lands we'll insert a
68
+ // tryPlaywright backend here ahead of Tavily.
69
+ // Anything other than 'playwright' is treated as 'tavily'.
70
+
63
71
  // 1. Tavily — default, fast, structured.
64
72
  if (search.tavilyApiKey) {
65
73
  const r = await tryTavily(query, limit, search.tavilyApiKey, signal);