File size: 16,886 Bytes
9509f5b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
# UI Components

## Architecture Overview

MiniSearch uses a **PubSub-based reactive architecture**. Components subscribe to state changes via channels rather than props drilling or Context API.

### PubSub Pattern

Each PubSub channel is a three-element tuple returned by `createPubSub`:

| Index | Name | Role |
|-------|------|------|
| `[0]` | `update*` | Setter β€” publishes a new value to all subscribers |
| `[1]` | `onValueChange` / `subscribe*` / `listen*` | Subscription registration β€” receives every future value |
| `[2]` | `get*` | Getter β€” reads the current value synchronously |

These are destructured at module level in `pubSub.ts` and exported under descriptive names (e.g., `updateTextGenerationState`, `listenToSettingsChanges`, `getQuery`).

```typescript
// Component subscribes to state
const query = usePubSub(queryPubSub);

// Any module can update state
updateQuery('new query');

// All subscribers automatically re-render
```

**Benefits:**
- **Decoupling** β€” Modules (text generation, search, React components) read and write shared state without importing each other directly
- **No provider boilerplate** β€” Unlike React Context or Redux, no `Provider` wrapper needed; any module imports a channel from `pubSub.ts`
- **Selective subscriptions** β€” Components subscribe only to channels they use; a streaming token update throttling `responsePubSub` does not trigger re-renders in unrelated components
- **Persistence as a decorator** β€” `createLocalStoragePubSub` layers persistence transparently onto the same interface; consumers don't need to know whether a channel is persisted or ephemeral

### localStorage Persistence

Some state must survive page reloads. The `createLocalStoragePubSub` helper wraps `createPubSub` with two behaviors:

1. **Hydration** β€” On first call, reads existing value from `localStorage` via `localStorage.getItem(key)`. If a stored JSON string is found, it is parsed and used as the initial value; otherwise the default value is used
2. **Persistence** β€” A subscriber is immediately registered on the inner PubSub that calls `localStorage.setItem` with the JSON-serialized new value on every state change

Channels using this pattern: `settingsPubSub`, `querySuggestionsPubSub`, `lastSearchTokenHashPubSub`, `menuExpandedAccordionsPubSub`.

### Throttling High-Frequency Updates

Streaming LLM output produces token-by-token state changes that would overwhelm React's rendering pipeline. Two channels apply `throttle` from `throttleit` to cap subscriber notification rate to ~12 updates/sec (83.3ms interval):

| Export | Raw Updater | Throttle Interval |
|--------|-------------|-------------------|
| `updateResponse` | `responsePubSub[0]` | 1000 / 12 ms |
| `updateReasoningContent` | `reasoningContentPubSub[0]` | 1000 / 12 ms |

Callers write streaming token output directly to these exports without awareness of internal throttling.

### Side Effects

Three channels register built-in side-effect subscribers at module load time, independently of any React component lifecycle, for automatic logging via `addLogEntry`:

| Channel | Side Effect |
|---------|-------------|
| `textGenerationStatePubSub` | Logs state transitions |
| `textSearchStatePubSub` | Logs state transitions |
| `imageSearchStatePubSub` | Logs state transitions |

## PubSub Channel Reference

All state channels are defined in `client/modules/pubSub.ts`:

