Skip to content

Commit bbb36fb

Browse files
authored
feat(ui-desktop): modernize chat UX + fix install, runtime, and orche… (#726)
…strator regressions Major chat UX improvements: - New ThinkingMessageBody component parses <think>/<thinking>/<thought>/ <reasoning>/<reflection> tags from reasoning models (DeepSeek R1, QwQ, Reflection-70B, etc.) into a collapsible block — auto-expanded while streaming, auto-collapsed when complete, user-toggleable thereafter. Empty reasoning blocks render with an "empty" marker so provider behavior is transparent. - Rewrite ChatHistory panel: proper truncation/click handling for long titles (was unclickable due to missing flex min-width:0), hover-revealed rename/delete actions with Enter/Esc shortcuts, native tooltip on truncated titles, sentence-case section labels (Today / Previous 7 days / Older), brighter placeholder, slim themed scrollbar, fixed edit-mode input flashing white. - Rewrite ModelSelectionModal: card layout with modality icons (LLM/TTS/ STT/Embeddings/Image/Vision), filter pills with live counts, sections for Local / TEE / Marketplace, status dot, provider count, cleaner price formatting (range and tabular-nums), TEE badge per row. Conditional "X of Y" counter only when filtering hides results. Fixed hooks order crash, layout overflow into chat input, and dim placeholder. Dropped unreachable bid-selector code. - Modernize shared modal close button: 12px from corner instead of 25/30, 18px IconX inside 32px button with hover/focus states and proper aria semantics — applies to every modal. Orchestrator resume-on-restart fix: - startAll is now mutex-guarded against concurrent runs. - After restartService succeeds, the pipeline auto-resumes if downstream services are still pending — no more "click Restart on failed service, then click global Retry to continue". resetState now preserves completed download statuses across resume so the UI doesn't flash finished bars back to pending. Proxy-router fix (internal/aiengine/ai_engine.go): - GetAdapter no longer treats modelID==zero-hash as "missing" when no session is provided. The local tinyllama model uses the zero hash as its legitimate identifier; the previous code conflated the two and tried a session lookup, returning "session not found" for every local chat with PROXY_STORE_CHAT_CONTEXT enabled. Also fixes a variable- shadowing bug where the recovered modelID never reached NewHistory. Dependency hygiene: - @reach/menu-button (abandoned, React 17 peer) → @radix-ui/react-dropdown-menu in ContractActions, Selector, CurrencySelector. - react-motion (React 16 peer) → framer-motion in toasts. - ethereumjs-wallet (renamed) → @ethereumjs/wallet with EthereumHDKey namespace migration and Buffer.from() wrap for the now-Uint8Array getPrivateKey() return. - cuid (deprecated, insecure) → @paralleldrive/cuid2. - universal-analytics removed entirely (Google sunset UA endpoint on 2024-07-01; the analytics module had zero callers anyway). - @electron-toolkit/preload pinned to ^2.0.0 (3.x imports webUtils which doesn't exist on Electron 28). - electron-builder pinned to 26.0.12 to keep @electron/rebuild at 3.7.0 (4.x requires Node ≥22.12; project is on Node 20). - redux bumped to ^5.0.1 to satisfy react-redux 9.x's peer constraint. - Added @popperjs/core (bootstrap 5's missing peer). - Added babel-plugin-styled-components as explicit devDep (referenced by electron-vite config; was only present via hoisting). - Switched preload bundle from ESM to CJS (Electron 28's ESM preload loader had edge cases that broke the renderer with "object null is not iterable"). - Fixed src/main/src/client/wallet.ts to use ESM imports for @ethereumjs/wallet (CJS require() in source caused ERR_REQUIRE_ESM due to transitive ESM-only @noble/curves). Renderer + main typecheck cleanup (108 → 0 errors): - Restored missing module paths via shim re-exports (apiGateway.ts, api.types.ts, orchestrator.types.ts at the locations renderer imports). - Added Window globals declaration in preload/index.d.ts for ipcRenderer, openLink, getAppVersion, copyToClipboard, isDev. - Moved process.env type augmentation from electron.vite.config.ts into env.schema.ts so both tsconfigs see it; coerced orchestrator.config.ts port/url fields accordingly. - Fixed HOC prop typings (Root, Chat, withChatState, withAgentsState), state typing in ToastsProvider, custom-prop generics on styled components in Btn/ProgressBar/RowContainer, styled-components v4 + React 18 ThemeProvider cast. - Cleaned unused imports/locals, exported type-level test assertions in parial-apply.types.ts. - Narrowed optional config fields in orchestrator.ts (extractPath, env) with guards instead of non-null assertions. Misc: - orchestrator.config.ts: local model apiUrl now includes /chat/completions (was just /v1, which caused 404 "File Not Found" from llama-server). - Updated persisted models-config.json in user-data on the local machine to match. <img width="1312" height="912" alt="Screenshot 2026-05-18 at 18 51 50" src="https://github.com/user-attachments/assets/54a673de-cece-404a-b77d-04d585e6d443" /> <img width="1314" height="912" alt="Screenshot 2026-05-18 at 18 51 58" src="https://github.com/user-attachments/assets/d97ee95f-1512-4217-a5c2-f478635c5ce8" /> <img width="1312" height="912" alt="Screenshot 2026-05-18 at 18 47 39" src="https://github.com/user-attachments/assets/673f0208-1e9d-4659-ab6a-968aa4176478" />
2 parents 697c3b5 + 197ac27 commit bbb36fb

43 files changed

Lines changed: 3838 additions & 2323 deletions

Some content is hidden

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

proxy-router/internal/aiengine/ai_engine.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,17 @@ func (a *AiEngine) GetAdapter(ctx context.Context, chatID, modelID, sessionID co
7272

7373
if storeChatContext {
7474
var actualModelID common.Hash
75-
if modelID == (common.Hash{}) {
76-
modelID, err := a.service.GetModelIdSession(ctx, sessionID)
75+
// Only recover modelID from the session when we actually have one.
76+
// `modelID == zero` is the legitimate identifier for the bundled
77+
// local (tinyllama) model, so we must not treat zero as "missing"
78+
// when there's no session to look up — that path returns
79+
// "session not found" and breaks local chats.
80+
if modelID == (common.Hash{}) && sessionID != (common.Hash{}) {
81+
recoveredModelID, err := a.service.GetModelIdSession(ctx, sessionID)
7782
if err != nil {
7883
return nil, err
7984
}
80-
actualModelID = modelID
85+
actualModelID = recoveredModelID
8186
} else {
8287
actualModelID = modelID
8388
}

ui-desktop/.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,27 @@
33

44
# MAINNET VALUES - BASE Mainnet
55
CHAIN_ID=8453
6+
CHAIN_NAME=Base
67
COOKIE_FILE_PATH=./.cookie
78
DIAMOND_ADDRESS=0x6aBE1d282f72B474E54527D93b979A4f64d3030a
8-
CHAIN_NAME=Base
99
EXPLORER_URL=https://basescan.org/tx/{{hash}}
1010
TOKEN_ADDRESS=0x7431ada8a591c955a994a21710752ef9b882b8e3
1111
DEV_TOOLS=false
1212
SYMBOL_ETH=ETH # remove it
1313
SYMBOL_COIN=MOR # remove it
14+
BLOCKSCOUT_API_URL="https://base.blockscout.com/api/v2"
1415

1516
# TESTNET VALUES
1617
#CHAIN_ID=84532
18+
#CHAIN_NAME=Base Sepolia
1719
#DIAMOND_ADDRESS=0x6e4d0B775E3C3b02683A6F277Ac80240C4aFF930
1820
#DISPLAY_NAME=Base Sepolia
1921
#EXPLORER_URL=https://sepolia.basescan.org/tx/{{hash}}
2022
#TOKEN_ADDRESS=0x5C80Ddd187054E1E4aBBfFCD750498e81d34FfA3
2123
#DEV_TOOLS=true
2224
#SYMBOL_ETH=ETH
2325
#SYMBOL_COIN=MOR
26+
#BLOCKSCOUT_API_URL="https://base-sepolia.blockscout.com/api/v2"
2427

2528
# COMMON
2629
BYPASS_AUTH=false

ui-desktop/electron.vite.config.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,9 @@ import { defineConfig, externalizeDepsPlugin, loadEnv } from 'electron-vite'
33
import react from '@vitejs/plugin-react'
44
import svgr from 'vite-plugin-svgr'
55
import { nodePolyfills } from 'vite-plugin-node-polyfills'
6-
import { Env, EnvSchema } from './env.schema'
6+
import { EnvSchema } from './env.schema'
77
import { newAjv } from './validator'
88

9-
declare global {
10-
namespace NodeJS {
11-
interface ProcessEnv extends Env {}
12-
}
13-
}
14-
159
const envsToInject = Object.keys(EnvSchema.properties)
1610

1711
export default defineConfig(({ /*command,*/ mode }) => {
@@ -63,8 +57,13 @@ export default defineConfig(({ /*command,*/ mode }) => {
6357
preload: {
6458
build: {
6559
rollupOptions: {
60+
// CJS for the preload script. Electron 28's ESM preload support is
61+
// patchy (notably, the bundled sandbox bootstrap fails with
62+
// "object null is not iterable"). CJS sidesteps the whole CJS↔ESM
63+
// interop issue around `electron` named imports and just works.
6664
output: {
67-
format: 'es'
65+
format: 'cjs',
66+
entryFileNames: '[name].js'
6867
}
6968
}
7069
},

ui-desktop/env.schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,12 @@ export const EnvSchema = Type.Object({
4545

4646
// Inferred type of environment variables
4747
export type Env = Static<typeof EnvSchema>
48+
49+
// Augment `process.env` with the validated/typed env so config-reading code
50+
// (both main and renderer) sees the correct types. Validation in
51+
// `electron.vite.config.ts` is what makes this safe at runtime.
52+
declare global {
53+
namespace NodeJS {
54+
interface ProcessEnv extends Env {}
55+
}
56+
}

ui-desktop/orchestrator.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const configMacArm = {
3737
buildLocalModelsConfig(
3838
'tiny-llama-1.1B-chat',
3939
'openai',
40-
`http://localhost:${process.env.SERVICE_AI_API_PORT}/v1`
40+
`http://localhost:${process.env.SERVICE_AI_API_PORT}/v1/chat/completions`
4141
)
4242
),
4343
ratingConfig: JSON.stringify(buildLocalRatingConfig()),

ui-desktop/package.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@
3939
"pkg": "electron-builder --config electron.builder.config.ts --mac"
4040
},
4141
"dependencies": {
42-
"@electron-toolkit/preload": "^3.0.0",
42+
"@electron-toolkit/preload": "^2.0.0",
4343
"@electron-toolkit/utils": "^3.0.0",
4444
"@electron/remote": "^2.1.2",
45-
"@reach/menu-button": "^0.18.0",
45+
"@ethereumjs/wallet": "^10.0.0",
46+
"@paralleldrive/cuid2": "^3.3.0",
47+
"@popperjs/core": "^2.11.8",
48+
"@radix-ui/react-dropdown-menu": "^2.1.16",
4649
"@sinclair/typebox": "^0.34.33",
4750
"@tabler/icons-react": "^3.1.0",
4851
"@types/tar-stream": "^3.1.3",
@@ -55,7 +58,6 @@
5558
"chalk": "^5.3.0",
5659
"credential-plus": "^2.0.7",
5760
"credential-plus-pbkdf2": "^2.0.4",
58-
"cuid": "^3.0.0",
5961
"dotenv": "^16.4.5",
6062
"electron-context-menu": "^3.6.1",
6163
"electron-debug": "^3.2.0",
@@ -65,7 +67,7 @@
6567
"electron-settings": "^4.0.2",
6668
"electron-updater": "^6.1.7",
6769
"electron-window-state": "^5.0.3",
68-
"ethereumjs-wallet": "^1.0.2",
70+
"framer-motion": "^12.38.0",
6971
"json-stringify-safe": "^5.0.1",
7072
"keytar": "^7.9.0",
7173
"lodash.debounce": "^4.0.8",
@@ -80,20 +82,18 @@
8082
"react-markdown": "^9.0.1",
8183
"react-modal": "^3.16.1",
8284
"react-modern-drawer": "^1.2.2",
83-
"react-motion": "^0.5.2",
8485
"react-redux": "^9.1.0",
8586
"react-router-dom": "7.1.5",
8687
"react-select": "^5.8.0",
8788
"react-simple-image-viewer": "^1.2.2",
8889
"react-syntax-highlighter": "^15.5.0",
8990
"react-textarea-autosize": "^8.5.3",
9091
"react-virtualized": "9.22.6",
91-
"redux": "4.2.0",
92+
"redux": "^5.0.1",
9293
"redux-actions": "2.3.0",
9394
"reselect": "^5.1.0",
9495
"styled-components": "4.1.2",
9596
"tar-stream": "^3.1.7",
96-
"universal-analytics": "^0.5.3",
9797
"web3-utils": "1.10.4",
9898
"yauzl": "^3.2.0",
9999
"zxcvbn3": "^0.1.1"
@@ -112,8 +112,9 @@
112112
"@types/styled-components": "^5.1.34",
113113
"@types/yauzl": "^2.10.3",
114114
"@vitejs/plugin-react": "^4.2.1",
115+
"babel-plugin-styled-components": "^2.1.4",
115116
"electron": "^28.2.0",
116-
"electron-builder": "^26.0.12",
117+
"electron-builder": "26.0.12",
117118
"electron-vite": "^2.3.0",
118119
"eslint": "^8.56.0",
119120
"eslint-plugin-react": "^7.33.2",

ui-desktop/src/main/analytics.js

Lines changed: 0 additions & 37 deletions
This file was deleted.

ui-desktop/src/main/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function createWindow(): void {
2424
autoHideMenuBar: true,
2525
// ...(process.platform === 'linux' ? { icon } : {}),
2626
webPreferences: {
27-
preload: join(__dirname, '../preload/index.mjs'),
27+
preload: join(__dirname, '../preload/index.js'),
2828
sandbox: false
2929
}
3030
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Re-export shim. The renderer imports orchestrator types from this path
2+
// historically; the real definitions live in `./orchestrator/orchestrator.types`.
3+
export * from './orchestrator/orchestrator.types'

ui-desktop/src/main/orchestrator/orchestrator.ts

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export class Orchestrator {
2525
private cfg: OrchestratorConfig
2626
private log: typeof logger
2727

28+
// Mutex-style guard: ensures only one startAll pipeline is in flight at a
29+
// time. Re-entrant callers (e.g. an auto-resume after a successful
30+
// restartService while the initial startAll hasn't returned yet) await the
31+
// existing promise instead of racing a second pipeline.
32+
private startInProgress: Promise<void> | null = null
33+
2834
private proxyDownloadState: DownloadItem = {
2935
name: 'Proxy Router',
3036
status: 'pending',
@@ -63,7 +69,20 @@ export class Orchestrator {
6369
})
6470
}
6571

66-
async startAll() {
72+
async startAll(): Promise<void> {
73+
if (this.startInProgress) {
74+
this.log.info('startAll already in progress; awaiting existing run')
75+
return this.startInProgress
76+
}
77+
this.startInProgress = this.runStartupPipeline()
78+
try {
79+
await this.startInProgress
80+
} finally {
81+
this.startInProgress = null
82+
}
83+
}
84+
85+
private async runStartupPipeline() {
6786
this.log.info('Orchestrator started')
6887
await this.resetState()
6988
this.emitStateUpdate()
@@ -86,7 +105,7 @@ export class Orchestrator {
86105
this.proxyDownloadState.status = 'success'
87106
this.emitStateUpdate()
88107

89-
if (this.cfg.aiRuntime.downloadUrl) {
108+
if (this.cfg.aiRuntime.downloadUrl && this.cfg.aiRuntime.extractPath) {
90109
if (fs.existsSync(resolveAppDataPath(this.cfg.aiRuntime.extractPath))) {
91110
this.log.info(
92111
'AI runtime already exists, skipping download',
@@ -146,6 +165,7 @@ export class Orchestrator {
146165

147166
if (
148167
this.cfg.ipfs.downloadUrl &&
168+
this.cfg.ipfs.extractPath &&
149169
!fs.existsSync(resolveAppDataPath(this.cfg.ipfs.extractPath))
150170
) {
151171
await downloadFile(
@@ -225,7 +245,7 @@ export class Orchestrator {
225245
const proxyFolder = path.dirname(resolveAppDataPath(this.cfg.proxyRouter.runPath))
226246

227247
// writting local config files if not exist
228-
await this.writeEnvFile(path.join(proxyFolder, '.env'), this.cfg.proxyRouter.env)
248+
await this.writeEnvFile(path.join(proxyFolder, '.env'), this.cfg.proxyRouter.env ?? {})
229249
await this.writeLocalConfigFile(
230250
path.join(proxyFolder, 'models-config.json'),
231251
this.cfg.proxyRouter.modelsConfig
@@ -286,8 +306,39 @@ export class Orchestrator {
286306
await process.stop()
287307
this.emitStateUpdate()
288308

289-
await process.start()
290-
this.emitStateUpdate()
309+
try {
310+
await process.start()
311+
} finally {
312+
this.emitStateUpdate()
313+
}
314+
315+
// If the original startAll pipeline aborted before reaching downstream
316+
// services (e.g. IPFS failed, so containerRuntime + proxyRouter were
317+
// never started), resume the pipeline now that this service is healthy.
318+
// startAll is idempotent: downloads check fs.exists, and each process's
319+
// start() short-circuits when already running.
320+
if (process.getState() === 'running' && !this.allServicesRunning()) {
321+
this.log.info(
322+
`Service ${service} restarted; resuming startup pipeline for any downstream services still pending`
323+
)
324+
try {
325+
await this.startAll()
326+
} catch (err) {
327+
this.log.error('Resume after restart failed', err)
328+
// Don't rethrow — the explicit restart did succeed; downstream
329+
// failures are surfaced via the per-service state.
330+
}
331+
}
332+
}
333+
334+
private allServicesRunning(): boolean {
335+
const all = [
336+
this.ipfsProcess,
337+
this.aiRuntimeProcess,
338+
this.containerRuntimeProcess,
339+
this.proxyRouterProcess
340+
]
341+
return all.every((p) => p?.getState() === 'running')
291342
}
292343

293344
async ping(service: keyof OrchestratorConfig): Promise<boolean> {
@@ -435,21 +486,29 @@ export class Orchestrator {
435486
}
436487

437488
private async resetState() {
489+
// Only reset processes that aren't already running. This preserves the
490+
// healthy ones across a resume (e.g. when restartService kicks off a
491+
// pipeline re-run, we don't want to flap IPFS / AI Runtime).
438492
this.proxyRouterProcess?.getState() !== 'running' && (await this.proxyRouterProcess?.reset())
439493
this.aiRuntimeProcess?.getState() !== 'running' && (await this.aiRuntimeProcess?.reset())
440494
this.ipfsProcess?.getState() !== 'running' && (await this.ipfsProcess?.reset())
441495
this.containerRuntimeProcess?.getState() !== 'running' &&
442496
(await this.containerRuntimeProcess?.reset())
443497

444-
this.proxyDownloadState.error = undefined
445-
this.aiRuntimeDownloadState.error = undefined
446-
this.aiModelDownloadState.error = undefined
447-
this.ipfsDownloadState.error = undefined
448-
449-
this.proxyDownloadState.status = 'pending'
450-
this.aiRuntimeDownloadState.status = 'pending'
451-
this.aiModelDownloadState.status = 'pending'
452-
this.ipfsDownloadState.status = 'pending'
498+
// Preserve `success` download statuses across resume so the UI doesn't
499+
// flash completed bars back to pending. Clear errors regardless — they
500+
// belong to the previous attempt.
501+
for (const dl of [
502+
this.proxyDownloadState,
503+
this.aiRuntimeDownloadState,
504+
this.aiModelDownloadState,
505+
this.ipfsDownloadState
506+
]) {
507+
dl.error = undefined
508+
if (dl.status !== 'success') {
509+
dl.status = 'pending'
510+
}
511+
}
453512
}
454513
}
455514

0 commit comments

Comments
 (0)