PageSourceSearch

https://robotdojo.ai/chat/modules/indicator.js

js robotdojo.ai collected 2026-10-05 15:25:06 UTC 6,572 bytes, 199 lines download raw bytes

1/**
2 * Turn-status narration. Renders the work currently in flight from
3 * server-emitted `phase` events. No spinner, no cycling copy, no icons —
4 * the visible text is the last honest phase label.
5 *
6 * Every visible TEXT state MUST correspond to a server-emitted phase or
7 * payload field — never invent — except the static LABELS fallback when
8 * the server sends a known phase with no label.
9 */
10
11const LABELS = {
12  connected: 'Opening the chat stream.',
13  assembling_context: 'Preparing the response.',
14  searching_memory: 'Searching memory context.',
15  calling_tools: 'Calling tools.',
16  thinking: 'Thinking it through.',
17  streaming: '',
18  persisted: '',
19};
20
21const RESPONSE_MODE_TIER = { fast: 0, context: 1, deep: 2 };
22const MODE_TO_LANE = { fast: 'ask', context: 'work', deep: 'think' };
23const LANE_TO_MODE = Object.fromEntries(Object.entries(MODE_TO_LANE).map(([mode, lane]) => [lane, mode]));
24
25const TOOL_ACTION_LABELS = {
26  search_memory: 'Searching your memory',
27  search_people: 'Looking through your contacts',
28  query_network: 'Mapping your network',
29  query_family: 'Checking your family info',
30  add_fact: 'Saving a fact',
31  add_person: 'Adding that person',
32  set_relation_tag: 'Updating your relationships',
33  set_employer: 'Updating your work info',
34  update_person: 'Updating that record',
35  update_profile: 'Updating your profile',
36  get_health_summary: 'Checking your health summary',
37  record_viewer_correction: 'Applying your correction',
38};
39const TOOL_ACTION_FALLBACK = 'Working on it…';
40
41function resolveMode(payload) {
42  const mode = payload?.mode;
43  if (Object.prototype.hasOwnProperty.call(RESPONSE_MODE_TIER, mode)) return mode;
44  const fromLane = LANE_TO_MODE[payload?.lane];
45  return fromLane || 'fast';
46}
47
48function humanizeToolPhase(payload) {
49  const rawLabel = typeof payload?.label === 'string' ? payload.label.trim() : '';
50  if (!rawLabel) {
51    return typeof payload?.count === 'number'
52      ? composeCountLabel('calling_tools', payload.count)
53      : TOOL_ACTION_FALLBACK;
54  }
55  const match = rawLabel.match(/^calling\s+(.+?)[.…]*$/i);
56  const name = (match ? match[1] : rawLabel).trim();
57  const key = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
58  return TOOL_ACTION_LABELS[key] || TOOL_ACTION_FALLBACK;
59}
60
61export class Indicator {
62  /**
63   * @param {Element} domEl - the `.chat-indicator` container. Must contain
64   *   a `.chat-indicator-icon` and a `.chat-indicator-text` child.
65   */
66  constructor(domEl) {
67    this._el = domEl;
68    this._iconEl = domEl?.querySelector?.('.chat-indicator-icon') || null;
69    this._textEl = domEl?.querySelector?.('.chat-indicator-text') || null;
70    this._lastPhase = null;
71    this._tier = null;
72    this._lane = null;
73    this._holdTimer = null;
74    this._providerDegradedMessage = null;
75  }
76
77  /**
78   * Render a phase label into the DOM. Unknown phases are no-ops.
79   *
80   * @param {string} phaseName
81   * @param {object} [payload]
82   */
83  setPhase(phaseName, payload) {
84    if (!Object.prototype.hasOwnProperty.call(LABELS, phaseName)) return;
85    this._lastPhase = phaseName;
86
87    if (payload && payload.degraded === true && typeof payload.label === 'string' && payload.label.trim()) {
88      this._providerDegradedMessage = payload.label.trim();
89    }
90    if (this._providerDegradedMessage) {
91      this._render(this._providerDegradedMessage);
92      if (this._el?.dataset) this._el.dataset.providerDegraded = 'true';
93      return;
94    }
95
96    if (phaseName === 'assembling_context') {
97      const mode = resolveMode(payload);
98      this._tier = RESPONSE_MODE_TIER[mode] ?? 0;
99      this._lane = MODE_TO_LANE[mode] || 'ask';
100      if (this._el?.dataset) this._el.dataset.tier = this._lane;
101    }
102
103    let text = '';
104    if (phaseName === 'calling_tools') {
105      text = humanizeToolPhase(payload);
106    } else if (payload && typeof payload === 'object') {
107      if (typeof payload.label === 'string' && payload.label.trim()) {
108        text = payload.label.trim();
109      } else if (typeof payload.count === 'number') {
110        text = composeCountLabel(phaseName, payload.count);
111      } else {
112        text = LABELS[phaseName];
113      }
114    } else {
115      text = LABELS[phaseName];
116    }
117
118    if (!text) return;
119    this._render(text);
120  }
121
122  fadeOut() {
123    if (this._el && this._el.classList) {
124      this._el.classList.add('is-fading');
125      this._el.classList.remove('is-active');
126    }
127  }
128
129  clear() {
130    this._lastPhase = null;
131    this._tier = null;
132    this._lane = null;
133    this._providerDegradedMessage = null;
134    this._holdTimer = null;
135    if (this._textEl) this._textEl.textContent = '';
136    else if (this._el) this._el.textContent = '';
137    if (this._iconEl) {
138      this._iconEl.innerHTML = '';
139      if (this._iconEl.classList) {
140        this._iconEl.classList.remove('ask');
141        this._iconEl.classList.remove('work');
142        this._iconEl.classList.remove('think');
143      }
144      this._iconEl.removeAttribute?.('data-tip');
145    }
146    if (this._el) {
147      if (this._el.dataset) {
148        delete this._el.dataset.phase;
149        delete this._el.dataset.status;
150        delete this._el.dataset.tier;
151        delete this._el.dataset.providerDegraded;
152      }
153      this._el.removeAttribute?.('aria-label');
154      if (this._el.classList) {
155        this._el.classList.remove('is-fading');
156        this._el.classList.remove('is-active');
157      }
158    }
159  }
160
161  getPhase() {
162    return this._lastPhase;
163  }
164
165  isProviderDegraded() {
166    return Boolean(this._providerDegradedMessage);
167  }
168
169  _render(text) {
170    if (this._textEl) this._textEl.textContent = text;
171    else if (this._el) this._el.textContent = text;
172    if (this._el?.dataset) {
173      this._el.dataset.slaManaged = 'true';
174      if (this._lastPhase) this._el.dataset.phase = this._lastPhase;
175      const value = String(text || '').trim();
176      if (value) {
177        this._el.dataset.status = value;
178        this._el.setAttribute?.('aria-label', value);
179      }
180    }
181    if (this._el?.classList) {
182      this._el.classList.add('is-active');
183      this._el.classList.remove('is-fading');
184    }
185  }
186}
187
188export function composeCountLabel(phase, count) {
189  const n = Number.isFinite(count) ? count : 0;
190  switch (phase) {
191    case 'searching_memory': return `Reading ${n} of your sources…`;
192    case 'calling_tools':    return `Calling ${n} tools…`;
193    case 'assembling_context': return `Adding context from ${n} sources.`;
194    default: return LABELS[phase] || '';
195  }
196}
197
198export { LABELS as INDICATOR_LABELS };
199export { RESPONSE_MODE_TIER, resolveMode, humanizeToolPhase };

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.