| Channel | Type | Description | Primary Consumers |
|---------|------|-------------|-------------------|
| `queryPubSub` | `string` | Current search query | SearchForm, SearchButton |
| `responsePubSub` | `string` | AI response content (throttled: 12/sec) | AiResponseSection |
| `reasoningContentPubSub` | `string` | AI reasoning/thinking content (throttled: 12/sec) | AiResponseSection |
| `settingsPubSub` | `Settings` | Application settings | SettingsForm, various components |
| `textSearchResultsPubSub` | `TextSearchResults` | Text search results | SearchResultsSection |
| `llmTextSearchResultsPubSub` | `TextSearchResults` | LLM-reranked text results | Internal use |
| `imageSearchResultsPubSub` | `ImageSearchResults` | Image search results | ImageResultsSection |
| `textSearchStatePubSub` | `SearchState` | Text search state: `"idle" \| "running" \| "failed" \| "completed"` | SearchResultsSection, LoadingIndicators |
| `imageSearchStatePubSub` | `SearchState` | Image search state: `"idle" \| "running" \| "failed" \| "completed"` | ImageResultsSection |
| `textGenerationStatePubSub` | `TextGenerationState` | AI generation state | AiResponseSection, StatusIndicators |
| `modelLoadingProgressPubSub` | `number` | Model download progress (0-100) | AiResponseSection |
| `modelSizeInMegabytesPubSub` | `number` | Model size in MB for progress calc | AiResponseSection |
| `chatMessagesPubSub` | `ChatMessage[]` | Chat conversation | ChatInterface |
| `chatInputPubSub` | `string` | Current chat input content | ChatInputArea |
| `chatGenerationStatePubSub` | `{isGeneratingResponse, isGeneratingFollowUpQuestion}` | Chat generation states | ChatInterface |
| `conversationSummaryPubSub` | `{id, summary}` | Rolling conversation summary | TextGeneration module |
| `followUpQuestionPubSub` | `string` | Generated follow-up question | AiResponseSection |
| `suppressNextFollowUpPubSub` | `boolean` | Flag to skip next follow-up | FollowUpQuestions module |
| `isRestoringFromHistoryPubSub` | `boolean` | History restoration in progress | SearchForm, components |
| `menuExpandedAccordionsPubSub` | `string[]` | Expanded menu accordion IDs | MenuDrawer |
| `querySuggestionsPubSub` | `string[]` | Query suggestions history | SearchForm |
| `lastSearchTokenHashPubSub` | `string` | Hash of last search token | Security/validation |
| `logEntriesPubSub` | `LogEntry[]` | Application log entries | LogsModal, ShowLogsButton |

## Component Hierarchy

```
App
β”œβ”€β”€ AccessPage (if access keys enabled)
└── MainPage
    β”œβ”€β”€ SearchForm
    β”‚   β”œβ”€β”€ HistoryButton
    β”‚   β”‚   └── HistoryDrawer (lazy-loaded)
    β”‚   └── MenuButton
    β”‚       └── MenuDrawer
    β”‚           β”œβ”€β”€ AISettingsForm
    β”‚           β”œβ”€β”€ SearchSettingsForm
    β”‚           β”œβ”€β”€ InterfaceSettingsForm
    β”‚           β”œβ”€β”€ HistorySettings
    β”‚           β”œβ”€β”€ VoiceSettingsForm
    β”‚           └── ActionsForm
    β”œβ”€β”€ SearchResultsSection
    β”‚   β”œβ”€β”€ TextSearchResults
    β”‚   β”‚   └── SearchResultsList
    β”‚   └── ImageSearchResults
    β”‚       └── ImageResultsList
    └── AiResponseSection
        β”œβ”€β”€ AiResponseContent
        β”‚   └── FormattedMarkdown
        └── ChatInterface (shown once generation is completed)
            β”œβ”€β”€ ChatHeader
            β”œβ”€β”€ MessageList
            └── ChatInputArea
```

## Key Components

### App (`client/components/App/`)

**Responsibility:** Application shell and routing

**Logic:**
```typescript
// App.tsx
// Access key validation is handled via useAccessKeyValidation hook
// which checks localStorage for a stored key hash and verifies it server-side
// Renders <AccessPage /> if validation fails, <MainPage /> otherwise
```

**Subscribes to:** None directly; validation state managed by `useAccessKeyValidation` hook

### SearchForm (`client/components/Search/Form/`)

**Responsibility:** Query input and search initiation

**PubSub:**
- **Subscribes:** `queryPubSub`, `textSearchStatePubSub`
- **Updates:** `queryPubSub` (on type), triggers `searchAndRespond()` (on submit)

**Logic:**
```typescript
function SearchForm() {
  const [query, setQuery] = usePubSub(queryPubSub);
  const [searchState] = usePubSub(textSearchStatePubSub);
  
  const handleSubmit = () => {
    searchAndRespond();
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <button disabled={searchState === 'running'}>
        {searchState === 'running' ? 'Searching...' : 'Search'}
      </button>
    </form>
  );
}
```

### SearchResultsSection (`client/components/Search/Results/`)

**Responsibility:** Display search results (text and images)

**PubSub:**
- **Subscribes:** `textSearchResultsPubSub`, `imageSearchResultsPubSub`, `textSearchStatePubSub`

