diff --git a/.gitignore b/.gitignore index a758325..a3bb9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,11 @@ out/ .eslintcache .nyc_output/ +# Desktop app +desktop/node_modules/ +desktop/out/ +desktop/release/ + # Editor and OS files .DS_Store Thumbs.db diff --git a/.playwright-cli/page-2026-05-17T13-53-51-306Z.yml b/.playwright-cli/page-2026-05-17T13-53-51-306Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b609259..578534b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,13 @@ Run the CLI locally with: pnpm azoth ``` +## Desktop UI design + +Desktop renderer features should follow the shared design-system note in +[docs/desktop-design-system.md](docs/desktop-design-system.md). Prefer the +tokens and primitives in `desktop/src/renderer/styles/globals.css` over one-off +utility class styling in JSX. + ## Pull requests - Keep changes focused and describe the behavior change clearly. diff --git a/README.md b/README.md index 2827d71..fd40206 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ HNX, and UPCOM: every recommendation should be grounded in tool output and constrained by explicit autonomy and risk settings. > Azoth is investment software, not financial advice. Live trading can place -> real orders against a real account. Use advisory or paper mode until you have +> real orders against a real account. Use manual or paper mode until you have > verified configuration, data quality, account state, and risk limits. Latest release: [v0.1.2](docs/releases/v0.1.2.md) @@ -70,8 +70,8 @@ Check market, portfolio, and backtest state: - **Multi-agent desk**: structured analyst workflow with technical, fundamentals, news, sentiment, bull, bear, research manager, trader, risk, and portfolio roles. -- **Broker-aware execution**: advisory, confirm, and auto autonomy modes with - explicit user approval before broker calls, paper broker support, and DNSE +- **Broker-aware execution**: manual and auto autonomy modes with explicit user + approval before every tool call in manual mode, paper broker support, and DNSE Entrade X integration for live accounts. - **Risk controls**: position sizing limits, order notional limits, optional ticker whitelist checks, market-session checks, margin-disabled enforcement, @@ -140,7 +140,7 @@ checks. | Team desk | Technical, fundamentals, news, sentiment, bull, bear, research manager, trader, risk, and portfolio roles. | | Market data | Quotes, OHLCV, technical indicators, fundamentals, CafeF news, macro indices, foreign flow, and ticker discovery. | | Portfolio | Broker state, sub-accounts, positions, cash, market value, and unrealized P&L. | -| Execution | Paper broker, optional DNSE broker, advisory/confirm/auto autonomy, human confirmation gate. | +| Execution | Paper broker, optional DNSE broker, manual/auto autonomy, human confirmation gate. | | Risk | Notional cap, concentration cap, whitelist, market session, no-margin cash check, daily-loss halt, drawdown buy freeze. | | Backtesting | Weekly team-driven replay, paper fills, fees, rejected guardrail orders, benchmark comparison, running-peak max drawdown. | | Runtime | `~/.azoth` config, SQLite state, project session logs, build-safe schema fallback. | @@ -239,7 +239,7 @@ still stream the full local team flow. | `/positions` | Summarize current portfolio positions and exposures. | | `/setup-llm` | Change LLM provider, API key, endpoint, and model after first-time setup. | | `/setup-fhsc` | Configure FHSC broker access and switch `broker` to `fhsc`. | -| `/autonomy ` | Persist the autonomy mode and rebuild tool access for new turns. | +| `/autonomy ` | Persist the autonomy mode and rebuild tool access for new turns. | | `/health [--probe]` | Check API key, config, DB, broker state, live-trading arm flag, market session, and optionally data providers. | | `/about` | Show version, runtime paths, broker, provider, and release references. | | `/new` | Start a new resumable session. | @@ -272,7 +272,7 @@ Useful environment variables: Default config: ```yaml -autonomy: advisory +autonomy: manual model: glm-5.1 llm: @@ -309,12 +309,10 @@ risk: Autonomy modes: -- `advisory`: no order tools are exposed. Azoth recommends; the user executes. -- `confirm`: broker tools are available, but each broker read/write requires - CLI approval before Azoth contacts the broker. -- `auto`: broker tools still require CLI approval before Azoth contacts the - broker; approved orders then run through configured guardrails before - submission. +- `manual`: all tools are available, but each tool call requires user approval + before it runs. +- `auto`: all tools run without approval prompts. Broker orders still run + through configured guardrails before submission. Broker modes: @@ -387,7 +385,7 @@ and creates the matching GitHub release. ## Live Trading With DNSE -Live mode places real orders. Keep `autonomy: advisory` or `broker: paper` +Live mode places real orders. Keep `autonomy: manual` or `broker: paper` until the checklist below is complete. 1. Open a DNSE account and enable Entrade X / LightSpeed API access. @@ -397,8 +395,8 @@ until the checklist below is complete. `GET https://api.dnse.com.vn/margin-service/loan-products` with the JWT and choose the correct loan product id for your equity sub-account. 4. Set `broker: dnse` in `~/.azoth/config.yaml`. -5. Set `autonomy: confirm` first while testing. All broker calls prompt for - approval in both `confirm` and `auto`. +5. Set `autonomy: manual` first while testing. Manual mode prompts before every + tool call; auto mode bypasses approval prompts. 6. Run `pnpm test` and then `DNSE_TEST_LIVE=1 pnpm test` for read-only live probes. 7. Verify `broker_state` and `list_orders` return the expected cash, positions, @@ -487,6 +485,5 @@ Azoth is built around a few explicit constraints: - Buy, sell, or hold recommendations should include technicals, fundamentals, news, and macro context. - News citations should include source URL and publish date. -- Order placement is disabled in advisory mode. In confirm/auto modes, every - broker call requires user approval before Azoth contacts the broker, and - approved orders still run through guardrails. +- Manual mode requires user approval before each tool call. Auto mode bypasses + approval prompts. Broker orders still run through guardrails in both modes. diff --git a/ROADMAP.md b/ROADMAP.md index e11aa56..3fcb365 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,7 +70,7 @@ Azoth v0.1.0, released on May 4, 2026, provides the public baseline: - Add comparison workflows for pairs, sectors, and portfolio candidates. - Improve drawdown, realized P&L, turnover, exposure, and concentration reporting. -- Add pre-trade impact previews before confirm or auto execution. +- Add pre-trade impact previews before manual approval or auto execution. - Add configurable risk presets for conservative, balanced, and aggressive operation. - Improve team synthesis with structured evidence tables and source timestamps. diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml new file mode 100644 index 0000000..4c6d2e7 --- /dev/null +++ b/desktop/electron-builder.yml @@ -0,0 +1,25 @@ +appId: com.toreleon.azoth +productName: Azoth +directories: + output: release + buildResources: resources +files: + - "out/**" + - "package.json" + - "!**/node_modules/*/{CHANGELOG.md,README.md,*.d.ts,*.map}" +asarUnpack: + - "**/node_modules/better-sqlite3/**" +mac: + category: public.app-category.finance + target: + - target: dmg + arch: [arm64, x64] + - target: zip + arch: [arm64, x64] + hardenedRuntime: true + gatekeeperAssess: false + entitlements: resources/entitlements.mac.plist + entitlementsInherit: resources/entitlements.mac.plist + notarize: false +dmg: + sign: false diff --git a/desktop/electron.vite.config.ts b/desktop/electron.vite.config.ts new file mode 100644 index 0000000..2eb5a27 --- /dev/null +++ b/desktop/electron.vite.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +const coreAlias = { + "@azoth/core": resolve(__dirname, "../src"), + "@shared": resolve(__dirname, "src/shared"), +}; + +const nativeExternals = ["better-sqlite3"]; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + resolve: { alias: coreAlias }, + build: { + outDir: "out/main", + rollupOptions: { + external: nativeExternals, + input: resolve(__dirname, "src/main/index.ts"), + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + resolve: { alias: coreAlias }, + build: { + outDir: "out/preload", + rollupOptions: { + input: resolve(__dirname, "src/preload/index.ts"), + }, + }, + }, + renderer: { + root: resolve(__dirname, "src/renderer"), + plugins: [react()], + resolve: { alias: coreAlias }, + build: { + outDir: resolve(__dirname, "out/renderer"), + rollupOptions: { + input: resolve(__dirname, "src/renderer/index.html"), + }, + }, + }, +}); diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..e89c955 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,35 @@ +{ + "name": "azoth-desktop", + "version": "0.1.0", + "private": true, + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build && electron-builder --mac", + "build:unpack": "electron-vite build && electron-builder --mac --dir", + "preview": "electron-vite preview", + "postinstall": "electron-builder install-app-deps", + "typecheck": "tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.web.json --noEmit", + "rebuild": "electron-rebuild -f -w better-sqlite3" + }, + "dependencies": { + "better-sqlite3": "^11.10.0", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@electron/rebuild": "^3.6.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "electron": "^33.0.0", + "electron-builder": "^25.0.0", + "electron-vite": "^2.3.0", + "postcss": "^8.4.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.0", + "typescript": "^5.6.3", + "vite": "^5.4.0" + } +} diff --git a/desktop/postcss.config.js b/desktop/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/desktop/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/desktop/resources/entitlements.mac.plist b/desktop/resources/entitlements.mac.plist new file mode 100644 index 0000000..9b2443e --- /dev/null +++ b/desktop/resources/entitlements.mac.plist @@ -0,0 +1,16 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + diff --git a/desktop/src/main/appSettings.ts b/desktop/src/main/appSettings.ts new file mode 100644 index 0000000..ba41212 --- /dev/null +++ b/desktop/src/main/appSettings.ts @@ -0,0 +1,70 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { app, nativeTheme } from "electron"; +import { azothHome } from "@azoth/core/runtime/paths.js"; +import type { DesktopSettings } from "../shared/ipc.js"; + +const DEFAULT_SETTINGS: DesktopSettings = { + launchAtLogin: false, + hideOnClose: true, + showNotifications: true, + notifyOnOrderFill: true, + appearance: "light", +}; + +function settingsPath(): string { + return resolve(azothHome(), "desktop-settings.json"); +} + +function normalizeSettings(raw: Partial | null | undefined): DesktopSettings { + const appearance = ["light", "dark", "system"].includes(String(raw?.appearance)) + ? raw!.appearance! + : DEFAULT_SETTINGS.appearance; + return { + launchAtLogin: Boolean(raw?.launchAtLogin ?? DEFAULT_SETTINGS.launchAtLogin), + hideOnClose: Boolean(raw?.hideOnClose ?? DEFAULT_SETTINGS.hideOnClose), + showNotifications: Boolean(raw?.showNotifications ?? DEFAULT_SETTINGS.showNotifications), + notifyOnOrderFill: Boolean(raw?.notifyOnOrderFill ?? DEFAULT_SETTINGS.notifyOnOrderFill), + appearance, + }; +} + +function readStoredSettings(): DesktopSettings { + const path = settingsPath(); + if (!existsSync(path)) return DEFAULT_SETTINGS; + try { + return normalizeSettings(JSON.parse(readFileSync(path, "utf8")) as Partial); + } catch { + return DEFAULT_SETTINGS; + } +} + +function writeStoredSettings(settings: DesktopSettings): void { + mkdirSync(azothHome(), { recursive: true }); + writeFileSync(settingsPath(), `${JSON.stringify(settings, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +export function getDesktopSettings(): DesktopSettings { + const settings = readStoredSettings(); + if (app.isReady()) { + settings.launchAtLogin = app.getLoginItemSettings().openAtLogin; + } + return settings; +} + +export function applyDesktopSettings(settings = getDesktopSettings()): void { + nativeTheme.themeSource = settings.appearance; + if (app.isReady()) { + app.setLoginItemSettings({ openAtLogin: settings.launchAtLogin }); + } +} + +export function saveDesktopSettings(patch: Partial): DesktopSettings { + const next = normalizeSettings({ ...getDesktopSettings(), ...patch }); + writeStoredSettings(next); + applyDesktopSettings(next); + return getDesktopSettings(); +} diff --git a/desktop/src/main/consent.ts b/desktop/src/main/consent.ts new file mode 100644 index 0000000..f61d9d6 --- /dev/null +++ b/desktop/src/main/consent.ts @@ -0,0 +1,34 @@ +import { randomUUID } from "node:crypto"; +import { setBrokerConsentHandler, type BrokerConsentRequest } from "@azoth/core/tools/brokerConsent.js"; +import { sendStream } from "./streamBus.js"; + +const pending = new Map void>(); + +export function installConsentBridge(): void { + setBrokerConsentHandler(async (req: BrokerConsentRequest) => { + return new Promise((resolve) => { + const id = randomUUID(); + pending.set(id, resolve); + sendStream({ + kind: "consent:request", + id, + action: req.action, + detail: req.detail, + broker: req.broker, + autonomy: req.autonomy, + }); + }); + }); +} + +export function respondConsent(id: string, approved: boolean): void { + const resolve = pending.get(id); + if (!resolve) return; + pending.delete(id); + resolve(approved); +} + +export function clearConsent(): void { + for (const resolve of pending.values()) resolve(false); + pending.clear(); +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts new file mode 100644 index 0000000..9426a49 --- /dev/null +++ b/desktop/src/main/index.ts @@ -0,0 +1,56 @@ +import { app, BrowserWindow } from "electron"; +import { initializeAzothRuntime } from "@azoth/core/runtime/init.js"; +import { loadConfig } from "@azoth/core/config/loader.js"; +import { ensureDefaultProject, getProject } from "./projects.js"; +import { activateProject } from "./projectContext.js"; +import { createMainWindow, markAppQuitting } from "./window.js"; +import { bindMainWindow } from "./streamBus.js"; +import { abortAllTurns, registerIpcHandlers } from "./mainIpc.js"; +import { installConsentBridge, clearConsent } from "./consent.js"; +import { applyDesktopSettings } from "./appSettings.js"; + +function boot(): void { + initializeAzothRuntime(); + try { + loadConfig(); + } catch (err) { + // Config may be incomplete on first boot; ignored — onboarding will fix it. + console.warn("[azoth] loadConfig failed during boot:", (err as Error).message); + } + const { activeId } = ensureDefaultProject(); + const project = getProject(activeId); + if (project) activateProject(project); + installConsentBridge(); + registerIpcHandlers(); + applyDesktopSettings(); + + const win = createMainWindow(); + bindMainWindow(win); +} + +app.whenReady().then(boot).catch((err) => { + console.error("[azoth] fatal boot error:", err); + app.exit(1); +}); + +app.on("window-all-closed", () => { + abortAllTurns(); + clearConsent(); + if (process.platform !== "darwin") app.quit(); +}); + +app.on("activate", () => { + const existing = BrowserWindow.getAllWindows()[0]; + if (existing) { + existing.show(); + existing.focus(); + } else { + const win = createMainWindow(); + bindMainWindow(win); + } +}); + +app.on("before-quit", () => { + markAppQuitting(); + abortAllTurns(); +}); diff --git a/desktop/src/main/ipc/marketHandlers.ts b/desktop/src/main/ipc/marketHandlers.ts new file mode 100644 index 0000000..2fb60be --- /dev/null +++ b/desktop/src/main/ipc/marketHandlers.ts @@ -0,0 +1,374 @@ +import { nowSec } from "@azoth/core/agent/clock.js"; +import { getCompanyIntro, getScreenerSnapshot } from "@azoth/core/data/sources/cafef.js"; +import { getIndexOhlcv, getStockOhlcv, type Bar, type Resolution } from "@azoth/core/data/sources/dnsePublic.js"; +import { getQuote } from "@azoth/core/data/sources/ssiIboard.js"; +import { getCompanyProfile } from "@azoth/core/data/sources/vndirectFinfo.js"; +import { MarketAssetReq, MarketHeatmapReq, MarketOverviewReq, type MarketIndexOverview } from "../../shared/ipc.js"; +import type { IpcRegister } from "./register.js"; + +const MARKET_INDICES = [ + { symbol: "VNINDEX", name: "VN-Index", exchange: "HOSE" }, + { symbol: "VN30", name: "VN30", exchange: "HOSE" }, + { symbol: "HNX", name: "HNX-Index", exchange: "HNX" }, + { symbol: "UPCOM", name: "UPCoM-Index", exchange: "UPCoM" }, +]; + +const MARKET_INDEX_SYMBOLS = new Map(MARKET_INDICES.map((index) => [index.symbol, index])); +let marketHeatmapCache: + | { expiresAt: number; value: { updatedAt: number; assets: MarketIndexOverview[] } } + | undefined; + +function lookbackDaysForMarket(resolution: Resolution, bars: number): number { + if (resolution === "1D") return bars * 2; + if (resolution === "1W") return bars * 14; + if (resolution === "1M") return bars * 60; + return Math.max(3, Math.ceil(bars / 50)); +} + +function compactMarketBar(bar: Bar) { + return { + t: bar.time, + o: roundMarketNumber(bar.open), + h: roundMarketNumber(bar.high), + l: roundMarketNumber(bar.low), + c: roundMarketNumber(bar.close), + v: Math.round(bar.volume), + }; +} + +function roundMarketNumber(value: number): number { + return Math.round(value * 100) / 100; +} + +function marketLine( + bars: Bar[], + values: number[], +): Array<{ t: number; value: number }> { + return values.map((value, idx) => ({ + t: bars[bars.length - values.length + idx]!.time, + value: roundMarketNumber(value), + })); +} + +function sma(values: number[], period: number): number[] { + if (values.length < period) return []; + const out: number[] = []; + let sum = values.slice(0, period).reduce((acc, value) => acc + value, 0); + out.push(sum / period); + for (let i = period; i < values.length; i++) { + sum += values[i]! - values[i - period]!; + out.push(sum / period); + } + return out; +} + +function ema(values: number[], period: number): number[] { + if (values.length < period) return []; + const out: number[] = []; + const k = 2 / (period + 1); + let prev = values.slice(0, period).reduce((acc, value) => acc + value, 0) / period; + out.push(prev); + for (let i = period; i < values.length; i++) { + prev = values[i]! * k + prev * (1 - k); + out.push(prev); + } + return out; +} + +function rma(values: number[], period: number): number[] { + if (values.length < period) return []; + const out: number[] = []; + let prev = values.slice(0, period).reduce((acc, value) => acc + value, 0) / period; + out.push(prev); + for (let i = period; i < values.length; i++) { + prev = (prev * (period - 1) + values[i]!) / period; + out.push(prev); + } + return out; +} + +function buildMarketSignals(rawBars: Bar[]) { + const closes = rawBars.map((bar) => bar.close); + const sma20 = sma(closes, 20); + const ema20 = ema(closes, 20); + const rma14 = rma(closes, 14); + const latestClose = closes.at(-1); + const latestRma = rma14.at(-1); + const prevRma = rma14.at(-2); + const slope = latestRma != null && prevRma != null ? latestRma - prevRma : 0; + const nextClose = + latestClose != null ? roundMarketNumber(latestClose + slope) : undefined; + const changePct = + latestClose && nextClose != null + ? roundMarketNumber(((nextClose - latestClose) / latestClose) * 100) + : undefined; + const direction = !changePct || Math.abs(changePct) < 0.05 + ? "flat" + : changePct > 0 + ? "up" + : "down"; + const distanceFromRma = + latestClose && latestRma ? Math.abs((latestClose - latestRma) / latestClose) * 100 : 0; + const confidence = + Math.abs(changePct ?? 0) > 0.8 && distanceFromRma > 1.5 + ? "high" + : Math.abs(changePct ?? 0) > 0.25 + ? "medium" + : "low"; + + return { + overlays: { + sma20: marketLine(rawBars, sma20), + ema20: marketLine(rawBars, ema20), + rma14: marketLine(rawBars, rma14), + }, + forecast: { + method: "RMA14 slope projection", + nextClose, + changePct, + direction, + confidence, + }, + } satisfies Pick; +} + +function inferMarketKind(symbol: string): "index" | "stock" { + return MARKET_INDEX_SYMBOLS.has(symbol) ? "index" : "stock"; +} + +function parseCafefTimestamp(input: string | undefined): number | undefined { + if (!input) return undefined; + const match = /\/Date\((\d+)\)\//.exec(input); + if (match?.[1]) return Math.floor(Number(match[1]) / 1000); + const parsed = Date.parse(input); + return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : undefined; +} + +function normalizeExchange(value: string | undefined): string { + const exchange = (value ?? "VN").trim(); + if (/^hsx$/i.test(exchange)) return "HOSE"; + if (/^upcom$/i.test(exchange)) return "UPCoM"; + return exchange.toUpperCase(); +} + +async function loadMarketHeatmap(includeIndexes: boolean): Promise<{ updatedAt: number; assets: MarketIndexOverview[] }> { + const now = Date.now(); + if (marketHeatmapCache && marketHeatmapCache.expiresAt > now) { + return includeIndexes + ? marketHeatmapCache.value + : { + ...marketHeatmapCache.value, + assets: marketHeatmapCache.value.assets.filter((asset) => asset.kind !== "index"), + }; + } + + const snapshot = await getScreenerSnapshot(); + const stocks = snapshot.items + .filter((item) => /^[A-Z0-9]{3,12}$/.test(item.Symbol)) + .map((item): MarketIndexOverview => { + const latestClose = item.Price != null ? roundMarketNumber(item.Price) : undefined; + const changePct = item.ChangePrice != null ? roundMarketNumber(item.ChangePrice) : undefined; + const previousClose = + latestClose != null && changePct != null && changePct !== -100 + ? roundMarketNumber(latestClose / (1 + changePct / 100)) + : undefined; + const change = + latestClose != null && previousClose != null + ? roundMarketNumber(latestClose - previousClose) + : undefined; + return { + symbol: item.Symbol.toUpperCase(), + name: item.FullName ?? item.Symbol.toUpperCase(), + exchange: normalizeExchange(item.CenterName), + kind: "stock", + industry: snapshot.categories[item.ParentCategoryId ?? 0] ?? "Unclassified", + latestClose, + previousClose, + change, + changePct, + volume: item.ChangeVolume != null ? Math.max(0, Math.round(Math.abs(item.ChangeVolume))) : undefined, + marketCap: item.VonHoa != null ? roundMarketNumber(item.VonHoa) : undefined, + updatedAt: parseCafefTimestamp(item.UpdatedDate), + bars: [], + }; + }); + + const value = { + updatedAt: nowSec(), + assets: includeIndexes + ? [ + ...MARKET_INDICES.map((index): MarketIndexOverview => ({ + ...index, + kind: "index", + industry: "Market indexes", + bars: [], + })), + ...stocks, + ] + : stocks, + }; + marketHeatmapCache = { expiresAt: now + 60_000, value }; + return value; +} + +async function loadIndexOverview( + index: (typeof MARKET_INDICES)[number], + resolution: Resolution, + bars: number, +): Promise { + const to = nowSec(); + const from = to - lookbackDaysForMarket(resolution, bars) * 86400; + try { + const rawBars = (await getIndexOhlcv(index.symbol, resolution, from, to)).slice(-bars); + const signals = buildMarketSignals(rawBars); + const latest = rawBars[rawBars.length - 1]; + const previous = rawBars[rawBars.length - 2] ?? rawBars[0]; + const change = + latest && previous ? roundMarketNumber(latest.close - previous.close) : undefined; + const changePct = + latest && previous?.close + ? roundMarketNumber(((latest.close - previous.close) / previous.close) * 100) + : undefined; + return { + ...index, + kind: "index", + industry: "Market indexes", + latestClose: latest ? roundMarketNumber(latest.close) : undefined, + previousClose: previous ? roundMarketNumber(previous.close) : undefined, + change, + changePct, + high: rawBars.length + ? roundMarketNumber(Math.max(...rawBars.map((bar) => bar.high))) + : undefined, + low: rawBars.length + ? roundMarketNumber(Math.min(...rawBars.map((bar) => bar.low))) + : undefined, + volume: rawBars.reduce((sum, bar) => sum + Math.round(bar.volume), 0), + updatedAt: latest?.time, + bars: rawBars.map(compactMarketBar), + ...signals, + }; + } catch (err) { + return { + ...index, + kind: "index", + industry: "Market indexes", + bars: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + +async function loadMarketAsset( + symbolInput: string, + kindInput: "stock" | "index" | undefined, + resolution: Resolution, + bars: number, +): Promise { + const symbol = symbolInput.trim().toUpperCase(); + const kind = kindInput ?? inferMarketKind(symbol); + const indexMeta = MARKET_INDEX_SYMBOLS.get(symbol); + const to = nowSec(); + const from = to - lookbackDaysForMarket(resolution, bars) * 86400; + try { + const rawBars = ( + kind === "index" + ? await getIndexOhlcv(symbol, resolution, from, to) + : await getStockOhlcv(symbol, resolution, from, to) + ).slice(-bars); + const [quote, profile, intro] = kind === "stock" + ? await Promise.all([ + getQuote(symbol).catch(() => null), + getCompanyProfile(symbol).catch(() => null), + getCompanyIntro(symbol).catch(() => null), + ]) + : [null, null, null] as const; + const latest = rawBars[rawBars.length - 1]; + const previous = rawBars[rawBars.length - 2] ?? rawBars[0]; + const latestClose = quote?.matchedPrice ?? latest?.close; + const previousClose = quote?.ref || previous?.close; + const change = + latestClose != null && previousClose != null + ? roundMarketNumber(latestClose - previousClose) + : undefined; + const changePct = + latestClose != null && previousClose + ? roundMarketNumber(((latestClose - previousClose) / previousClose) * 100) + : undefined; + return { + symbol, + name: indexMeta?.name ?? quote?.companyNameEn ?? profile?.enName ?? profile?.vnName ?? symbol, + exchange: indexMeta?.exchange ?? quote?.exchange ?? profile?.floor ?? "VN", + kind, + industry: indexMeta ? "Market indexes" : intro?.CategoryName ?? "Unclassified", + intro: intro?.Intro && intro.Intro.trim() ? intro.Intro.trim() : undefined, + website: intro?.Web && intro.Web.trim() ? intro.Web.trim() : undefined, + latestClose: latestClose != null ? roundMarketNumber(latestClose) : undefined, + previousClose: previousClose != null ? roundMarketNumber(previousClose) : undefined, + change, + changePct, + high: rawBars.length + ? roundMarketNumber(Math.max(...rawBars.map((bar) => bar.high))) + : undefined, + low: rawBars.length + ? roundMarketNumber(Math.min(...rawBars.map((bar) => bar.low))) + : undefined, + volume: rawBars.reduce((sum, bar) => sum + Math.round(bar.volume), 0), + updatedAt: latest?.time, + bars: rawBars.map(compactMarketBar), + ...buildMarketSignals(rawBars), + quote: quote + ? { + bestBid: quote.bestBid, + bestOffer: quote.bestOffer, + matchedVolume: quote.matchedVolume, + session: quote.session, + tradingStatus: quote.tradingStatus, + } + : undefined, + }; + } catch (err) { + return { + symbol, + name: indexMeta?.name ?? symbol, + exchange: indexMeta?.exchange ?? "VN", + kind, + industry: indexMeta ? "Market indexes" : "Unclassified", + bars: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + + +export function registerMarketHandlers(register: IpcRegister): void { + register("market:overview", async (raw) => { + const req = MarketOverviewReq.parse(raw); + const resolution = (req?.resolution ?? "1D") as Resolution; + const bars = req?.bars ?? 90; + const indices = await Promise.all( + MARKET_INDICES.map((index) => loadIndexOverview(index, resolution, bars)), + ); + return { + updatedAt: nowSec(), + indices, + }; + }); + + register("market:asset", async (raw) => { + const req = MarketAssetReq.parse(raw); + return loadMarketAsset( + req.symbol, + req.kind, + req.resolution as Resolution, + req.bars, + ); + }); + + register("market:heatmap", async (raw) => { + const req = MarketHeatmapReq.parse(raw); + return loadMarketHeatmap(req?.includeIndexes ?? true); + }); + + +} diff --git a/desktop/src/main/ipc/portfolioHandlers.ts b/desktop/src/main/ipc/portfolioHandlers.ts new file mode 100644 index 0000000..e027f55 --- /dev/null +++ b/desktop/src/main/ipc/portfolioHandlers.ts @@ -0,0 +1,146 @@ +import { nowSec } from "@azoth/core/agent/clock.js"; +import { getBroker } from "@azoth/core/broker/index.js"; +import type { Order, PlaceOrderInput } from "@azoth/core/broker/types.js"; +import { getStockOhlcv, type Bar } from "@azoth/core/data/sources/dnsePublic.js"; +import { placeOrderWithGuards } from "@azoth/core/tools/order.js"; +import { shapeBrokerPortfolio } from "@azoth/core/tools/portfolio.js"; +import { + PortfolioCancelOrderReq, + PortfolioHistoryReq, + PortfolioOrdersReq, + PortfolioPlaceOrderReq, + type BrokerOrderUi, + type PortfolioHistoryRes, + type PortfolioPlaceOrderRes, + type PortfolioSnapshot, +} from "../../shared/ipc.js"; +import type { IpcRegister } from "./register.js"; + +async function lastCloseThousandVnd(ticker: string): Promise { + const to = nowSec(); + const from = to - 14 * 86400; + const bars = await getStockOhlcv(ticker, "1D", from, to).catch(() => [] as Bar[]); + return bars.length ? bars[bars.length - 1]!.close : null; +} + +function toOrderUi(o: Order): BrokerOrderUi { + return { + id: o.id, + broker: o.broker, + ticker: o.ticker, + side: o.side, + type: o.type, + quantity: o.quantity, + limitPrice: o.limitPrice, + status: o.status, + rejectReason: o.rejectReason, + createdAt: o.createdAt, + filledAt: o.filledAt, + filledPrice: o.filledPrice, + filledQty: o.filledQty, + notes: o.notes, + }; +} + + +export function registerPortfolioHandlers(register: IpcRegister): void { + register("portfolio:snapshot", async () => { + const broker = getBroker(); + const snap = await broker.snapshot(); + const shaped = await shapeBrokerPortfolio(snap, lastCloseThousandVnd); + return shaped as unknown as PortfolioSnapshot; + }); + + register("portfolio:orders", async (raw) => { + const req = PortfolioOrdersReq.parse(raw); + const broker = getBroker(); + const orders = await broker.listOrders({ + ticker: req?.ticker, + status: req?.status, + limit: req?.limit ?? 50, + }); + return { orders: orders.map(toOrderUi) }; + }); + + register("portfolio:history", async (raw) => { + const req = PortfolioHistoryReq.parse(raw); + const broker = getBroker(); + if (!broker.accountHistory) { + const res: PortfolioHistoryRes = { + supported: false, + broker: broker.name, + reason: `Broker "${broker.name}" does not support account history.`, + }; + return res; + } + const history = await broker.accountHistory({ + fromDate: req.fromDate, + toDate: req.toDate, + ticker: req.ticker?.toUpperCase(), + limit: req.limit, + }); + const kind = req.kind; + const filtered = { + orders: kind === "all" || kind === "orders" ? history.orders : [], + fills: kind === "all" || kind === "orders" || kind === "fills" ? history.fills : [], + transactions: kind === "all" || kind === "transactions" ? history.transactions : [], + rights: kind === "all" || kind === "rights" ? history.rights : [], + }; + const res: PortfolioHistoryRes = { + supported: true, + broker: history.broker, + fromDate: history.fromDate, + toDate: history.toDate, + subAccounts: history.subAccounts, + ...filtered, + unavailable: history.unavailable, + }; + return res; + }); + + register("portfolio:placeOrder", async (raw) => { + const req = PortfolioPlaceOrderReq.parse(raw); + const input: PlaceOrderInput = { + ticker: req.ticker.toUpperCase(), + side: req.side, + type: req.type, + quantity: req.quantity, + limitPrice: req.limitPrice, + notes: req.notes, + }; + try { + const result = await placeOrderWithGuards(input); + if (!result.ok) { + const res: PortfolioPlaceOrderRes = + result.error === "no_reference_price" + ? { + ok: false, + error: "no_reference_price", + message: `No reference price available for ${result.ticker}.`, + } + : { + ok: false, + error: "guardrail_blocked", + reasons: result.reasons, + order: result.order ? toOrderUi(result.order) : undefined, + }; + return res; + } + const okRes: PortfolioPlaceOrderRes = { ok: true, order: toOrderUi(result.order) }; + return okRes; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const res: PortfolioPlaceOrderRes = { ok: false, error: "broker_error", message }; + return res; + } + }); + + register("portfolio:cancelOrder", async (raw) => { + const req = PortfolioCancelOrderReq.parse(raw); + const broker = getBroker(); + const order = await broker.cancelOrder(req.id); + return { ok: order.status === "CANCELLED", order: toOrderUi(order) }; + }); + + +} diff --git a/desktop/src/main/ipc/register.ts b/desktop/src/main/ipc/register.ts new file mode 100644 index 0000000..ebe68ad --- /dev/null +++ b/desktop/src/main/ipc/register.ts @@ -0,0 +1,7 @@ +import type { IpcChannelMap } from "../../shared/ipc.js"; + +export type Handler = ( + req: IpcChannelMap[K]["req"], +) => Promise | IpcChannelMap[K]["res"]; + +export type IpcRegister = (channel: K, handler: Handler) => void; diff --git a/desktop/src/main/mainIpc.ts b/desktop/src/main/mainIpc.ts new file mode 100644 index 0000000..8c06c73 --- /dev/null +++ b/desktop/src/main/mainIpc.ts @@ -0,0 +1,625 @@ +import { ipcMain, Notification } from "electron"; +import { + ActivateProjectReq, + AbortTurnReq, + ArchiveSessionReq, + ConsentRespondReq, + CreateProjectReq, + DeleteProjectReq, + HealthProbeReq, + ListModelsReq, + ListSessionsReq, + ResumeSessionReq, + RestoreSessionReq, + SaveConfigReq, + SaveDesktopSettingsReq, + SendPromptReq, + SlashCommandReq, + StartSessionReq, + type ChatRecord, + type IpcChannelMap, + type SessionDescriptor, + type TeamUiEvent, +} from "../shared/ipc.js"; +import { + createProject, + deleteProject, + getProject, + isOnboarded, + listProjects, + setActiveProject, + setOnboarded, +} from "./projects.js"; +import { activateProject } from "./projectContext.js"; +import { respondConsent } from "./consent.js"; +import { sendStream } from "./streamBus.js"; +import { + resumeSession, + startNewSession, + recentSessions, + runTurn, +} from "@azoth/core/agent/orchestrator.js"; +import { + archiveSession, + appendSessionRecord, + listSessions, + readSessionRecords, + restoreArchivedSession, + type SessionIndexEntry, + type SessionRecord, +} from "@azoth/core/runtime/sessionStore.js"; +import { loadConfig, updateConfig, type Config } from "@azoth/core/config/loader.js"; +import { registerMarketHandlers } from "./ipc/marketHandlers.js"; +import { registerPortfolioHandlers } from "./ipc/portfolioHandlers.js"; +import type { Handler } from "./ipc/register.js"; +import { collectHealth, renderHealth } from "@azoth/core/runtime/health.js"; +import { azothPaths } from "@azoth/core/runtime/paths.js"; +import { listLlmModels } from "@azoth/core/runtime/llmSetup.js"; +import { abortActiveTeamRuns } from "@azoth/core/tools/team.js"; +import { + subscribeTeamToolEvents, + type TeamToolEvent, + withTeamToolEventContext, +} from "@azoth/core/agent/team/toolEventBus.js"; +import { getDesktopSettings, saveDesktopSettings } from "./appSettings.js"; +import { SLASH_COMMANDS } from "../shared/slashCommands.js"; + +const activeTurns = new Map(); +const abortedTurns = new Set(); + +function toDescriptor(entry: SessionIndexEntry): SessionDescriptor { + return { + id: entry.id, + sdkSessionId: entry.sdkSessionId, + title: entry.title, + cwd: entry.cwd, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + model: entry.model, + autonomy: entry.autonomy, + }; +} + +function toRecord(r: SessionRecord): ChatRecord { + return { + type: r.type, + timestamp: r.timestamp, + sessionId: r.sessionId, + text: r.text, + toolName: r.toolName, + toolUseId: r.toolUseId, + toolInput: r.toolInput, + sdkSessionId: r.sdkSessionId, + usage: r.usage, + costUsd: r.costUsd, + model: r.model, + autonomy: r.autonomy, + title: r.title, + }; +} + +function sendRecord(turnId: string, record: ChatRecord): void { + sendStream({ + kind: "turn:record", + turnId, + sessionId: record.sessionId, + record, + }); +} + +function maybeNotifyOrderResult(record: ChatRecord, records: ChatRecord[]): void { + if (record.type !== "tool_result" || !record.toolUseId || !record.text) return; + const settings = getDesktopSettings(); + if (!settings.showNotifications || !settings.notifyOnOrderFill || !Notification.isSupported()) return; + + const tool = records.find( + (candidate) => candidate.type === "tool_use" && candidate.toolUseId === record.toolUseId, + ); + if (tool?.toolName !== "place_order" && tool?.toolName !== "cancel_order") return; + + try { + const parsed = JSON.parse(record.text) as { + order?: { + status?: string; + side?: string; + quantity?: number; + ticker?: string; + rejectReason?: string | null; + }; + error?: string; + }; + const status = parsed.order?.status; + if (!status || !["FILLED", "REJECTED", "CANCELLED"].includes(status)) return; + const order = parsed.order; + if (!order) return; + const detail = [order.side, order.quantity, order.ticker].filter(Boolean).join(" "); + const body = status === "REJECTED" && (order.rejectReason || parsed.error) + ? `${detail} - ${order.rejectReason ?? parsed.error}` + : detail; + new Notification({ + title: `Order ${status.toLowerCase()}`, + body, + }).show(); + } catch { + // Tool output is best-effort JSON; ignore non-order payloads. + } +} + +function sendLiveBlockEvent(turnId: string, sessionId: string, message: unknown): void { + if ((message as { type?: string }).type !== "stream_event") return; + const ev = (message as { event?: any }).event; + if (ev?.type === "content_block_start") { + const cb = ev.content_block; + const blockType = + cb?.type === "thinking" + ? "thinking" + : cb?.type === "text" + ? "assistant" + : cb?.type === "tool_use" + ? "tool_use" + : undefined; + if (!blockType) return; + sendStream({ + kind: "turn:block_start", + turnId, + sessionId, + blockType, + toolName: cb?.name, + toolUseId: cb?.id, + timestamp: Date.now(), + }); + } else if (ev?.type === "content_block_delta") { + const d = ev.delta; + const delta = + d?.type === "thinking_delta" + ? d.thinking + : d?.type === "text_delta" + ? d.text + : d?.type === "input_json_delta" + ? d.partial_json + : undefined; + if (!delta) return; + sendStream({ + kind: "turn:block_delta", + turnId, + sessionId, + delta, + }); + } else if (ev?.type === "content_block_stop" || ev?.type === "message_stop") { + sendStream({ + kind: "turn:block_stop", + turnId, + sessionId, + }); + } +} + +function compactText(value: string | undefined, limit = 120): string { + if (!value) return ""; + const oneLine = value.replace(/\s+/g, " ").trim(); + if (oneLine.length <= limit) return oneLine; + return `${oneLine.slice(0, limit - 3)}...`; +} + +function toTeamUiEvent(event: TeamToolEvent): TeamUiEvent | null { + const ev = event.event; + switch (ev.type) { + case "run_start": + return { + type: "run_start", + teamTool: event.tool, + runId: ev.runId, + ticker: ev.ticker, + }; + case "role_start": + return { + type: "role_start", + teamTool: event.tool, + role: ev.role, + round: ev.round, + }; + case "role_tool": { + const input = compactText(ev.input); + return { + type: "role_tool", + teamTool: event.tool, + role: ev.role, + subtool: ev.tool, + detail: input, + }; + } + case "role_tool_result": + return { + type: "role_tool_result", + teamTool: event.tool, + role: ev.role, + subtool: ev.tool, + }; + case "role_end": + return { + type: "role_end", + teamTool: event.tool, + role: ev.role, + round: ev.round, + }; + case "final": + return { + type: "final", + teamTool: event.tool, + ticker: ev.decision.ticker, + rating: ev.decision.rating, + sizingPct: ev.decision.sizingPct, + }; + case "error": + return { + type: "error", + teamTool: event.tool, + role: ev.role, + message: compactText(ev.message, 240), + }; + case "role_delta": + return null; + default: + return null; + } +} + +function activateProjectById(id: string) { + const project = getProject(id); + if (!project) throw new Error(`Unknown project: ${id}`); + activateProject(project); + return project; +} + +function renderAbout(): string { + const cfg = loadConfig(); + const paths = azothPaths(); + return [ + "Azoth Desktop", + `config: ${paths.config}`, + `database: ${paths.db}`, + `sessions: ${paths.projects}`, + `provider: ${cfg.llm.provider}`, + `model: ${cfg.model}`, + `broker: ${cfg.broker}`, + `autonomy: ${cfg.autonomy}`, + ].join("\n"); +} + +function renderHelp(): string { + return SLASH_COMMANDS + .map((c) => `/${c.name}${c.args ? ` ${c.args}` : ""} - ${c.description}`) + .join("\n"); +} + +function persistLocalSlashTurn( + sessionId: string | undefined, + cwd: string, + prompt: string, + response: string, +): void { + if (!sessionId) return; + const cfg = loadConfig(); + appendSessionRecord(sessionId, { + type: "user", + timestamp: Date.now(), + sessionId, + cwd, + text: prompt, + model: cfg.model, + autonomy: cfg.autonomy, + }, cwd); + appendSessionRecord(sessionId, { + type: "assistant", + timestamp: Date.now(), + sessionId, + cwd, + text: response, + model: cfg.model, + autonomy: cfg.autonomy, + }, cwd); +} + +function register(channel: K, handler: Handler): void { + ipcMain.handle(channel, async (_evt, raw) => handler(raw)); +} + +export function registerIpcHandlers(): void { + register("project:list", () => listProjects()); + + register("project:create", (raw) => { + const req = CreateProjectReq.parse(raw); + return createProject(req); + }); + + register("project:delete", (raw) => { + const req = DeleteProjectReq.parse(raw); + deleteProject(req.id); + return { ok: true as const }; + }); + + register("project:activate", (raw) => { + const req = ActivateProjectReq.parse(raw); + const project = setActiveProject(req.id); + activateProject(project); + return project; + }); + + register("session:list", (raw) => { + const req = ListSessionsReq.parse(raw); + const project = activateProjectById(req.projectId); + return listSessions(project.rootPath).map(toDescriptor); + }); + + register("session:start", (raw) => { + const req = StartSessionReq.parse(raw); + const project = activateProjectById(req.projectId); + const entry = startNewSession(req.title, project.rootPath); + return toDescriptor(entry); + }); + + register("session:resume", (raw) => { + const req = ResumeSessionReq.parse(raw); + const project = activateProjectById(req.projectId); + const entry = resumeSession(req.sessionId, project.rootPath); + if (!entry) throw new Error(`Session not found: ${req.sessionId}`); + const records = readSessionRecords(entry.id, project.rootPath).map(toRecord); + return { session: toDescriptor(entry), records }; + }); + + register("session:archive", (raw) => { + const req = ArchiveSessionReq.parse(raw); + const project = activateProjectById(req.projectId); + archiveSession(req.sessionId, project.rootPath); + return { ok: true as const }; + }); + + register("session:restore", (raw) => { + const req = RestoreSessionReq.parse(raw); + const project = activateProjectById(req.projectId); + const entry = restoreArchivedSession(req.session, project.rootPath); + return toDescriptor(entry); + }); + + register("turn:send", (raw) => { + const req = SendPromptReq.parse(raw); + const project = activateProjectById(req.projectId); + const session = resumeSession(req.sessionId, project.rootPath); + if (!session) throw new Error(`Session not found: ${req.sessionId}`); + const controller = new AbortController(); + let streamedRecordCount = readSessionRecords(req.sessionId, project.rootPath).length; + + const drainRecords = () => { + const records = readSessionRecords(req.sessionId, project.rootPath).map(toRecord); + for (const record of records.slice(streamedRecordCount)) { + sendRecord(req.turnId, record); + maybeNotifyOrderResult(record, records); + } + streamedRecordCount = records.length; + }; + + activeTurns.set(req.turnId, { controller, sessionId: req.sessionId }); + + void (async () => { + const unsubscribeTeamEvents = subscribeTeamToolEvents((event) => { + if (abortedTurns.has(req.turnId) || controller.signal.aborted) return; + if (event.contextId && event.contextId !== req.turnId) return; + if (!event.contextId && activeTurns.size > 1) return; + const teamEvent = toTeamUiEvent(event); + if (!teamEvent) return; + sendStream({ + kind: "team:event", + turnId: req.turnId, + sessionId: req.sessionId, + event: teamEvent, + }); + }); + try { + let usage: ChatRecord["usage"] | undefined; + let costUsd: number | undefined; + let sdkSessionId: string | undefined; + await withTeamToolEventContext(req.turnId, async () => { + for await (const message of runTurn(req.prompt, { + signal: controller.signal, + sessionId: req.sessionId, + cwd: project.rootPath, + displayPrompt: req.displayPrompt, + })) { + if (controller.signal.aborted) break; + drainRecords(); + sendLiveBlockEvent(req.turnId, req.sessionId, message); + if ((message as { type?: string }).type === "result") { + const r = message as { + session_id?: string; + total_cost_usd?: number; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + }; + sdkSessionId = r.session_id; + costUsd = r.total_cost_usd; + usage = { + inputTokens: r.usage?.input_tokens, + outputTokens: r.usage?.output_tokens, + cacheReadTokens: r.usage?.cache_read_input_tokens, + cacheCreationTokens: r.usage?.cache_creation_input_tokens, + }; + } + } + }); + if (abortedTurns.has(req.turnId)) return; + drainRecords(); + sendStream({ + kind: "turn:done", + turnId: req.turnId, + sessionId: req.sessionId, + usage, + costUsd, + sdkSessionId, + }); + } catch (err) { + if (abortedTurns.has(req.turnId) || controller.signal.aborted) return; + const message = err instanceof Error ? err.message : String(err); + sendStream({ + kind: "turn:error", + turnId: req.turnId, + sessionId: req.sessionId, + message, + }); + } finally { + unsubscribeTeamEvents(); + activeTurns.delete(req.turnId); + abortedTurns.delete(req.turnId); + } + })(); + + return { ok: true as const }; + }); + + register("turn:abort", (raw) => { + const req = AbortTurnReq.parse(raw); + const entry = activeTurns.get(req.turnId); + if (!entry) return { ok: false }; + abortedTurns.add(req.turnId); + abortActiveTeamRuns(); + entry.controller.abort(); + sendStream({ + kind: "turn:done", + turnId: req.turnId, + sessionId: entry.sessionId, + }); + return { ok: true }; + }); + + register("slash:run", async (raw) => { + const req = SlashCommandReq.parse(raw); + const project = activateProjectById(req.projectId); + const name = req.name.toLowerCase(); + const args = req.args?.trim() ?? ""; + let text: string; + switch (name) { + case "sessions": { + const list = recentSessions(20, project.rootPath) + .map((s) => { + const date = new Date(s.updatedAt).toISOString().slice(0, 16).replace("T", " "); + return `${s.id.slice(0, 8)} ${date} ${s.title}`; + }) + .join("\n"); + text = list || "No saved sessions for this project."; + break; + } + case "health": { + const report = await collectHealth({ probeProviders: args.includes("--probe") }); + text = renderHealth(report); + break; + } + case "autonomy": { + const mode = args.split(/\s+/)[0]; + if (!mode) { + text = `Current autonomy mode: ${loadConfig().autonomy}`; + break; + } + if (!["manual", "auto"].includes(mode)) { + text = "Usage: /autonomy "; + break; + } + const next = updateConfig({ autonomy: mode as Config["autonomy"] }); + text = `Autonomy mode set to ${next.autonomy}.`; + break; + } + case "about": + text = renderAbout(); + break; + case "help": + text = renderHelp(); + break; + case "team": + text = "Usage: /team "; + break; + case "quote": + text = "Usage: /quote "; + break; + case "new": { + startNewSession(undefined, project.rootPath); + text = "Started a fresh session."; + break; + } + default: + text = `Unknown command: /${req.name}. Type /help for available commands.`; + break; + } + persistLocalSlashTurn(req.sessionId, project.rootPath, `/${name}${args ? ` ${args}` : ""}`, text); + return { ok: true as const, text }; + }); + + register("config:get", () => loadConfig() as unknown); + + register("config:save", (raw) => { + const req = SaveConfigReq.parse(raw); + if (Object.keys(req.patch).length === 0) return loadConfig() as unknown; + return updateConfig(req.patch as Partial) as unknown; + }); + + register("models:list", async (raw) => { + const req = ListModelsReq.parse(raw); + const cfg = loadConfig(); + const provider = req?.provider ?? cfg.llm.provider; + const apiKey = req?.apiKey ?? cfg.llm.api_key; + const baseUrl = req?.baseUrl ?? cfg.llm.base_url; + try { + const models = await listLlmModels({ provider, apiKey, baseUrl }); + return { models }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { models: [], error: message }; + } + }); + + register("app-settings:get", () => getDesktopSettings()); + + register("app-settings:save", (raw) => { + const req = SaveDesktopSettingsReq.parse(raw); + return saveDesktopSettings(req.patch); + }); + + register("broker:state", async () => { + // Best-effort; broker state is exposed via a tool, but a direct read is useful. + const cfg = loadConfig(); + return { broker: cfg.broker, autonomy: cfg.autonomy }; + }); + + registerPortfolioHandlers(register); + + register("health:probe", async (raw) => { + const req = HealthProbeReq.parse(raw); + return collectHealth({ probeProviders: req.probe }); + }); + + registerMarketHandlers(register); + + register("consent:respond", (raw) => { + const req = ConsentRespondReq.parse(raw); + respondConsent(req.id, req.approved); + return { ok: true as const }; + }); + + register("onboarding:status", () => ({ onboarded: isOnboarded() })); + + register("onboarding:complete", () => { + setOnboarded(true); + return { ok: true as const }; + }); +} + +export function abortAllTurns(): void { + for (const [turnId, { controller, sessionId }] of activeTurns.entries()) { + abortedTurns.add(turnId); + abortActiveTeamRuns(); + controller.abort(); + sendStream({ kind: "turn:done", turnId, sessionId }); + } + activeTurns.clear(); +} diff --git a/desktop/src/main/projectContext.ts b/desktop/src/main/projectContext.ts new file mode 100644 index 0000000..5a4623e --- /dev/null +++ b/desktop/src/main/projectContext.ts @@ -0,0 +1,14 @@ +import { mkdirSync } from "node:fs"; +import type { Project } from "../shared/ipc.js"; + +let currentProjectId: string | null = null; + +export function activateProject(project: Project): void { + mkdirSync(project.rootPath, { recursive: true }); + process.chdir(project.rootPath); + currentProjectId = project.id; +} + +export function getCurrentProjectId(): string | null { + return currentProjectId; +} diff --git a/desktop/src/main/projects.ts b/desktop/src/main/projects.ts new file mode 100644 index 0000000..94e582e --- /dev/null +++ b/desktop/src/main/projects.ts @@ -0,0 +1,123 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { azothHome } from "@azoth/core/runtime/paths.js"; +import type { Project } from "../shared/ipc.js"; + +interface ProjectsFile { + version: 1; + onboarded: boolean; + activeId: string | null; + projects: Project[]; +} + +function projectsFilePath(): string { + return resolve(azothHome(), "projects-desktop.json"); +} + +function defaultRootFor(id: string): string { + return resolve(azothHome(), "desktop-projects", id); +} + +function emptyFile(): ProjectsFile { + return { version: 1, onboarded: false, activeId: null, projects: [] }; +} + +function readFile(): ProjectsFile { + const path = projectsFilePath(); + if (!existsSync(path)) return emptyFile(); + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Partial; + return { + version: 1, + onboarded: Boolean(raw.onboarded), + activeId: raw.activeId ?? null, + projects: Array.isArray(raw.projects) ? (raw.projects as Project[]) : [], + }; + } catch { + return emptyFile(); + } +} + +function writeFile(file: ProjectsFile): void { + mkdirSync(resolve(azothHome()), { recursive: true }); + writeFileSync(projectsFilePath(), `${JSON.stringify(file, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +export function ensureDefaultProject(): { projects: Project[]; activeId: string } { + const file = readFile(); + if (file.projects.length === 0) { + const id = randomUUID(); + const root = defaultRootFor("personal"); + mkdirSync(root, { recursive: true }); + const project: Project = { + id, + name: "Personal", + rootPath: root, + createdAt: Date.now(), + isDefault: true, + }; + file.projects.push(project); + file.activeId = id; + writeFile(file); + } else if (!file.activeId) { + file.activeId = file.projects[0]!.id; + writeFile(file); + } + return { projects: file.projects, activeId: file.activeId! }; +} + +export function listProjects(): { projects: Project[]; activeId: string | null } { + const file = readFile(); + return { projects: file.projects, activeId: file.activeId }; +} + +export function getProject(id: string): Project | undefined { + return readFile().projects.find((p) => p.id === id); +} + +export function createProject(input: { name: string; rootPath?: string }): Project { + const file = readFile(); + const id = randomUUID(); + const root = resolve(input.rootPath ?? defaultRootFor(id)); + mkdirSync(root, { recursive: true }); + const project: Project = { + id, + name: input.name, + rootPath: root, + createdAt: Date.now(), + }; + file.projects.push(project); + if (!file.activeId) file.activeId = id; + writeFile(file); + return project; +} + +export function deleteProject(id: string): void { + const file = readFile(); + file.projects = file.projects.filter((p) => p.id !== id); + if (file.activeId === id) file.activeId = file.projects[0]?.id ?? null; + writeFile(file); +} + +export function setActiveProject(id: string): Project { + const file = readFile(); + const project = file.projects.find((p) => p.id === id); + if (!project) throw new Error(`Unknown project: ${id}`); + file.activeId = id; + writeFile(file); + return project; +} + +export function isOnboarded(): boolean { + return readFile().onboarded; +} + +export function setOnboarded(value: boolean): void { + const file = readFile(); + file.onboarded = value; + writeFile(file); +} diff --git a/desktop/src/main/sessionTail.ts b/desktop/src/main/sessionTail.ts new file mode 100644 index 0000000..4fc128a --- /dev/null +++ b/desktop/src/main/sessionTail.ts @@ -0,0 +1,133 @@ +import { existsSync, statSync, createReadStream, watch, type FSWatcher } from "node:fs"; +import { sessionFile } from "@azoth/core/runtime/sessionStore.js"; +import type { ChatRecord, StreamEvent } from "../shared/ipc.js"; +import { sendStream } from "./streamBus.js"; + +interface TailHandle { + stop(): void; +} + +interface ActiveTail { + sessionId: string; + turnId: string; + offset: number; + buffer: string; + watcher: FSWatcher | null; + reading: boolean; + closed: boolean; +} + +function recordToEvent(turnId: string, record: ChatRecord): StreamEvent | null { + switch (record.type) { + case "thinking": + case "assistant": + return { + kind: "turn:record", + turnId, + sessionId: record.sessionId, + record, + }; + case "tool_use": + case "tool_result": + case "user": + case "system": + return { + kind: "turn:record", + turnId, + sessionId: record.sessionId, + record, + }; + case "result": + return { + kind: "turn:record", + turnId, + sessionId: record.sessionId, + record, + }; + default: + return null; + } +} + +async function drain(state: ActiveTail, path: string): Promise { + if (state.reading) return; + state.reading = true; + try { + if (!existsSync(path)) return; + const size = statSync(path).size; + if (size <= state.offset) return; + await new Promise((resolve, reject) => { + const stream = createReadStream(path, { + start: state.offset, + end: size - 1, + encoding: "utf8", + }); + stream.on("data", (chunk) => { + state.buffer += chunk; + }); + stream.on("error", reject); + stream.on("end", () => { + state.offset = size; + let nl = state.buffer.indexOf("\n"); + while (nl !== -1) { + const line = state.buffer.slice(0, nl).trim(); + state.buffer = state.buffer.slice(nl + 1); + if (line) { + try { + const record = JSON.parse(line) as ChatRecord; + const ev = recordToEvent(state.turnId, record); + if (ev) sendStream(ev); + } catch { + // ignore malformed line + } + } + nl = state.buffer.indexOf("\n"); + } + resolve(); + }); + }); + } finally { + state.reading = false; + } +} + +export function tailSession(opts: { + sessionId: string; + turnId: string; + cwd: string; +}): TailHandle { + const path = sessionFile(opts.sessionId, opts.cwd); + const state: ActiveTail = { + sessionId: opts.sessionId, + turnId: opts.turnId, + offset: existsSync(path) ? statSync(path).size : 0, + buffer: "", + watcher: null, + reading: false, + closed: false, + }; + try { + state.watcher = watch(path, { persistent: false }, () => { + void drain(state, path); + }); + } catch { + // file may not exist yet; fallback handled below + } + const interval = setInterval(() => { + void drain(state, path); + }, 80); + return { + stop() { + clearInterval(interval); + try { + state.watcher?.close(); + } catch { + /* noop */ + } + // Final drain. + void drain(state, path).finally(() => { + state.closed = true; + }); + }, + }; +} diff --git a/desktop/src/main/streamBus.ts b/desktop/src/main/streamBus.ts new file mode 100644 index 0000000..3ffdc28 --- /dev/null +++ b/desktop/src/main/streamBus.ts @@ -0,0 +1,16 @@ +import type { BrowserWindow } from "electron"; +import { STREAM_CHANNEL, type StreamEvent } from "../shared/ipc.js"; + +let mainWindow: BrowserWindow | null = null; + +export function bindMainWindow(win: BrowserWindow): void { + mainWindow = win; + win.on("closed", () => { + if (mainWindow === win) mainWindow = null; + }); +} + +export function sendStream(event: StreamEvent): void { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send(STREAM_CHANNEL, event); +} diff --git a/desktop/src/main/window.ts b/desktop/src/main/window.ts new file mode 100644 index 0000000..3cf2317 --- /dev/null +++ b/desktop/src/main/window.ts @@ -0,0 +1,46 @@ +import { app, BrowserWindow, shell } from "electron"; +import { resolve } from "node:path"; +import { getDesktopSettings } from "./appSettings.js"; + +let appQuitting = false; + +export function markAppQuitting(): void { + appQuitting = true; +} + +export function createMainWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 1280, + height: 820, + minWidth: 960, + minHeight: 640, + titleBarStyle: "hiddenInset", + backgroundColor: "#fafafa", + webPreferences: { + preload: resolve(__dirname, "../preload/index.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + + win.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); + + win.on("close", (event) => { + if (process.platform === "darwin" && !appQuitting && getDesktopSettings().hideOnClose) { + event.preventDefault(); + win.hide(); + } + }); + + const devUrl = process.env["ELECTRON_RENDERER_URL"]; + if (devUrl) { + void win.loadURL(devUrl); + } else { + void win.loadFile(resolve(__dirname, "../renderer/index.html")); + } + return win; +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts new file mode 100644 index 0000000..df90eb6 --- /dev/null +++ b/desktop/src/preload/index.ts @@ -0,0 +1,22 @@ +import { contextBridge, ipcRenderer } from "electron"; +import { STREAM_CHANNEL, type IpcChannel, type IpcChannelMap, type StreamEvent } from "../shared/ipc.js"; + +const api = { + invoke: ( + channel: K, + req: IpcChannelMap[K]["req"], + ): Promise => { + return ipcRenderer.invoke(channel, req) as Promise; + }, + on: (handler: (event: StreamEvent) => void): (() => void) => { + const listener = (_: unknown, event: StreamEvent) => handler(event); + ipcRenderer.on(STREAM_CHANNEL, listener); + return () => { + ipcRenderer.removeListener(STREAM_CHANNEL, listener); + }; + }, +}; + +contextBridge.exposeInMainWorld("azoth", api); + +export type AzothApi = typeof api; diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx new file mode 100644 index 0000000..c67f52e --- /dev/null +++ b/desktop/src/renderer/App.tsx @@ -0,0 +1,277 @@ +import { useEffect, useState } from "react"; +import { Sidebar } from "./components/Sidebar/Sidebar.js"; +import { ChatView } from "./components/ChatView/ChatView.js"; +import { EmptyState } from "./components/Empty/EmptyState.js"; +import { PromptComposer } from "./components/Composer/PromptComposer.js"; +import { Onboarding } from "./components/Onboarding/Onboarding.js"; +import { ConsentToast } from "./components/Consent/ConsentToast.js"; +import { SettingsModal } from "./components/Settings/SettingsModal.js"; +import { AgentPanel } from "./components/AgentPanel/AgentPanel.js"; +import { ArrowLeftIcon, ArrowRightIcon, SidebarToggleIcon } from "./components/Icon.js"; +import { MarketView } from "./components/Market/MarketView.js"; +import { TickerDetailWindow } from "./components/Market/TickerDetailWindow.js"; +import { PortfolioView } from "./components/Portfolio/PortfolioView.js"; +import { PositionDetailView } from "./components/Portfolio/PositionDetailView.js"; +import { useStreamBridge } from "./lib/streamBridge.js"; +import { useChatStore } from "./store/chatStore.js"; + +type AppView = "chat" | "markets" | "portfolio" | "position"; +type NavState = { + view: AppView; + settingsOpen: boolean; + tickerSymbol: string | null; + positionSymbol: string | null; +}; + +export function App({ initialTickerSymbol }: { initialTickerSymbol?: string | null } = {}) { + useStreamBridge(); + const [nav, setNav] = useState({ + view: initialTickerSymbol ? "markets" : "chat", + settingsOpen: false, + tickerSymbol: initialTickerSymbol ? initialTickerSymbol.toUpperCase() : null, + positionSymbol: null, + }); + const [backStack, setBackStack] = useState([]); + const [forwardStack, setForwardStack] = useState([]); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const { + activeProjectId, + activeSessionId, + projects, + sessions, + onboarded, + appSettings, + setProjects, + setSessions, + setOnboarded, + setConfig, + setAppSettings, + } = useChatStore(); + + useEffect(() => { + void (async () => { + const [{ projects, activeId }, status, cfg, appSettings] = await Promise.all([ + window.azoth.invoke("project:list", undefined), + window.azoth.invoke("onboarding:status", undefined), + window.azoth.invoke("config:get", undefined), + window.azoth.invoke("app-settings:get", undefined), + ]); + setProjects(projects, activeId); + setOnboarded(status.onboarded); + setConfig(cfg); + setAppSettings(appSettings); + })(); + }, [setProjects, setOnboarded, setConfig, setAppSettings]); + + useEffect(() => { + if (!appSettings) return; + const root = document.documentElement; + const apply = () => { + const theme = appSettings.appearance === "system" + ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" + : appSettings.appearance; + root.dataset.theme = theme; + root.dataset.appearance = appSettings.appearance; + }; + apply(); + const media = window.matchMedia("(prefers-color-scheme: dark)"); + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, [appSettings]); + + useEffect(() => { + if (!activeProjectId) return; + void (async () => { + const sessions = await window.azoth.invoke("session:list", { + projectId: activeProjectId, + }); + setSessions(sessions); + })(); + }, [activeProjectId, setSessions]); + + function normalizeNav(next: NavState): NavState { + return { + view: next.view, + settingsOpen: next.settingsOpen, + tickerSymbol: next.tickerSymbol ? next.tickerSymbol.toUpperCase() : null, + positionSymbol: next.positionSymbol ? next.positionSymbol.toUpperCase() : null, + }; + } + + function sameNav(a: NavState, b: NavState): boolean { + return ( + a.view === b.view && + a.settingsOpen === b.settingsOpen && + a.tickerSymbol === b.tickerSymbol && + a.positionSymbol === b.positionSymbol + ); + } + + function navigateTo(next: NavState): void { + const normalized = normalizeNav(next); + if (sameNav(nav, normalized)) return; + setBackStack((current) => [...current, nav].slice(-50)); + setForwardStack([]); + setNav(normalized); + } + + function goBack(): void { + const previous = backStack.at(-1); + if (!previous) return; + setBackStack((current) => current.slice(0, -1)); + setForwardStack((current) => [nav, ...current].slice(0, 50)); + setNav(previous); + } + + function goForward(): void { + const next = forwardStack[0]; + if (!next) return; + setForwardStack((current) => current.slice(1)); + setBackStack((current) => [...current, nav].slice(-50)); + setNav(next); + } + + if (!onboarded) { + return setOnboarded(true)} />; + } + + const activeSession = sessions.find((s) => s.id === activeSessionId); + const activeProject = projects.find((p) => p.id === activeProjectId); + const { view, settingsOpen, tickerSymbol, positionSymbol } = nav; + const titleBase = + positionSymbol ?? + tickerSymbol ?? + (view === "markets" + ? "Markets" + : view === "portfolio" + ? "My Portfolio" + : view === "position" + ? "Position" + : activeSession?.title ?? "New chat"); + const windowTitle = `${titleBase}${ + activeProject?.name ? ` - ${activeProject.name}` : "" + }`; + const appClassName = [ + "app", + view === "markets" ? "is-markets" : "", + view === "portfolio" ? "is-portfolio" : "", + view === "position" ? "is-portfolio" : "", + sidebarCollapsed ? "is-sidebar-collapsed" : "", + ].filter(Boolean).join(" "); + + return ( +
+
+
+ + + +
+
{windowTitle}
+
+
+ + + +
+ + navigateTo({ view: "chat", settingsOpen: false, tickerSymbol: null, positionSymbol: null }) + } + onOpenMarkets={() => + navigateTo({ view: "markets", settingsOpen: false, tickerSymbol: null, positionSymbol: null }) + } + onOpenPortfolio={() => + navigateTo({ view: "portfolio", settingsOpen: false, tickerSymbol: null, positionSymbol: null }) + } + onOpenSettings={() => navigateTo({ ...nav, settingsOpen: true, tickerSymbol: null, positionSymbol: null })} + /> +
+ {tickerSymbol ? ( + + ) : view === "position" && positionSymbol ? ( + + navigateTo({ + view: "markets", + settingsOpen: false, + tickerSymbol: symbol.toUpperCase(), + positionSymbol: null, + }) + } + onBack={goBack} + /> + ) : view === "markets" ? ( + + navigateTo({ + view: "markets", + settingsOpen: false, + tickerSymbol: symbol.toUpperCase(), + positionSymbol: null, + }) + } + /> + ) : view === "portfolio" ? ( + + navigateTo({ + view: "markets", + settingsOpen: false, + tickerSymbol: symbol.toUpperCase(), + positionSymbol: null, + }) + } + onOpenPosition={(symbol) => + navigateTo({ + view: "position", + settingsOpen: false, + tickerSymbol: null, + positionSymbol: symbol.toUpperCase(), + }) + } + /> + ) : ( + <> + {activeSessionId ? : } + + + )} +
+ {view === "chat" ? : null} + + {settingsOpen && ( + navigateTo({ ...nav, settingsOpen: false })} /> + )} +
+ ); +} diff --git a/desktop/src/renderer/components/AgentPanel/AgentPanel.tsx b/desktop/src/renderer/components/AgentPanel/AgentPanel.tsx new file mode 100644 index 0000000..2245822 --- /dev/null +++ b/desktop/src/renderer/components/AgentPanel/AgentPanel.tsx @@ -0,0 +1,217 @@ +import { useMemo, type ReactNode } from "react"; +import { + AgentIcon, + CheckIcon, + LightningIcon, + SpinnerIcon, + TerminalIcon, + UsersIcon, + XIcon, +} from "../Icon.js"; +import { useChatStore, type TeamRoleView, type TeamRunView } from "../../store/chatStore.js"; + +export function AgentPanel() { + const activeSessionId = useChatStore((s) => s.activeSessionId); + const activeTurnId = useChatStore((s) => + activeSessionId ? s.activeTurnsBySession[activeSessionId] : undefined, + ); + const runs = useChatStore((s) => + activeSessionId ? s.teamRunsBySession[activeSessionId] ?? [] : [], + ); + + const orderedRuns = useMemo( + () => + [...runs].sort((a, b) => { + const activeDelta = Number(b.turnId === activeTurnId) - Number(a.turnId === activeTurnId); + if (activeDelta !== 0) return activeDelta; + const statusDelta = statusRank(b.status) - statusRank(a.status); + if (statusDelta !== 0) return statusDelta; + return b.updatedAt - a.updatedAt; + }), + [activeTurnId, runs], + ); + const activeCount = runs.filter((run) => run.status === "running").length; + const roleTotals = runs.reduce( + (acc, run) => { + acc.total += run.roles.length; + acc.done += run.roles.filter((role) => role.status === "done").length; + acc.running += run.roles.filter((role) => role.status === "running").length; + return acc; + }, + { total: 0, done: 0, running: 0 }, + ); + + return ( + + ); +} + +function AgentRun({ run }: { run: TeamRunView }) { + const done = run.roles.filter((role) => role.status === "done").length; + const total = run.roles.length; + const progress = total > 0 ? Math.round((done / total) * 100) : 0; + + return ( +
+
+ +
+
{runTitle(run)}
+
{runSubtitle(run)}
+
+ {statusLabel(run)} +
+ +
+ +
+ +
+ {run.roles.length > 0 ? ( + run.roles.map((role) => ) + ) : ( +
Starting
+ )} +
+
+ ); +} + +function BannerRow({ + icon, + label, + value, + tone, +}: { + icon: ReactNode; + label: string; + value?: string; + tone?: "active" | "auto"; +}) { + return ( +
+ {icon} + {label} + {value ? {value} : null} +
+ ); +} + +function AgentRole({ role }: { role: TeamRoleView }) { + return ( +
+ +
+ {roleName(role)} + {roleMeta(role)} +
+
+ ); +} + +function StatusIcon({ status }: { status: TeamRoleView["status"] }) { + if (status === "done") { + return ; + } + if (status === "error") { + return ; + } + return ; +} + +function runTitle(run: TeamRunView): string { + if (run.ticker && run.ticker !== "TEAM") return `${run.ticker} analysis`; + return run.tool === "team_analyze" ? "Ticker analysis" : "Team question"; +} + +function runSubtitle(run: TeamRunView): string { + if (run.status === "error") return run.message ?? "Run failed"; + if (run.status === "done") { + const size = run.sizingPct != null ? ` · ${(run.sizingPct * 100).toFixed(1)}%` : ""; + return run.rating ? `${run.rating}${size}` : "Complete"; + } + const activeRole = run.roles.find((role) => role.status === "running"); + return activeRole ? roleName(activeRole) : "Preparing agents"; +} + +function statusLabel(run: TeamRunView): string { + if (run.status === "done") return "Done"; + if (run.status === "error") return "Failed"; + return "Live"; +} + +function statusRank(status: TeamRunView["status"]): number { + if (status === "running") return 2; + if (status === "done") return 1; + return 0; +} + +function roleName(role: TeamRoleView): string { + const name = role.role + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + return role.round == null ? name : `${name} R${role.round}`; +} + +function roleMeta(role: TeamRoleView): string { + if (role.status === "error") return role.detail ? `Failed - ${role.detail}` : "Failed"; + if (role.status === "done") { + if (role.toolCount > 0) return `${role.resultCount}/${role.toolCount} tools`; + return "Complete"; + } + if (role.lastTool) return role.lastTool.replace(/^mcp__[^_]+__/, "").replace(/[_-]+/g, " "); + return "Running"; +} diff --git a/desktop/src/renderer/components/ChatView/Block.tsx b/desktop/src/renderer/components/ChatView/Block.tsx new file mode 100644 index 0000000..775ae38 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/Block.tsx @@ -0,0 +1,87 @@ +import type { ChatRecord } from "../../../shared/ipc.js"; +import { MarkdownContent } from "./MarkdownContent.js"; +import { isTeamToolName, TeamResultCard } from "./TeamResultCard.js"; +import { isLiveChartToolName, MarketChartCard } from "./MarketChartCard.js"; +import { ToolChip } from "./ToolChip.js"; + +export function Block({ record }: { record: ChatRecord }) { + switch (record.type) { + case "user": + return ( +
+
{record.text}
+
+ ); + case "assistant": + return ( +
+
+ +
+
+ ); + case "thinking": + return ( +
+
Reasoning
+ {record.text} +
+ ); + case "tool_use": + if (isLiveChartToolName(record.toolName) && record.text) { + const fallback = ( +
+ +
+ ); + return ; + } + if (isTeamToolName(record.toolName) && record.text) { + const fallback = ( +
+ +
+ ); + return ; + } + return ( +
+ +
+ ); + case "tool_result": + if (isLiveChartToolName(record.toolName) && record.text) { + const fallback = ( +
+ +
+ ); + return ; + } + if (isTeamToolName(record.toolName) && record.text) { + const fallback = ( +
+ +
+ ); + return ; + } + return ( +
+ +
+ ); + case "result": + return null; + case "error": + return ( +
+
{record.text}
+
+ ); + case "session_start": + case "system": + default: + return null; + } +} diff --git a/desktop/src/renderer/components/ChatView/ChatView.tsx b/desktop/src/renderer/components/ChatView/ChatView.tsx new file mode 100644 index 0000000..5cf123f --- /dev/null +++ b/desktop/src/renderer/components/ChatView/ChatView.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import { useChatStore } from "../../store/chatStore.js"; +import { Block } from "./Block.js"; +import { TeamRunCard } from "./TeamRunCard.js"; + +interface Props { + sessionId: string; +} + +export function ChatView({ sessionId }: Props) { + const records = useChatStore((s) => s.recordsBySession[sessionId] ?? []); + const liveRecords = useChatStore((s) => s.liveRecordsBySession[sessionId] ?? []); + const liveTeamRuns = useChatStore((s) => { + const activeTurnId = s.activeTurnsBySession[sessionId]; + return (s.teamRunsBySession[sessionId] ?? []).filter((run) => run.turnId === activeTurnId); + }); + const isStreaming = useChatStore((s) => Boolean(s.activeTurnsBySession[sessionId])); + const scrollRef = useRef(null); + const liveTextSize = liveRecords.reduce( + (sum, record) => sum + (record.text?.length ?? 0) + (record.toolInput?.length ?? 0), + 0, + ); + const teamSize = liveTeamRuns.reduce((sum, run) => sum + run.roles.length + run.updatedAt, 0); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; + if (nearBottom) el.scrollTop = el.scrollHeight; + }, [records.length, liveRecords.length, liveTextSize, liveTeamRuns.length, teamSize]); + + return ( +
+ {records.map((record, idx) => ( + + ))} + {liveRecords.map((record, idx) => ( + + ))} + {liveTeamRuns.map((run) => ( + + ))} + {isStreaming && liveRecords.length === 0 && liveTeamRuns.length === 0 && ( +
+
Reasoning
+ Thinking... +
+ )} +
+ ); +} diff --git a/desktop/src/renderer/components/ChatView/MarkdownContent.tsx b/desktop/src/renderer/components/ChatView/MarkdownContent.tsx new file mode 100644 index 0000000..1177448 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/MarkdownContent.tsx @@ -0,0 +1,227 @@ +import type React from "react"; + +interface Props { + text: string; +} + +type Block = + | { type: "heading"; level: 1 | 2 | 3; text: string } + | { type: "paragraph"; text: string } + | { type: "code"; lang: string; text: string } + | { type: "blockquote"; text: string } + | { type: "list"; ordered: boolean; items: string[] } + | { type: "table"; headers: string[]; rows: string[][] }; + +export function MarkdownContent({ text }: Props) { + const blocks = parseBlocks(text); + return ( + <> + {blocks.map((block, idx) => ( + + ))} + + ); +} + +function MarkdownBlock({ block }: { block: Block }) { + switch (block.type) { + case "heading": { + const children = parseInline(block.text); + if (block.level === 1) return

{children}

; + if (block.level === 2) return

{children}

; + return

{children}

; + } + case "paragraph": + return

{parseInline(block.text)}

; + case "code": + return ( +
+          {block.lang ? (
+            
+ {block.lang} +
+ ) : null} + {block.text} +
+ ); + case "blockquote": + return
{parseInline(block.text)}
; + case "list": { + const Tag = block.ordered ? "ol" : "ul"; + return ( + + {block.items.map((item, idx) => ( +
  • {parseInline(item)}
  • + ))} +
    + ); + } + case "table": + return ( + + + + {block.headers.map((header, idx) => ( + + ))} + + + + {block.rows.map((row, rowIdx) => ( + + {block.headers.map((_, cellIdx) => ( + + ))} + + ))} + +
    {parseInline(header)}
    {parseInline(row[cellIdx] ?? "")}
    + ); + } +} + +function parseBlocks(text: string): Block[] { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const blocks: Block[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i] ?? ""; + if (!line.trim()) { + i++; + continue; + } + + const fence = line.match(/^```([\w-]*)\s*$/); + if (fence) { + const code: string[] = []; + i++; + while (i < lines.length && !/^```\s*$/.test(lines[i] ?? "")) { + code.push(lines[i] ?? ""); + i++; + } + if (i < lines.length) i++; + blocks.push({ type: "code", lang: fence[1] ?? "", text: code.join("\n") }); + continue; + } + + const heading = line.match(/^(#{1,3})\s+(.+)$/); + if (heading) { + blocks.push({ + type: "heading", + level: heading[1]!.length as 1 | 2 | 3, + text: heading[2]!, + }); + i++; + continue; + } + + if (isTableStart(lines, i)) { + const headers = splitTableRow(lines[i]!); + i += 2; + const rows: string[][] = []; + while (i < lines.length && /^\s*\|.+\|\s*$/.test(lines[i] ?? "")) { + rows.push(splitTableRow(lines[i]!)); + i++; + } + blocks.push({ type: "table", headers, rows }); + continue; + } + + if (/^>\s?/.test(line)) { + const quote: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i] ?? "")) { + quote.push((lines[i] ?? "").replace(/^>\s?/, "")); + i++; + } + blocks.push({ type: "blockquote", text: quote.join(" ") }); + continue; + } + + const ordered = /^\d+\.\s+/.test(line); + const unordered = /^[-*]\s+/.test(line); + if (ordered || unordered) { + const items: string[] = []; + const pattern = ordered ? /^\d+\.\s+/ : /^[-*]\s+/; + while (i < lines.length && pattern.test(lines[i] ?? "")) { + items.push((lines[i] ?? "").replace(pattern, "")); + i++; + } + blocks.push({ type: "list", ordered, items }); + continue; + } + + const paragraph: string[] = []; + while ( + i < lines.length && + lines[i]?.trim() && + !/^```/.test(lines[i] ?? "") && + !/^(#{1,3})\s+/.test(lines[i] ?? "") && + !/^>\s?/.test(lines[i] ?? "") && + !/^\d+\.\s+/.test(lines[i] ?? "") && + !/^[-*]\s+/.test(lines[i] ?? "") && + !isTableStart(lines, i) + ) { + paragraph.push(lines[i] ?? ""); + i++; + } + blocks.push({ type: "paragraph", text: paragraph.join(" ") }); + } + + return blocks; +} + +function isTableStart(lines: string[], index: number): boolean { + const header = lines[index] ?? ""; + const separator = lines[index + 1] ?? ""; + return ( + /^\s*\|.+\|\s*$/.test(header) && + /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(separator) + ); +} + +function splitTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); +} + +function parseInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text)) != null) { + if (match.index > lastIndex) nodes.push(text.slice(lastIndex, match.index)); + const token = match[0]; + const key = nodes.length; + + if (token.startsWith("`")) { + nodes.push({token.slice(1, -1)}); + } else if (token.startsWith("**")) { + nodes.push({parseInline(token.slice(2, -2))}); + } else if (token.startsWith("*")) { + nodes.push({parseInline(token.slice(1, -1))}); + } else { + const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + const href = link?.[2] ?? ""; + nodes.push( + + {parseInline(link?.[1] ?? token)} + , + ); + } + lastIndex = match.index + token.length; + } + + if (lastIndex < text.length) nodes.push(text.slice(lastIndex)); + return nodes; +} + +function safeHref(href: string): string { + return /^(https?:|mailto:)/i.test(href) ? href : "#"; +} diff --git a/desktop/src/renderer/components/ChatView/MarketChartCard.tsx b/desktop/src/renderer/components/ChatView/MarketChartCard.tsx new file mode 100644 index 0000000..e06e876 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/MarketChartCard.tsx @@ -0,0 +1,361 @@ +import { useMemo, useState, type PointerEvent, type ReactNode } from "react"; +import type { ChatRecord } from "../../../shared/ipc.js"; + +interface ChartBar { + t: number; + o: number; + h: number; + l: number; + c: number; + v: number; +} + +interface LiveChartPayload { + ok?: boolean; + tool?: string; + symbol?: string; + kind?: "stock" | "index"; + resolution?: string; + count?: number; + updatedAt?: number; + unit?: string; + summary?: { + latestClose?: number; + latestTime?: number; + changePct?: number | null; + high?: number; + low?: number; + volume?: number; + dataAgeSeconds?: number; + } | null; + bars?: ChartBar[]; + error?: string; +} + +interface Props { + record: ChatRecord; + fallback: ReactNode; +} + +export function isLiveChartToolName(name: string | undefined): boolean { + const normalized = name?.replace(/^mcp__[^_]+__/, ""); + return normalized === "live_chart"; +} + +export function MarketChartCard({ record, fallback }: Props) { + const payload = parseChartPayload(record); + if (!payload || payload.ok === false || !payload.bars?.length) return <>{fallback}; + + const bars = payload.bars; + const latest = payload.summary?.latestClose ?? bars[bars.length - 1]?.c; + const changePct = payload.summary?.changePct; + const direction = (changePct ?? 0) >= 0 ? "up" : "down"; + const title = `${payload.symbol ?? "Market"} chart`; + const subtitle = [ + resolutionLabel(payload.resolution), + `${bars.length} candles`, + payload.summary?.latestTime ? `Last bar ${formatTime(payload.summary.latestTime)}` : null, + payload.updatedAt ? `Fetched ${formatTime(payload.updatedAt)}` : null, + ].filter(Boolean).join(" - "); + + return ( +
    +
    +
    +
    +
    {title}
    +
    {subtitle}
    +
    +
    + {formatPrice(latest)} + {changePct != null ? {formatPct(changePct)} : null} +
    +
    + +
    + + + + +
    +
    +
    + ); +} + +function CandlestickSvg({ bars }: { bars: ChartBar[] }) { + const [hoverIndex, setHoverIndex] = useState(null); + const width = 720; + const height = 300; + const pad = { top: 14, right: 54, bottom: 24, left: 8 }; + const chartHeight = 210; + const volumeTop = pad.top + chartHeight + 16; + const volumeHeight = 36; + const plotWidth = width - pad.left - pad.right; + const highs = bars.map((bar) => bar.h); + const lows = bars.map((bar) => bar.l); + const volumes = bars.map((bar) => bar.v); + const min = Math.min(...lows); + const max = Math.max(...highs); + const range = Math.max(0.001, max - min); + const priceMin = min - range * 0.08; + const priceMax = max + range * 0.08; + const priceRange = priceMax - priceMin; + const maxVolume = Math.max(1, ...volumes); + const step = plotWidth / Math.max(1, bars.length); + const bodyWidth = Math.max(2, Math.min(8, step * 0.58)); + const grid = [0, 0.25, 0.5, 0.75, 1]; + const hoverBar = hoverIndex == null ? null : bars[hoverIndex] ?? null; + + const xAt = (idx: number) => pad.left + step * idx + step / 2; + const yAt = (price: number) => pad.top + ((priceMax - price) / priceRange) * chartHeight; + const handlePointerMove = (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const relativeX = ((event.clientX - rect.left) / rect.width) * width; + const idx = Math.round((relativeX - pad.left - step / 2) / step); + setHoverIndex(Math.max(0, Math.min(bars.length - 1, idx))); + }; + + return ( + setHoverIndex(null)} + > + {grid.map((ratio) => { + const y = pad.top + ratio * chartHeight; + const price = priceMax - ratio * priceRange; + return ( + + + + {formatPrice(price)} + + + ); + })} + {bars.map((bar, idx) => { + const x = xAt(idx); + const openY = yAt(bar.o); + const closeY = yAt(bar.c); + const highY = yAt(bar.h); + const lowY = yAt(bar.l); + const top = Math.min(openY, closeY); + const bodyHeight = Math.max(1.5, Math.abs(closeY - openY)); + const isUp = bar.c >= bar.o; + const volumeHeightPx = Math.max(1, (bar.v / maxVolume) * volumeHeight); + return ( + + + + + + ); + })} + {hoverBar && hoverIndex != null ? ( + + ) : null} + + + ); +} + +function ChartHoverLayer({ + bar, + x, + y, + width, + chartTop, + chartBottom, + volumeBottom, +}: { + bar: ChartBar; + x: number; + y: number; + width: number; + chartTop: number; + chartBottom: number; + volumeBottom: number; +}) { + const tooltipWidth = 184; + const tooltipHeight = 82; + const tooltipX = x > width - tooltipWidth - 72 ? x - tooltipWidth - 12 : x + 12; + const tooltipY = Math.max(8, Math.min(chartBottom - tooltipHeight + 10, y - tooltipHeight / 2)); + const rows = useMemo( + () => [ + ["O", formatPrice(bar.o), "H", formatPrice(bar.h)], + ["L", formatPrice(bar.l), "C", formatPrice(bar.c)], + ["V", formatVolume(bar.v), "", ""], + ], + [bar], + ); + + return ( + + + + + + + {formatDate(bar.t)} + {rows.map((row, idx) => ( + + {row[0]} + {row[1]} + {row[2] ? {row[2]} : null} + {row[3] ? {row[3]} : null} + + ))} + + + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
    + {label} + {value} +
    + ); +} + +function parseChartPayload(record: ChatRecord): LiveChartPayload | null { + const text = record.text; + const input = parseToolInput(record.toolInput); + if (!text) return null; + try { + const parsed = JSON.parse(text) as LiveChartPayload; + if (parsed?.tool !== "live_chart" && !parsed?.bars?.length) return null; + return { + ...parsed, + symbol: parsed.symbol ?? input.symbol, + kind: parsed.kind ?? input.kind, + resolution: parsed.resolution ?? input.resolution, + bars: parsed.bars, + }; + } catch { + return salvagePartialChartPayload(text, input); + } +} + +function parseToolInput(input: string | undefined): Partial { + if (!input) return {}; + try { + const parsed = JSON.parse(input) as Partial; + return { + symbol: typeof parsed.symbol === "string" ? parsed.symbol : undefined, + kind: parsed.kind, + resolution: typeof parsed.resolution === "string" ? parsed.resolution : undefined, + }; + } catch { + return {}; + } +} + +function salvagePartialChartPayload( + text: string, + input: Partial, +): LiveChartPayload | null { + if (!text.includes('"tool":"live_chart"')) return null; + const bars = [...text.matchAll(/\{"t":(\d+),"o":(-?\d+(?:\.\d+)?),"h":(-?\d+(?:\.\d+)?),"l":(-?\d+(?:\.\d+)?),"c":(-?\d+(?:\.\d+)?),"v":(\d+)\}/g)] + .map((match) => ({ + t: Number(match[1]), + o: Number(match[2]), + h: Number(match[3]), + l: Number(match[4]), + c: Number(match[5]), + v: Number(match[6]), + })) + .filter((bar) => Number.isFinite(bar.t) && Number.isFinite(bar.c)); + if (!bars.length) return null; + const latest = bars[bars.length - 1]!; + return { + ok: true, + tool: "live_chart", + symbol: input.symbol, + kind: input.kind, + resolution: input.resolution, + count: bars.length, + summary: { + latestClose: latest.c, + latestTime: latest.t, + changePct: pctFromBars(bars), + high: Math.max(...bars.map((bar) => bar.h)), + low: Math.min(...bars.map((bar) => bar.l)), + volume: bars.reduce((sum, bar) => sum + bar.v, 0), + }, + bars, + }; +} + +function pctFromBars(bars: ChartBar[]): number | null { + const first = bars[0]?.c; + const latest = bars[bars.length - 1]?.c; + if (first == null || latest == null || first === 0) return null; + return ((latest - first) / first) * 100; +} + +function resolutionLabel(resolution: string | undefined): string { + if (!resolution) return "Live"; + if (resolution === "1") return "1 minute"; + if (["5", "15", "30"].includes(resolution)) return `${resolution} minute`; + if (resolution === "1H") return "1 hour"; + if (resolution.endsWith("D")) return `${resolution.replace("D", "")} day`; + if (resolution.endsWith("W")) return `${resolution.replace("W", "")} week`; + if (resolution.endsWith("M")) return `${resolution.replace("M", "")} month`; + return `${resolution} candles`; +} + +function formatTime(seconds: number): string { + return new Date(seconds * 1000).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatDate(seconds: number): string { + return new Date(seconds * 1000).toLocaleString([], { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatPrice(value: number | undefined): string { + if (value == null || !Number.isFinite(value)) return "-"; + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} + +function formatPct(value: number): string { + const prefix = value > 0 ? "+" : ""; + return `${prefix}${value.toFixed(2)}%`; +} + +function formatVolume(value: number | undefined): string { + if (value == null || !Number.isFinite(value)) return "-"; + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(value); +} diff --git a/desktop/src/renderer/components/ChatView/TeamResultCard.tsx b/desktop/src/renderer/components/ChatView/TeamResultCard.tsx new file mode 100644 index 0000000..f2fd7c0 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/TeamResultCard.tsx @@ -0,0 +1,179 @@ +import type { ReactNode } from "react"; +import type { ChatRecord } from "../../../shared/ipc.js"; +import { MarkdownContent } from "./MarkdownContent.js"; + +interface Props { + record: ChatRecord; + fallback: ReactNode; +} + +interface TeamResult { + ok?: boolean; + type?: string; + runId?: string; + asOfDateIso?: string; + decision?: Record; + researchPlan?: Record; + trader?: Record; + risk?: Record; + analysts?: Array>; +} + +export function isTeamToolName(name: string | undefined): boolean { + return Boolean(name && /(^|__)team_(analyze|question)$/.test(name)); +} + +export function TeamResultCard({ record, fallback }: Props) { + const result = parseTeamResult(record.text); + if (!result) return <>{fallback}; + + const isAnalyze = result.type === "team_analyze" || record.toolName?.includes("team_analyze"); + const decision = result.decision ?? {}; + const title = isAnalyze ? "Team Analyze" : "Team Question"; + const rating = textValue(decision.rating) || textValue(decision.recommendation); + const ticker = textValue(decision.ticker); + const size = numberValue(decision.sizingPct); + const summary = textValue(decision.rationale) || textValue(decision.answer); + const runLabel = result.runId ? result.runId.slice(0, 8) : undefined; + + return ( +
    +
    +
    + +
    +
    {title}
    +
    + {[ticker, result.asOfDateIso, runLabel].filter(Boolean).join(" - ")} +
    +
    + {rating ? {rating} : null} +
    + +
    + {ticker ? : null} + {rating ? : null} + {size != null ? : null} + {typeof result.risk?.approved === "boolean" ? ( + + ) : null} +
    + + {summary ? ( +
    + +
    + ) : null} + + {isAnalyze ? : } +
    +
    + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
    + {label} + {value} +
    + ); +} + +function AnalyzeDetails({ result }: { result: TeamResult }) { + const analystRows = result.analysts ?? []; + const concerns = arrayText(result.risk?.concerns); + return ( +
    + Subagents +
    + {analystRows.map((analyst, idx) => ( +
    + {textValue(analyst.role) || `Analyst ${idx + 1}`} +

    {textValue(analyst.summary)}

    +
    + ))} + {result.researchPlan?.rationale ? ( +
    + Research manager +

    {textValue(result.researchPlan.rationale)}

    +
    + ) : null} + {result.trader?.rationale ? ( +
    + Trader +

    {textValue(result.trader.rationale)}

    +
    + ) : null} + {concerns.length > 0 ? ( +
    + Risk +

    {concerns.join("; ")}

    +
    + ) : null} +
    +
    + ); +} + +function QuestionDetails({ decision }: { decision: Record }) { + const reasons = arrayText(decision.keyReasons); + const risks = arrayText(decision.risks); + const nextActions = arrayText(decision.nextActions); + if (reasons.length + risks.length + nextActions.length === 0) return null; + return ( +
    + Subagents +
    + {reasons.length > 0 ? : null} + {risks.length > 0 ? : null} + {nextActions.length > 0 ? : null} +
    +
    + ); +} + +function DetailList({ title, values }: { title: string; values: string[] }) { + return ( +
    + {title} +

    {values.join("; ")}

    +
    + ); +} + +function parseTeamResult(text: string | undefined): TeamResult | null { + if (!text) return null; + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const result = parsed as TeamResult; + if (result.ok === false) return null; + if (result.type !== "team_analyze" && result.type !== "team_question") return null; + return result; + } catch { + return null; + } +} + +function textValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function numberValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function arrayText(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0); +} + +function formatPercent(value: number): string { + const pct = value <= 1 ? value * 100 : value; + return `${pct.toFixed(pct >= 10 ? 0 : 1)}%`; +} diff --git a/desktop/src/renderer/components/ChatView/TeamRunCard.tsx b/desktop/src/renderer/components/ChatView/TeamRunCard.tsx new file mode 100644 index 0000000..22db851 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/TeamRunCard.tsx @@ -0,0 +1,76 @@ +import type { TeamRoleView, TeamRunView } from "../../store/chatStore.js"; + +export function TeamRunCard({ run }: { run: TeamRunView }) { + const subtitle = run.ticker && run.ticker !== "TEAM" + ? `${run.ticker}${run.runId ? ` - ${run.runId.slice(0, 8)}` : ""}` + : run.runId + ? run.runId.slice(0, 8) + : "Coordinating subagents"; + + return ( +
    +
    +
    + +
    +
    {run.title}
    +
    {subtitle}
    +
    + {teamStatusLabel(run)} +
    + + {run.roles.length > 0 ? ( +
    + {run.roles.map((role) => ( + + ))} +
    + ) : ( +
    Starting team
    + )} +
    +
    + ); +} + +function TeamRoleRow({ role }: { role: TeamRoleView }) { + return ( +
    +
    + ); +} + +function teamStatusLabel(run: TeamRunView): string { + if (run.status === "error") return "Failed"; + if (run.status === "done") return run.rating ? `Done - ${run.rating}` : "Done"; + return "Running"; +} + +function roleName(role: TeamRoleView): string { + const name = role.role + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + return role.round == null ? name : `${name} R${role.round}`; +} + +function roleMeta(role: TeamRoleView): string { + if (role.status === "error") return role.detail ? `Failed - ${role.detail}` : "Failed"; + if (role.status === "done") { + if (role.toolCount > 0) return `Done - ${role.resultCount}/${role.toolCount} tools`; + return "Done"; + } + if (role.lastTool) { + const tool = role.lastTool.replace(/^mcp__[^_]+__/, "").replace(/[_-]+/g, " "); + if (role.detail) return `${tool} - ${role.detail}`; + return `${tool} running`; + } + return "Running"; +} diff --git a/desktop/src/renderer/components/ChatView/ToolChip.tsx b/desktop/src/renderer/components/ChatView/ToolChip.tsx new file mode 100644 index 0000000..72dfc49 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/ToolChip.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import type { ChatRecord } from "../../../shared/ipc.js"; +import { AlertIcon, CheckIcon, ChevronDownIcon, ListIcon } from "../Icon.js"; +import { summarizeToolInput, toolLabel } from "../../lib/toolSummary.js"; + +const MAX_BODY_LINES = 8; +const MAX_ARRAY_ITEMS = 8; +const MAX_OBJECT_ENTRIES = 6; +const MAX_VALUE_CHARS = 420; +const MAX_LINE_CHARS = 720; + +interface Props { + record: ChatRecord; + state: "running" | "done"; +} + +export function ToolChip({ record, state }: Props) { + const [open, setOpen] = useState(false); + const isResult = record.type === "tool_result"; + const label = toolLabel(record.toolName); + const summary = summarizeToolInput(record.toolName, record.toolInput); + const resultBody = record.text ?? ""; + const body = resultBody || (isResult ? record.text ?? "" : record.toolInput ?? ""); + const isError = isToolError(body); + const className = [ + "tool-call", + state === "running" ? "is-running" : "", + isError ? "is-error" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
    setOpen(e.currentTarget.open)}> + + + {summaryText({ isResult, isError, state, label, summary })} + {body ? : null} + + {open && body && ( +
    + {bodyLines({ body, label, isError }).map((line, idx) => ( +
    + {line.tool ? {line.tool} : null} + {line.tool ? " · " : null} + {line.text} +
    + ))} +
    + )} +
    + ); +} + +function summaryText({ + isResult, + isError, + state, + label, + summary, +}: { + isResult: boolean; + isError: boolean; + state: "running" | "done"; + label: string; + summary: string; +}): string { + if (isError) return `${humanizeTool(label)} failed`; + if (state === "running") return humanizeTool(label); + if (isResult) return humanizeTool(label); + return summary ? `${humanizeTool(label)} ${summary}` : humanizeTool(label); +} + +function humanizeTool(label: string): string { + return label + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function isToolError(body: string): boolean { + const trimmed = body.trim(); + if (!trimmed) return false; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const obj = parsed as Record; + if (obj.ok === false) return true; + if (typeof obj.error === "string" && obj.error.trim()) return true; + if (typeof obj.message === "string" && isErrorText(obj.message)) return true; + const status = Number(obj.status ?? obj.statusCode ?? obj.code); + if (Number.isFinite(status) && status >= 400) return true; + } + return false; + } catch { + return isErrorText(trimmed); + } +} + +function isErrorText(text: string): boolean { + return ( + /\b(error|failed|exception|unauthorized|forbidden|invalid)\b/i.test(text) || + /\bno\s+json\s+object\s+found\b/i.test(text) || + /\bHTTP\s*(4\d\d|5\d\d)\b/i.test(text) || + /\b(status|statusCode|code)\s*[:=]\s*(4\d\d|5\d\d)\b/i.test(text) + ); +} + +function bodyLines({ + body, + label, + isError, +}: { + body: string; + label: string; + isError: boolean; +}): Array<{ tool?: string; text: string; error?: boolean }> { + const trimmed = body.trim(); + if (!trimmed) return []; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return Object.entries(parsed as Record) + .slice(0, MAX_BODY_LINES) + .map(([key, value]) => ({ + tool: label, + text: truncateText(`${key}=${formatValue(value)}`, MAX_LINE_CHARS), + error: isError, + })); + } + if (Array.isArray(parsed)) { + return parsed.slice(0, MAX_BODY_LINES).map((item, idx) => ({ + tool: label, + text: truncateText(`${idx + 1}. ${formatValue(item)}`, MAX_LINE_CHARS), + error: isError, + })); + } + } catch { + // Fall through to plain text lines. + } + } + return trimmed + .split(/\n+/) + .slice(0, MAX_BODY_LINES) + .map((line) => ({ tool: label, text: truncateText(line.trim(), MAX_LINE_CHARS), error: isError })); +} + +function formatValue(value: unknown, depth = 0): string { + if (typeof value === "string") return truncateText(value, MAX_VALUE_CHARS); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (value == null) return "null"; + if (depth >= 2) return truncateText(JSON.stringify(value), MAX_VALUE_CHARS); + if (Array.isArray(value)) { + const visible = value.slice(0, MAX_ARRAY_ITEMS).map((item) => formatValue(item, depth + 1)); + const suffix = value.length > MAX_ARRAY_ITEMS ? `, ... +${value.length - MAX_ARRAY_ITEMS}` : ""; + return `[${visible.join(", ")}${suffix}]`; + } + const entries = Object.entries(value as Record); + const visible = entries + .slice(0, MAX_OBJECT_ENTRIES) + .map(([key, entryValue]) => `${key}=${formatValue(entryValue, depth + 1)}`); + const suffix = entries.length > MAX_OBJECT_ENTRIES ? ` · ... +${entries.length - MAX_OBJECT_ENTRIES}` : ""; + return `${visible.join(" · ")}${suffix}`; +} + +function truncateText(text: string, max: number): string { + if (text.length <= max) return text; + const visibleLength = Math.max(0, max - 24); + return `${text.slice(0, visibleLength).trimEnd()} ... (${text.length - visibleLength} more chars)`; +} + +function ToolIcon({ state }: { state: "running" | "done" | "error" }) { + if (state === "error") { + return ; + } + if (state === "running") { + return ; + } + return ; +} + +function ChevronIcon() { + return ; +} diff --git a/desktop/src/renderer/components/Composer/AutonomyPicker.tsx b/desktop/src/renderer/components/Composer/AutonomyPicker.tsx new file mode 100644 index 0000000..720bb2b --- /dev/null +++ b/desktop/src/renderer/components/Composer/AutonomyPicker.tsx @@ -0,0 +1,46 @@ +import { ChevronDownIcon, HandIcon, LightningIcon } from "../Icon.js"; +import { useChatStore } from "../../store/chatStore.js"; + +const MODES = ["manual", "auto"] as const; +const LABELS: Record<(typeof MODES)[number], string> = { + manual: "Manual", + auto: "Auto", +}; + +export function AutonomyPicker() { + const config = useChatStore((s) => s.config) as { autonomy?: string } | null; + const setConfig = useChatStore((s) => s.setConfig); + const mode = MODES.includes(config?.autonomy as (typeof MODES)[number]) + ? config?.autonomy as (typeof MODES)[number] + : "manual"; + + async function update(autonomy: string) { + const next = await window.azoth.invoke("config:save", { patch: { autonomy } }); + setConfig(next); + } + + return ( + + ); +} + +function ModeIcon({ mode }: { mode: (typeof MODES)[number] }) { + if (mode === "auto") { + return ; + } + return ; +} diff --git a/desktop/src/renderer/components/Composer/BrokerPicker.tsx b/desktop/src/renderer/components/Composer/BrokerPicker.tsx new file mode 100644 index 0000000..56c39f5 --- /dev/null +++ b/desktop/src/renderer/components/Composer/BrokerPicker.tsx @@ -0,0 +1,28 @@ +import { useChatStore } from "../../store/chatStore.js"; + +const BROKERS = ["paper", "dnse", "fhsc"] as const; + +export function BrokerPicker() { + const config = useChatStore((s) => s.config) as { broker?: string } | null; + const setConfig = useChatStore((s) => s.setConfig); + + async function update(broker: string) { + const next = await window.azoth.invoke("config:save", { patch: { broker } }); + setConfig(next); + } + + return ( + + ); +} diff --git a/desktop/src/renderer/components/Composer/ModelPicker.tsx b/desktop/src/renderer/components/Composer/ModelPicker.tsx new file mode 100644 index 0000000..2a99638 --- /dev/null +++ b/desktop/src/renderer/components/Composer/ModelPicker.tsx @@ -0,0 +1,55 @@ +import { useEffect } from "react"; +import { ChevronDownIcon } from "../Icon.js"; +import { availableModelOrDefault, normalizeProvider } from "../../lib/providerModels.js"; +import { useProviderModels } from "../../lib/useProviderModels.js"; +import { useChatStore } from "../../store/chatStore.js"; + +export function ModelPicker() { + const config = useChatStore((s) => s.config) as { + model?: string; + llm?: { provider?: string; api_key?: string; base_url?: string }; + } | null; + const setConfig = useChatStore((s) => s.setConfig); + const provider = normalizeProvider(config?.llm?.provider); + const modelList = useProviderModels({ + provider, + apiKey: config?.llm?.api_key, + baseUrl: config?.llm?.base_url, + }); + const selectedModel = availableModelOrDefault(modelList.models, config?.model); + + async function update(model: string) { + if (!model) return; + const next = await window.azoth.invoke("config:save", { patch: { model } }); + setConfig(next); + } + + useEffect(() => { + if (modelList.loading || modelList.error || modelList.models.length === 0) return; + if (!config?.model || config.model === selectedModel) return; + void update(selectedModel); + }, [config?.model, modelList.error, modelList.loading, modelList.models.length, selectedModel]); + + const disabled = modelList.loading || Boolean(modelList.error) || modelList.models.length === 0; + + return ( + + ); +} diff --git a/desktop/src/renderer/components/Composer/PromptComposer.tsx b/desktop/src/renderer/components/Composer/PromptComposer.tsx new file mode 100644 index 0000000..0c51a4b --- /dev/null +++ b/desktop/src/renderer/components/Composer/PromptComposer.tsx @@ -0,0 +1,336 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { matchSlash } from "../../../shared/slashCommands.js"; +import { useChatStore } from "../../store/chatStore.js"; +import { SlashSuggest } from "./SlashSuggest.js"; +import { ModelPicker } from "./ModelPicker.js"; +import { AutonomyPicker } from "./AutonomyPicker.js"; +import { MicIcon, PlusIcon, SendIcon, StopIcon } from "../Icon.js"; + +function newTurnId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function parseSlash(input: string): { name: string; args: string } | null { + if (!input.startsWith("/")) return null; + const [rawName = "", ...rest] = input.slice(1).trim().split(/\s+/); + const name = rawName.toLowerCase(); + if (!name) return null; + return { name, args: rest.join(" ").trim() }; +} + +function promptForSlash(name: string, args: string): string | null { + switch (name) { + case "team": + return args ? `Run agent-team orchestration for this request: ${args}` : null; + case "backtest": + return `Run an interval backtest with these arguments: ${args || "default previous calendar week"}.`; + case "quote": + return args + ? `Give me a market quote with technicals and recent news for ${args.toUpperCase()}.` + : null; + case "positions": + return "Summarize my current portfolio positions, unrealized PnL, and exposures."; + default: + return null; + } +} + +export function PromptComposer() { + const [value, setValue] = useState(""); + const [suggestIdx, setSuggestIdx] = useState(0); + const [error, setError] = useState(null); + const taRef = useRef(null); + + const { + activeProjectId, + activeSessionId, + activeTurnsBySession, + setActiveSession, + setRecords, + setSessions, + appendRecord, + startStreaming, + stopStreaming, + setConfig, + } = useChatStore(); + const activeTurnId = activeSessionId ? activeTurnsBySession[activeSessionId] : undefined; + const streaming = activeTurnId != null; + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (typeof detail === "string") { + setValue(detail); + taRef.current?.focus(); + } + }; + window.addEventListener("azoth:prefill", handler); + return () => window.removeEventListener("azoth:prefill", handler); + }, []); + + useEffect(() => { + const el = taRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }, [value]); + + const refreshSessions = useCallback(async () => { + if (!activeProjectId) return; + const list = await window.azoth.invoke("session:list", { projectId: activeProjectId }); + setSessions(list); + }, [activeProjectId, setSessions]); + + const ensureSession = useCallback(async (title: string) => { + if (!activeProjectId) throw new Error("No active project selected."); + let sessionId = activeSessionId; + if (sessionId) return sessionId; + const entry = await window.azoth.invoke("session:start", { + projectId: activeProjectId, + title: title.slice(0, 80) || "Untitled session", + }); + sessionId = entry.id; + setActiveSession(sessionId); + setRecords(sessionId, []); + await refreshSessions(); + return sessionId; + }, [activeProjectId, activeSessionId, refreshSessions, setActiveSession, setRecords]); + + const sendPrompt = useCallback(async (prompt: string, displayPrompt = prompt) => { + if (!activeProjectId || streaming) return; + setError(null); + let sessionId: string | null = null; + try { + sessionId = await ensureSession(displayPrompt); + // Optimistic user record so the message appears immediately. + appendRecord(sessionId, { + type: "user", + timestamp: Date.now(), + sessionId, + text: displayPrompt, + }); + + const turnId = newTurnId(); + startStreaming(sessionId, turnId); + setValue(""); + await window.azoth.invoke("turn:send", { + projectId: activeProjectId, + sessionId, + prompt, + displayPrompt: displayPrompt === prompt ? undefined : displayPrompt, + turnId, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (sessionId) stopStreaming(sessionId); + if (sessionId) { + appendRecord(sessionId, { + type: "error", + timestamp: Date.now(), + sessionId, + text: message, + }); + } else { + setError(message); + } + } + }, [ + activeProjectId, + streaming, + ensureSession, + appendRecord, + startStreaming, + stopStreaming, + ]); + + const runLocalSlash = useCallback(async (name: string, args: string, userText: string) => { + if (!activeProjectId || streaming) return; + setError(null); + let sessionId: string | null = null; + try { + sessionId = await ensureSession(userText); + appendRecord(sessionId, { + type: "user", + timestamp: Date.now(), + sessionId, + text: userText, + }); + setValue(""); + const res = await window.azoth.invoke("slash:run", { + projectId: activeProjectId, + sessionId, + name, + args, + }); + appendRecord(sessionId, { + type: "assistant", + timestamp: Date.now(), + sessionId, + text: res.text ?? "", + }); + if (name === "autonomy") { + const cfg = await window.azoth.invoke("config:get", undefined); + setConfig(cfg); + } + await refreshSessions(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (sessionId) { + appendRecord(sessionId, { + type: "error", + timestamp: Date.now(), + sessionId, + text: message, + }); + } else { + setError(message); + } + } + }, [ + activeProjectId, + streaming, + ensureSession, + appendRecord, + refreshSessions, + setConfig, + ]); + + const startNewChat = useCallback(async () => { + if (!activeProjectId || streaming) return; + setError(null); + try { + const entry = await window.azoth.invoke("session:start", { + projectId: activeProjectId, + title: "Untitled session", + }); + setActiveSession(entry.id); + setRecords(entry.id, []); + setValue(""); + await refreshSessions(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [activeProjectId, streaming, refreshSessions, setActiveSession, setRecords]); + + const send = useCallback(async () => { + const text = value.trim(); + if (!text || !activeProjectId || streaming) return; + + const slash = parseSlash(text); + if (slash) { + if (slash.name === "new") { + await startNewChat(); + return; + } + const prompt = promptForSlash(slash.name, slash.args); + if (prompt) { + await sendPrompt(prompt, text); + return; + } + if (["help", "sessions", "health", "about", "autonomy"].includes(slash.name)) { + await runLocalSlash(slash.name, slash.args, text); + return; + } + await runLocalSlash(slash.name, slash.args, text || `/${slash.name}`); + return; + } + + await sendPrompt(text); + }, [ + value, + activeProjectId, + streaming, + sendPrompt, + runLocalSlash, + startNewChat, + ]); + + async function abort() { + if (!activeTurnId) return; + const sessionId = activeSessionId; + const res = await window.azoth.invoke("turn:abort", { turnId: activeTurnId }); + if (res.ok && sessionId) stopStreaming(sessionId); + } + + const suggestions = matchSlash(value); + + function handleKey(e: React.KeyboardEvent) { + if (suggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSuggestIdx((i) => i + 1); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSuggestIdx((i) => i - 1); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + const sel = + ((suggestIdx % suggestions.length) + suggestions.length) % suggestions.length; + const cmd = suggestions[sel]!; + setValue(`/${cmd.name}${cmd.args ? " " : ""}`); + return; + } + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void send(); + } + } + + return ( +
    +
    + setValue(`/${c.name}${c.args ? " " : ""}`)} + /> + {error && ( +
    + {error} +
    + )} +