Skip to content

Commit 1f63f03

Browse files
Merge pull request #199 from off-grid-ai/perf/desktop-optimizations-h3zn29
Documents and project knowledge bases on one engine; voice and timeline polish
2 parents 8e6b1cc + 3b09225 commit 1f63f03

122 files changed

Lines changed: 6270 additions & 4031 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎electron-builder.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ afterSign: scripts/notarize-mac.mjs
1717
asarUnpack:
1818
- 'node_modules/@img/sharp-libvips-linux-x64/lib/*.so*'
1919
- 'node_modules/**/onnxruntime-node/bin/**/*'
20+
# ONNX Runtime opens model files by path; it cannot read them inside app.asar.
21+
- 'node_modules/@gutenye/ocr-models/assets/**/*'
2022
directories:
2123
buildResources: build
2224
files:

‎electron.vite.config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ export default defineConfig({
134134
input: {
135135
index: resolve('src/main/index.ts'),
136136
'embeddings-worker': resolve('src/main/embeddings-worker.ts'),
137+
'documents-extract-worker': resolve('src/main/documents/extract-worker.ts'),
138+
'documents-ocr-worker': resolve('src/main/documents/ocr-worker.ts'),
137139
'tts-onnx-worker': resolve('src/main/tts-onnx-worker.ts')
138140
}
139141
}

‎electron/accessibility/ocr.swift‎

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import Foundation
22
import Vision
33
import AppKit
4+
import PDFKit
45

56
// OCR a single image file with the macOS Vision framework and print the
67
// recognized text, one line per detected block. Fast, accurate, on-device,
78
// AGPL-safe (no external service). Usage: ocr [--json] <image-path>
9+
//
10+
// PDF mode: ocr --pdf <file.pdf> --pages 3,4,9
11+
// Renders each page with PDFKit onto opaque white (a transparent background reads as nothing)
12+
// at 200 DPI, long side capped at 2800 px, and prints one JSON line per page: {"page":3,"text":"…"}.
13+
// One process reads many pages, so a scanned book does not pay a process start per page.
814

915
struct PixelBounds: Codable {
1016
let x: Int
@@ -25,6 +31,84 @@ struct OcrDocument: Codable {
2531
let blocks: [TextBlock]
2632
}
2733

34+
func recognizeText(
35+
_ cgImage: CGImage,
36+
recognitionLevel: VNRequestTextRecognitionLevel,
37+
languageCorrection: Bool
38+
) throws -> [VNRecognizedTextObservation] {
39+
let request = VNRecognizeTextRequest()
40+
request.recognitionLevel = recognitionLevel
41+
request.usesLanguageCorrection = languageCorrection
42+
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
43+
try handler.perform([request])
44+
return request.results ?? []
45+
}
46+
47+
48+
struct PdfPageText: Codable {
49+
let page: Int
50+
let text: String
51+
}
52+
53+
func recognizePageText(_ image: CGImage) -> String {
54+
let observations =
55+
(try? recognizeText(image, recognitionLevel: .accurate, languageCorrection: true))
56+
?? (try? recognizeText(image, recognitionLevel: .accurate, languageCorrection: false))
57+
?? []
58+
return observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n")
59+
}
60+
61+
func renderPage(_ page: PDFPage) -> CGImage? {
62+
let bounds = page.bounds(for: .mediaBox)
63+
guard bounds.width > 0, bounds.height > 0 else { return nil }
64+
let scale = min(200.0 / 72.0, 2800.0 / max(bounds.width, bounds.height))
65+
let width = Int((bounds.width * scale).rounded())
66+
let height = Int((bounds.height * scale).rounded())
67+
guard let context = CGContext(
68+
data: nil,
69+
width: width,
70+
height: height,
71+
bitsPerComponent: 8,
72+
bytesPerRow: 0,
73+
space: CGColorSpaceCreateDeviceGray(),
74+
bitmapInfo: CGImageAlphaInfo.none.rawValue
75+
) else { return nil }
76+
context.setFillColor(gray: 1, alpha: 1)
77+
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
78+
context.scaleBy(x: scale, y: scale)
79+
context.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
80+
page.draw(with: .mediaBox, to: context)
81+
return context.makeImage()
82+
}
83+
84+
if CommandLine.arguments.count > 2 && CommandLine.arguments[1] == "--pdf" {
85+
let pdfPath = CommandLine.arguments[2]
86+
var pages: [Int] = []
87+
if let flag = CommandLine.arguments.firstIndex(of: "--pages"), flag + 1 < CommandLine.arguments.count {
88+
pages = CommandLine.arguments[flag + 1].split(separator: ",").compactMap { Int($0) }
89+
}
90+
guard let document = PDFDocument(url: URL(fileURLWithPath: pdfPath)) else {
91+
FileHandle.standardError.write("could not open PDF\n".data(using: .utf8)!)
92+
exit(1)
93+
}
94+
if pages.isEmpty { pages = Array(1...max(1, document.pageCount)) }
95+
let encoder = JSONEncoder()
96+
for number in pages {
97+
autoreleasepool {
98+
var text = ""
99+
if let page = document.page(at: number - 1), let image = renderPage(page) {
100+
text = recognizePageText(image)
101+
}
102+
if let data = try? encoder.encode(PdfPageText(page: number, text: text)),
103+
let line = String(data: data, encoding: .utf8) {
104+
print(line)
105+
fflush(stdout)
106+
}
107+
}
108+
}
109+
exit(0)
110+
}
111+
28112
guard CommandLine.arguments.count > 1 else {
29113
FileHandle.standardError.write("usage: ocr <image-path>\n".data(using: .utf8)!)
30114
exit(1)
@@ -43,19 +127,6 @@ guard let image = NSImage(contentsOfFile: path),
43127
exit(1)
44128
}
45129

46-
func recognizeText(
47-
_ cgImage: CGImage,
48-
recognitionLevel: VNRequestTextRecognitionLevel,
49-
languageCorrection: Bool
50-
) throws -> [VNRecognizedTextObservation] {
51-
let request = VNRecognizeTextRequest()
52-
request.recognitionLevel = recognitionLevel
53-
request.usesLanguageCorrection = languageCorrection
54-
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
55-
try handler.perform([request])
56-
return request.results ?? []
57-
}
58-
59130
let results: [VNRecognizedTextObservation]
60131
do {
61132
do {

‎integration-tests/conversation-rename.dbtest.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ function installApi(): void {
8787
finishedAt: null
8888
}),
8989
onRagStream: () => () => undefined,
90+
documents: {
91+
list: async () => [],
92+
setExcluded: async () => undefined,
93+
onProgress: () => () => undefined
94+
},
9095
onImageGenProgress: () => () => undefined,
9196
onImageGenJobState: () => () => undefined,
9297
onImageGenConversationUpdated: () => () => undefined

‎integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,17 @@ beforeEach(() => {
175175
}
176176
})
177177

178-
afterEach(() => {
178+
afterEach(async () => {
179179
cleanup()
180+
// Background passes (follow-up suggestions) wait out the foreground grace and may still be queued
181+
// when a test ends; let them finish so the next test's model replies are its own.
182+
const { modalityQueue } = await import('../src/main/modality-queue/queue')
183+
const deadline = Date.now() + 20_000
184+
for (;;) {
185+
const { running, queued } = modalityQueue.getState()
186+
if ((!running.length && !queued.length) || Date.now() > deadline) break
187+
await new Promise((resolve) => setTimeout(resolve, 100))
188+
}
180189
fake.reset()
181190
})
182191

@@ -213,6 +222,11 @@ async function inTranscript(text: string | RegExp): Promise<HTMLElement> {
213222
)
214223
}
215224

225+
/** Follow-up suggestions are background passes: they wait out the 3 s foreground grace after a reply,
226+
* and behind any background pass still queued. Tests that wait for several get a matching timeout. */
227+
const SUGGESTION_TEST_TIMEOUT_MS = 60_000
228+
const SUGGESTION_WAIT_MS = 15_000
229+
216230
describe('production workspace bridge', () => {
217231
it('sends a rendered chat turn through preload, IPC, the model socket, and SQLite', async () => {
218232
fake.enqueue(
@@ -238,7 +252,7 @@ describe('production workspace bridge', () => {
238252
['assistant', 'The production bridge persisted this answer.']
239253
])
240254
})
241-
expect(await screen.findByPlaceholderText('Check the saved answer (Tab)')).toBeTruthy()
255+
expect(await screen.findByPlaceholderText('Check the saved answer (Tab)', undefined, { timeout: SUGGESTION_WAIT_MS })).toBeTruthy()
242256
expect(fake.requests).toHaveLength(2)
243257
})
244258

@@ -325,10 +339,12 @@ describe('production workspace bridge', () => {
325339
}, 15_000)
326340

327341
it('shows the real memory-chat failure instead of a fabricated answer', async () => {
328-
fake.enqueue({
342+
// An unavailable model is tried again once (chat fallback policy): it fails both times here.
343+
const unavailable = {
329344
errorStatus: 503,
330345
errorBody: JSON.stringify({ error: { message: 'Memory model is unavailable.' } })
331-
})
346+
}
347+
fake.enqueue(unavailable, unavailable)
332348
const user = userEvent.setup()
333349
renderChat()
334350

@@ -389,15 +405,15 @@ describe('production workspace bridge', () => {
389405
fireEvent.change(composer, { target: { value: 'Start a long conversation' } })
390406
await user.click(screen.getByRole('button', { name: /^send$/i }))
391407
expect(await inTranscript(longAnswer)).toBeTruthy()
392-
expect(await screen.findByPlaceholderText('Continue after the context fills (Tab)')).toBeTruthy()
408+
expect(await screen.findByPlaceholderText('Continue after the context fills (Tab)', undefined, { timeout: SUGGESTION_WAIT_MS })).toBeTruthy()
393409

394410
fireEvent.change(composer, { target: { value: 'Continue after the context fills' } })
395411
await user.click(screen.getByRole('button', { name: /^send$/i }))
396412
expect(await inTranscript('The next answer still works.')).toBeTruthy()
397413
expect(
398414
await screen.findByText('Compacted conversation to make room for more messages.')
399415
).toBeTruthy()
400-
expect(await screen.findByPlaceholderText('Review the shorter conversation (Tab)')).toBeTruthy()
416+
expect(await screen.findByPlaceholderText('Review the shorter conversation (Tab)', undefined, { timeout: SUGGESTION_WAIT_MS })).toBeTruthy()
401417
const summaryRequest = JSON.stringify(fake.requests[2])
402418
expect(summaryRequest).toContain('## Facts and preferences')
403419
const nextModelRequest = JSON.stringify(fake.requests[3])
@@ -413,7 +429,7 @@ describe('production workspace bridge', () => {
413429
const notice = getRagMessages(conversation!.id).find(({ content }) => content === '_Compacted_')
414430
expect(notice).toBeTruthy()
415431
expect(JSON.parse(String(notice!.context))).toEqual({ notice: true, compactionSummary: summary })
416-
})
432+
}, SUGGESTION_TEST_TIMEOUT_MS)
417433

418434
it('uses the same chat Thinking control for remote provider requests', async () => {
419435
const endpoint = `http://127.0.0.1:${fake.port}/v1`
@@ -436,7 +452,7 @@ describe('production workspace bridge', () => {
436452
fireEvent.change(composer, { target: { value: `Test ${provider} with Thinking on` } })
437453
await user.click(screen.getByRole('button', { name: /^send$/i }))
438454
expect(await inTranscript(`${provider} answered with Thinking on.`)).toBeTruthy()
439-
expect(await screen.findByPlaceholderText('Try with Thinking off (Tab)')).toBeTruthy()
455+
expect(await screen.findByPlaceholderText('Try with Thinking off (Tab)', undefined, { timeout: SUGGESTION_WAIT_MS })).toBeTruthy()
440456
const onRequest = fake.requests[0]
441457
if (provider === 'openrouter') {
442458
expect(onRequest?.reasoning).toEqual({ max_tokens: 1024 })
@@ -451,7 +467,7 @@ describe('production workspace bridge', () => {
451467
fireEvent.change(composer, { target: { value: `Test ${provider} with Thinking off` } })
452468
await user.click(screen.getByRole('button', { name: /^send$/i }))
453469
expect(await inTranscript(`${provider} answered with Thinking off.`)).toBeTruthy()
454-
expect(await screen.findByPlaceholderText('Compare both answers (Tab)')).toBeTruthy()
470+
expect(await screen.findByPlaceholderText('Compare both answers (Tab)', undefined, { timeout: SUGGESTION_WAIT_MS })).toBeTruthy()
455471
const offRequest = fake.requests[2]
456472
if (provider === 'openrouter') {
457473
expect(offRequest?.reasoning).toEqual({ effort: 'none' })
@@ -470,7 +486,7 @@ describe('production workspace bridge', () => {
470486
})
471487
await window.api.setLlmSettings({ reasoningBudget: 0 })
472488
}
473-
}, 15_000)
489+
}, SUGGESTION_TEST_TIMEOUT_MS)
474490

475491
it('shows Gemini thinking and completes a signed reasoning tool round', async () => {
476492
const endpoint = `http://127.0.0.1:${fake.port}/v1`

‎knip.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
"@offgrid/design",
2727
"better-sqlite3",
2828
"@types/better-sqlite3",
29-
"pdf-parse",
3029
"kokoro-js",
3130
"ollama"
3231
],

0 commit comments

Comments
 (0)