**Logic:**
```typescript
function SearchResultsSection() {
  const [textResults] = usePubSub(textSearchResultsPubSub);
  const [imageResults] = usePubSub(imageSearchResultsPubSub);
  const [searchState] = usePubSub(textSearchStatePubSub);
  
  if (searchState === 'running') return <LoadingSkeleton />;
  if (searchState === 'failed') return <ErrorMessage />;
  
  return (
    <>
      <SearchResultsList searchResults={textResults} />
      {settings.enableImageSearch && <ImageResultsList searchResults={imageResults} />}
    </>
  );
}
```

### AiResponseSection (`client/components/AiResponse/`)

**Responsibility:** AI response display and chat interface

**PubSub:**
- **Subscribes:** `responsePubSub`, `textGenerationStatePubSub`, `chatMessagesPubSub`

**State Machine (`textGenerationStatePubSub`):**

| State | Description | UI Display |
|-------|-------------|------------|
| `idle` | No active generation | Hidden or empty |
| `awaitingModelDownloadAllowance` | Waiting for user to confirm model download | `AiModelDownloadAllowanceContent` confirmation prompt |
| `loadingModel` | Downloading or initializing AI model | `LoadingModelContent` with progress |
| `awaitingSearchResults` | Waiting for search to complete | `PreparingContent` indicator |
| `preparingToGenerate` | Search done, response not yet started | `PreparingContent` indicator |
| `generating` | Streaming response tokens | Active response with streaming text |
| `interrupted` | Generation manually stopped by user | Response retained, with a yellow "Interrupted" badge |
| `completed` | Full response received | Complete response with chat interface |
| `failed` | Error occurred | Error message with retry option |

**Reasoning Content Extraction:**

When models output internal thought processes, the UI extracts reasoning content bounded by `reasoningStartMarker` and `reasoningEndMarker` markers. Reasoning is displayed separately from the final response in a collapsible section.

**Logic:**
```typescript
function AiResponseSection() {
  const [response] = usePubSub(responsePubSub);
  const [textGenerationState] = usePubSub(textGenerationStatePubSub);
  const [chatMessages] = usePubSub(chatMessagesPubSub);

  if (["generating", "interrupted", "completed", "failed"].includes(textGenerationState)) {
    return (
      <>
        <AiResponseContent textGenerationState={textGenerationState} response={response} />
        {textGenerationState === "completed" && (
          <ChatInterface initialResponse={response} initialMessages={chatMessages} />
        )}
      </>
    );
  }

  if (textGenerationState === "loadingModel") return <LoadingModelContent />;
  if (["preparingToGenerate", "awaitingSearchResults"].includes(textGenerationState)) {
    return <PreparingContent textGenerationState={textGenerationState} />;
  }
  if (textGenerationState === "awaitingModelDownloadAllowance") {
    return <AiModelDownloadAllowanceContent />;
  }

  return null;
}
```

### MenuDrawer (`client/components/Pages/Main/Menu/`)

**Responsibility:** Application settings UI

**Sub-components:**
- **AISettingsForm:** Model selection, inference type, reasoning markers (sampling parameters such as temperature are hardcoded, not user-configurable)
- **SearchSettingsForm:** Result limits, image search toggle
- **InterfaceSettingsForm:** UI preferences
- **HistorySettings:** Retention days, max entries
- **VoiceSettingsForm:** TTS voice selection
- **ActionsForm:** Data management actions

**PubSub:**
- **Subscribes/Updates:** `settingsPubSub` (full settings object)

**Persistence:**
```typescript
// client/modules/pubSub.ts
export const settingsPubSub = createLocalStoragePubSub('settings', defaultSettings);
```

`createLocalStoragePubSub` registers the localStorage-writing subscriber internally, so components just read and write `settingsPubSub` like any other channel.

### HistoryDrawer (`client/components/Search/History/`)

**Responsibility:** Search history display and management

**PubSub:**
- **Subscribes:** History loaded from IndexedDB (not via PubSub, via custom hook)

**Hook:** `useSearchHistory()`
```typescript
const {
  filteredSearches,
  groupedSearches,
  togglePin,
  deleteEntry,
  searchHistory,
} = useSearchHistory({ limit: 100, enableGrouping: true });
```

