Skip to content

Commit 197ac27

Browse files
committed
feat(ui-desktop): modernize chat UX + fix install, runtime, and orchestrator regressions
1 parent 697c3b5 commit 197ac27

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)