Restoring a past search (re-running its query) is handled separately by `useHistoryRestore`, used in `SearchForm`:
```typescript
const { restoreSearch } = useHistoryRestore(updateQuery, textAreaRef);
```

**Features:**
- Fuzzy search through history
- Date-based grouping (Today, Yesterday, Last Week, etc.)
- Pin/unpin searches
- Restore previous search (re-runs query)
- Analytics: Search frequency, cache hit rate

## State Flow Examples

### Search Flow

```
User types query
    ↓
SearchForm updates queryPubSub
    ↓
User submits
    ↓
searchAndRespond() called
    ↓
searchText() updates textSearchStatePubSub β†’ 'loading'
    ↓
SearchResultsSection shows loading skeleton
    ↓
API returns results
    ↓
textSearchResultsPubSub updated with results
    ↓
textSearchStatePubSub β†’ 'idle'
    ↓
SearchResultsSection renders results
```

### AI Response Flow

```
Search results ready
    ↓
canStartResponding() β†’ true
    ↓
textGenerationStatePubSub β†’ 'loadingModel'
    ↓
AiResponseSection shows "Loading AI model..."
    ↓
Model loaded
    ↓
textGenerationStatePubSub β†’ 'generating'
    ↓
Response tokens stream in
    ↓
responsePubSub updated (throttled 12/sec)
    ↓
AiResponseSection updates content
    ↓
Generation complete
    ↓
textGenerationStatePubSub β†’ 'completed'
```

### Chat Flow

```
User sends message
    ↓
Message added to chatMessagesPubSub
    ↓
generateChatResponse() called
    ↓
Token budget calculated
    ↓
If overflow: generate summary β†’ conversationSummaryPubSub
    ↓
Inference API called
    ↓
Response tokens stream to responsePubSub
    ↓
Full response added to chatMessagesPubSub
    ↓
Saved to IndexedDB
```

## Custom Hooks

### usePubSub

Subscribes to a PubSub channel:
```typescript
const [value, setValue] = usePubSub(channel);
```

### useSearchHistory

Manages search history from IndexedDB:
```typescript
const { searchHistory, groupedSearches, deleteEntry, togglePin } = useSearchHistory();
```

### useDrawerState

Manages open/close state for a drawer with log entry tracking:
```typescript
const { isDrawerOpen, openDrawer, closeDrawer } = useDrawerState(
  "User opened the menu",
  "User closed the menu",
);
```

## Styling

**Framework:** Mantine UI v9

**Theme Configuration:**
```typescript
// client/components/App/App.tsx
<MantineProvider defaultColorScheme="dark">
```

**Dark Mode:**
- Default color scheme is dark
- All components support dark mode via Mantine

## Accessibility

**Standards:** WCAG 2.1 AA compliance

**Features:**
- All interactive elements keyboard accessible
- ARIA labels on select interactive elements (e.g. chat input, message list, history actions, logs modal)
- Focus management in drawers and modals
- Screen reader announcements for loading states

**Implementation:**
```typescript
// Using Mantine's accessibility props
<Button aria-label="Search the web">
  <SearchIcon />
</Button>

<TextInput
  label="Search query"
  aria-describedby="search-help"
/>
<span id="search-help">Enter keywords to search</span>
```

## Component Design Principles

1. **Single Responsibility:** Components do one thing well
2. **PubSub-First:** Use channels for cross-component communication
3. **Lazy Loading:** Route-level components use `React.lazy()` for code splitting
4. **Error Boundaries:** Used selectively -- `SearchResultsSection` wraps each result type, and `MarkdownRenderer` wraps syntax-highlighted code blocks (falling back to plain text on failure). AI response and chat components are not currently wrapped in error boundaries.

## File Organization

Most components are single `.tsx` files directly under their feature directory (e.g. `client/components/AiResponse/ChatInterface.tsx`). There is no `index.tsx` re-export convention. CSS Modules are used only where needed (currently just `ImageResultsList.module.css`). Tests, when present, are co-located as `ComponentName.test.tsx` alongside the component, but not every component has one.

## Related Topics

- **Search Module**: `docs/search-history.md` - History implementation
- **AI Integration**: `docs/ai-integration.md` - Text generation flow
- **State Management**: `docs/overview.md` - PubSub architecture
- **Design**: `docs/design.md` - UI/UX principles