diff --git a/.dockerignore b/.dockerignore index f0165244..5c94b0ec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ __pycache__ .DS_Store node_modules .venv +pytorch_connectomics diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..32169baf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +permissions: + contents: read + +"on": + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" + + - name: Set up uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.7.3" + enable-cache: true + + - name: Fetch pinned PyTorch Connectomics runtime + run: bash scripts/setup_pytorch_connectomics.sh + + - name: Install Python dependencies + run: uv sync --frozen --python 3.11 --group dev + + - name: Run backend tests + run: uv run pytest -q + + frontend: + name: Frontend tests and build + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: client + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: client/package-lock.json + + - name: Install frontend dependencies + run: npm ci --fetch-retries=5 --fetch-retry-maxtimeout=120000 + + - name: Run frontend tests + run: npm test -- --runInBand + env: + CI: "true" + + - name: Build frontend + run: npm run build diff --git a/.github/workflows/superlinter.yml b/.github/workflows/superlinter.yml index 05823f6e..e91ddcf5 100644 --- a/.github/workflows/superlinter.yml +++ b/.github/workflows/superlinter.yml @@ -1,6 +1,10 @@ --- name: Super-Linter +permissions: + contents: read + statuses: write + "on": push: branches: @@ -15,14 +19,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false - name: Run Super-Linter - uses: github/super-linter@v4 + uses: super-linter/super-linter@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0 env: DEFAULT_BRANCH: main GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VALIDATE_ALL_CODEBASE: false + ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false VALIDATE_CSS_PRETTIER: true VALIDATE_HTML_PRETTIER: true VALIDATE_JAVASCRIPT_PRETTIER: true diff --git a/.gitignore b/.gitignore index 20267ae2..0d858bfe 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,10 @@ sql_app.db uploads pytorch_connectomics server_api/chatbot/faiss_index/ -.logs/ \ No newline at end of file +.logs/ + +# Local deployment evidence (may contain transient viewer URLs) +demo-proofread-3d.png +docs/case-studies/yixiao-postdeploy-smoke-report-*.json +docs/case-studies/yixiao-postdeploy-smoke-report-*.md +docs/case-studies/yixiao-synthetic-walkthrough-report-*.md diff --git a/Dockerfile b/Dockerfile index 08ebe97b..94713534 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,10 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh -s -- --install-dir /usr/loc WORKDIR /app COPY pyproject.toml uv.lock ./ -RUN uv sync --frozen --no-dev --python 3.11 - COPY scripts/setup_pytorch_connectomics.sh ./scripts/setup_pytorch_connectomics.sh RUN chmod +x scripts/setup_pytorch_connectomics.sh && \ - ./scripts/setup_pytorch_connectomics.sh --force && \ - uv pip install --directory /app --editable /app/pytorch_connectomics && \ + ./scripts/setup_pytorch_connectomics.sh && \ + uv sync --frozen --no-dev --python 3.11 && \ rm -rf /app/pytorch_connectomics/.git COPY server_api ./server_api diff --git a/README.md b/README.md index c4e6747f..1fdc2da7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ scripts\bootstrap.ps1 # Windows Re-run the relevant bootstrap script when you need to update dependencies. +The bootstrap scripts fetch the legacy-compatible PyTorch Connectomics runtime +from `pytc-client-legacy-runtime` and verify commit +`04c2a35e78a1a7ca1138f83a98fc3ef27097abd4` before installation. The generated +`pytorch_connectomics/` checkout is intentionally not tracked by this repository. + ## Run the app ``` @@ -35,6 +40,10 @@ Optional runtime environment variables: PYTC_AUTH_SECRET=replace-me PYTC_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,null PYTC_NEUROGLANCER_PUBLIC_BASE=http://localhost:4244 +OLLAMA_MODEL=qwen3.6:27b +PYTC_WORKFLOW_INTENT_MODEL=qwen3.6:27b +OLLAMA_EMBED_MODEL=qwen3-embedding:8b +PYTC_UNLOAD_OLLAMA_BEFORE_TRAINING=0 # set to 1 only on memory-constrained GPUs ``` ## Chatbot Docs Index @@ -54,6 +63,11 @@ You can override the embeddings endpoint if needed: OLLAMA_BASE_URL=http://cscigpu08.bc.edu:4443 uv run python server_api/chatbot/update_faiss.py ``` +The local assistant and workflow intent classifier default to `qwen3.6:27b` with +`qwen3-embedding:8b`, matching the bundled FAISS index settings. For +experimental local model upgrades, see +`docs/research/on-device-llm-options-2026-04-26.md`. + If restarting after a crash or interrupted session, kill any lingering processes first: ``` diff --git a/app_event_logger.py b/app_event_logger.py new file mode 100644 index 00000000..3cdc5dfd --- /dev/null +++ b/app_event_logger.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import json +import logging +import os +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_ROOT_DIR = Path(__file__).resolve().parent +_DEFAULT_LOG_PATH = _ROOT_DIR / ".logs" / "app" / "app-events.jsonl" +_ORIGINAL_STDOUT = sys.stdout +_ORIGINAL_STDERR = sys.stderr +_WRITE_LOCK = threading.Lock() +_CONFIG_LOCK = threading.Lock() +_CONFIGURED_COMPONENTS: set[str] = set() +_STDIO_REDIRECTED = False + + +def get_app_event_log_path() -> Path: + raw_path = os.getenv("PYTC_APP_EVENT_LOG_PATH", "").strip() + if raw_path: + return Path(raw_path).expanduser().resolve(strict=False) + return _DEFAULT_LOG_PATH + + +def _detect_stream_level(default_level: str, message: str) -> str: + text = (message or "").strip() + upper = text.upper() + if upper.startswith("INFO:") or " INFO:" in upper: + return "INFO" + if ( + upper.startswith("WARNING:") + or upper.startswith("WARN:") + or " WARNING:" in upper + ): + return "WARNING" + if upper.startswith("DEBUG:") or " DEBUG:" in upper: + return "DEBUG" + if upper.startswith("ERROR:") or " ERROR:" in upper: + return "ERROR" + if "TRACEBACK" in upper or "EXCEPTION" in upper or "FAILED" in upper: + return "ERROR" + return default_level.upper() + + +def _normalize_value(value: Any) -> Any: + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, dict): + return {str(key): _normalize_value(val) for key, val in value.items()} + if isinstance(value, (list, tuple, set)): + return [_normalize_value(item) for item in value] + return str(value) + + +def append_app_event( + *, + component: str, + event: str, + level: str = "INFO", + message: str | None = None, + **fields: Any, +) -> dict[str, Any]: + log_path = get_app_event_log_path() + log_path.parent.mkdir(parents=True, exist_ok=True) + + record = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "pid": os.getpid(), + "component": component, + "event": event, + "level": level.upper(), + "message": message or "", + } + record.update( + { + key: _normalize_value(value) + for key, value in fields.items() + if value is not None + } + ) + + with _WRITE_LOCK: + with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=True) + "\n") + + return record + + +class _AppEventStream: + def __init__( + self, + *, + component: str, + stream_name: str, + level: str, + original_stream, + ) -> None: + self.component = component + self.stream_name = stream_name + self.level = level + self.original_stream = original_stream + self._buffer = "" + + def write(self, data) -> int: + text = str(data) + if not text: + return 0 + + self.original_stream.write(text) + self._buffer += text + + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + if line.strip(): + append_app_event( + component=self.component, + event=f"{self.stream_name}_line", + level=_detect_stream_level(self.level, line), + message=line, + stream=self.stream_name, + ) + + return len(text) + + def flush(self) -> None: + self.original_stream.flush() + if self._buffer.strip(): + append_app_event( + component=self.component, + event=f"{self.stream_name}_line", + level=_detect_stream_level(self.level, self._buffer), + message=self._buffer, + stream=self.stream_name, + ) + self._buffer = "" + + def isatty(self) -> bool: + return bool(getattr(self.original_stream, "isatty", lambda: False)()) + + def fileno(self) -> int: + return self.original_stream.fileno() + + +def configure_process_logging(component: str) -> Path: + global _STDIO_REDIRECTED + + with _CONFIG_LOCK: + if component in _CONFIGURED_COMPONENTS: + return get_app_event_log_path() + + if not _STDIO_REDIRECTED: + sys.stdout = _AppEventStream( + component=component, + stream_name="stdout", + level="INFO", + original_stream=_ORIGINAL_STDOUT, + ) + sys.stderr = _AppEventStream( + component=component, + stream_name="stderr", + level="ERROR", + original_stream=_ORIGINAL_STDERR, + ) + _STDIO_REDIRECTED = True + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + if not root_logger.handlers: + logging.basicConfig( + level=logging.INFO, + stream=sys.stdout, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + else: + for handler in root_logger.handlers: + if not isinstance(handler, logging.StreamHandler): + continue + stream = getattr(handler, "stream", None) + if stream is _ORIGINAL_STDOUT: + handler.setStream(sys.stdout) + elif stream is _ORIGINAL_STDERR: + handler.setStream(sys.stderr) + + append_app_event( + component=component, + event="process_logging_configured", + level="INFO", + message=f"{component} logging configured", + log_path=str(get_app_event_log_path()), + configured_at_ms=round(time.time() * 1000), + ) + _CONFIGURED_COMPONENTS.add(component) + return get_app_event_log_path() diff --git a/client/main.js b/client/main.js index d80fb2ea..1881f411 100644 --- a/client/main.js +++ b/client/main.js @@ -1,23 +1,56 @@ const path = require("path"); const url = require("url"); +const fs = require("fs"); const { app, BrowserWindow, ipcMain, dialog, Menu, + nativeImage, screen, shell, } = require("electron"); let mainWindow; +const APP_NAME = "PyTC Client"; + +app.setName(APP_NAME); +app.setAppUserModelId("bio.seg.pytc-client"); +process.title = APP_NAME; + +function getAppIconPath() { + const candidates = [ + path.join(__dirname, "public", "pytc-app-icon.png"), + path.join(__dirname, "build", "pytc-app-icon.png"), + path.join(__dirname, "public", "favicon.ico"), + path.join(__dirname, "build", "favicon.ico"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)); +} + +function loadAppIcon() { + const iconPath = getAppIconPath(); + if (!iconPath) return undefined; + + const icon = nativeImage.createFromPath(iconPath); + if (icon.isEmpty()) return undefined; + + if (process.platform === "darwin" && app.dock) { + app.dock.setIcon(icon); + } + + return icon; +} function createWindow() { const { width, height } = screen.getPrimaryDisplay().workAreaSize; + const appIcon = loadAppIcon(); mainWindow = new BrowserWindow({ + title: APP_NAME, width, height, - icon: path.join(__dirname, "public", "favicon.ico"), + icon: appIcon, webPreferences: { preload: path.join(__dirname, "preload.js"), nodeIntegration: false, @@ -42,6 +75,7 @@ function createWindow() { }); } mainWindow.loadURL(startUrl); + mainWindow.setTitle(APP_NAME); mainWindow.on("closed", () => { mainWindow = null; @@ -51,9 +85,11 @@ function createWindow() { } function createMenu() { + app.setName(APP_NAME); + app.setAboutPanelOptions({ applicationName: APP_NAME }); const template = [ { - label: "Electron", + label: APP_NAME, submenu: [{ role: "toggleDevTools" }, { role: "quit" }], }, { role: "editMenu" }, diff --git a/client/package-lock.json b/client/package-lock.json index bda968ff..868d7b99 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,11 +1,11 @@ { - "name": "PyTC Client", + "name": "pytc-client", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "PyTC Client", + "name": "pytc-client", "version": "0.1.0", "dependencies": { "@testing-library/jest-dom": "^6.4.6", @@ -26,6 +26,7 @@ "react-router-dom": "^6.23.1", "react-scripts": "^5.0.1", "remark-gfm": "^4.0.1", + "three": "^0.164.1", "utif": "^3.1.0", "web-vitals": "^4.1.1" }, @@ -34,7 +35,7 @@ "babel-loader": "8.2.2", "cross-env": "^7.0.3", "electron-reload": "^2.0.0-alpha.1", - "prettier": "^3.3.2" + "prettier": "3.8.4" } }, "node_modules/@adobe/css-tools": { @@ -15401,11 +15402,10 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -18763,10 +18763,9 @@ } }, "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "optional": true, "peer": true, "bin": { @@ -18946,6 +18945,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.164.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.164.1.tgz", + "integrity": "sha512-iC/hUBbl1vzFny7f5GtqzVXYjMJKaTPxiCxXfrvVdBi1Sf+jhd1CAkitiFwC7mIBFCo3MrDLJG97yisoaWig0w==", + "license": "MIT" + }, "node_modules/throat": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", diff --git a/client/package.json b/client/package.json index 0107480c..3af2d657 100644 --- a/client/package.json +++ b/client/package.json @@ -1,5 +1,6 @@ { - "name": "PyTC Client", + "name": "pytc-client", + "productName": "PyTC Client", "version": "0.1.0", "private": true, "main": "main.js", @@ -23,6 +24,7 @@ "react-router-dom": "^6.23.1", "react-scripts": "^5.0.1", "remark-gfm": "^4.0.1", + "three": "^0.164.1", "utif": "^3.1.0", "web-vitals": "^4.1.1" }, @@ -55,7 +57,7 @@ "babel-loader": "8.2.2", "cross-env": "^7.0.3", "electron-reload": "^2.0.0-alpha.1", - "prettier": "^3.3.2" + "prettier": "3.8.4" }, "overrides": { "nth-check": "$nth-check", diff --git a/client/public/favicon.ico b/client/public/favicon.ico index ab831f31..b509cdba 100644 Binary files a/client/public/favicon.ico and b/client/public/favicon.ico differ diff --git a/client/public/pytc-app-icon.icns b/client/public/pytc-app-icon.icns new file mode 100644 index 00000000..57e15acc Binary files /dev/null and b/client/public/pytc-app-icon.icns differ diff --git a/client/public/pytc-app-icon.png b/client/public/pytc-app-icon.png new file mode 100644 index 00000000..8d2c4358 Binary files /dev/null and b/client/public/pytc-app-icon.png differ diff --git a/client/src/App.css b/client/src/App.css index 49191aa5..336d8ef9 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -2,6 +2,196 @@ text-align: center; } +:root { + --seg-bg-canvas: #f7f4ed; + --seg-bg-panel: #fffdfa; + --seg-bg-raised: #ffffff; + --seg-border-subtle: #e4ded2; + --seg-border-strong: #c9bda9; + --seg-text-primary: #1f2933; + --seg-text-muted: #667085; + --seg-accent-primary: #3f37c9; + --seg-accent-primary-soft: #f0efff; + --seg-accent-primary-border: #c7c2ff; + --seg-focus-ring: rgba(63, 55, 201, 0.22); + --seg-selection-fill: rgba(63, 55, 201, 0.12); + --seg-accent-teal: var(--seg-accent-primary); + --seg-accent-blue: #2b5b9f; + --seg-accent-amber: #b8660f; + --seg-accent-green: #2f7d32; + --seg-accent-red: #b13a2f; + --seg-radius-panel: 12px; + --seg-shadow-panel: 0 12px 32px rgba(51, 43, 31, 0.08); +} + +.workflow-stage-header { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + padding: 2px 0 8px; + border: 0; + border-bottom: 1px solid var(--seg-border-subtle); + background: transparent; +} + +.workflow-stage-title, +.workflow-stage-copy { + min-width: 0; +} + +.workflow-stage-copy { + display: flex; + align-items: center; + min-width: 0; +} + +.workflow-stage-title { + display: inline-block; + padding-right: 8px; +} + +.workflow-stage-eyebrow, +.workflow-stage-description { + font-size: 12px; +} + +.workflow-insight-card, +.workflow-proposal-card { + border: 1px solid var(--seg-border-subtle); + border-radius: 10px; + background: var(--seg-bg-raised); +} + +.workflow-insight-card { + margin-top: 8px; + padding: 8px 10px; +} + +.workflow-proposal-card { + margin-top: 6px; + padding: 10px; +} + +.workflow-proposal-card__header { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.workflow-proposal-card__title { + flex: 1 1 100%; + overflow-wrap: anywhere; + min-width: 0; +} + +.workflow-proposal-card__rationale, +.workflow-proposal-card__field-value { + overflow-wrap: anywhere; +} + +.workflow-proposal-card__type-tag, +.workflow-proposal-card__status-tag { + max-width: 100%; + overflow-wrap: anywhere; + white-space: normal; +} + +.workflow-proposal-card__agent-badge { + flex: 0 0 auto; + max-width: 100%; + min-width: 0; +} + +.workflow-proposal-card__field-value--editable { + cursor: pointer; +} + +.workflow-proposal-card__fields { + display: grid; + grid-template-columns: minmax(92px, auto) 1fr; + column-gap: 10px; + row-gap: 4px; + overflow-wrap: anywhere; + word-break: break-word; +} + +.workflow-proposal-card__fields .workflow-proposal-card__field-label { + overflow-wrap: anywhere; +} + +.workflow-proposal-card__trace { + border-top: 1px solid var(--seg-border-subtle); + margin-top: 4px; + padding-top: 4px; +} + +.workflow-proposal-card__trace-body { + margin-top: 4px; +} + +.workflow-proposal-card__trace-block { + margin-bottom: 4px; +} + +.workflow-proposal-card__trace-block-title { + display: block; + font-size: 11px; +} + +.workflow-proposal-card__trace-block-body { + margin-top: 2px; +} + +.workflow-proposal-card__trace-line { + display: block; + font-size: 11px; + line-height: 1.35; +} + +.assistant-trace__panel { + border-top: 1px solid var(--seg-border-subtle); + display: grid; + gap: 8px; + margin-top: 4px; + padding-top: 6px; +} + +.assistant-trace__section { + display: grid; + gap: 4px; +} + +.assistant-trace__items { + display: grid; + gap: 4px; +} + +.assistant-trace__item { + border: 1px solid var(--seg-border-subtle); + border-radius: 8px; + display: grid; + gap: 4px; + padding: 6px 8px; + background: var(--seg-bg-raised); +} + +.workflow-proposal-card__field-label { + font-size: 11px; + text-transform: capitalize; +} + +.pytc-top-menu .ant-menu-item { + margin-inline: 4px !important; + padding-inline: 16px 24px !important; +} + +.pytc-top-menu .ant-menu-title-content { + padding-right: 2px; +} + .app-logo { height: 40vmin; pointer-events: none; @@ -18,6 +208,19 @@ color: white; } +.visualization-viewer-tabs > .ant-tabs-nav { + margin: 8px 0 0; +} + +.visualization-viewer-tabs > .ant-tabs-content-holder, +.visualization-viewer-tabs > .ant-tabs-content-holder > .ant-tabs-content, +.visualization-viewer-tabs + > .ant-tabs-content-holder + > .ant-tabs-content + > .ant-tabs-tabpane { + height: 100%; +} + @media (prefers-reduced-motion: no-preference) { .app-logo { animation: app-logo-spin infinite 20s linear; diff --git a/client/src/App.js b/client/src/App.js index 9c26a2c7..00771fb5 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,36 +1,9 @@ -import { useContext, useEffect, useState } from "react"; import "./App.css"; import Views from "./views/Views"; -import { AppContext, ContextWrapper } from "./contexts/GlobalContext"; +import { ContextWrapper } from "./contexts/GlobalContext"; import { YamlContextWrapper } from "./contexts/YamlContext"; import { WorkflowProvider } from "./contexts/WorkflowContext"; -function CacheBootstrapper({ children }) { - const { resetFileState } = useContext(AppContext); - const [isCacheCleared, setIsCacheCleared] = useState(false); - - useEffect(() => { - let isMounted = true; - const clearCache = async () => { - await resetFileState(); - if (isMounted) { - setIsCacheCleared(true); - } - }; - - clearCache(); - return () => { - isMounted = false; - }; - }, [resetFileState]); - - if (!isCacheCleared) { - return null; - } - - return children; -} - function MainContent() { return ; } @@ -40,11 +13,9 @@ function App() { - -
- -
-
+
+ +
diff --git a/client/src/__tests__/agentProposalCards.test.js b/client/src/__tests__/agentProposalCards.test.js index afd415ea..ec287e3c 100644 --- a/client/src/__tests__/agentProposalCards.test.js +++ b/client/src/__tests__/agentProposalCards.test.js @@ -10,7 +10,7 @@ jest.mock("antd", () => ({ ), Space: ({ children }) =>
{children}
, - Tag: ({ children }) => {children}, + Tag: ({ children, ...props }) => {children}, Typography: { Text: ({ children }) => {children}, }, @@ -43,7 +43,7 @@ describe("AgentProposalCard", () => { fireEvent.click(screen.getByRole("button", { name: "Approve" })); fireEvent.click(screen.getByRole("button", { name: "Reject" })); - expect(onApprove).toHaveBeenCalledWith(proposal); + expect(onApprove).toHaveBeenCalledWith(proposal, {}); expect(onReject).toHaveBeenCalledWith(proposal); }); @@ -67,7 +67,11 @@ describe("AgentProposalCard", () => { it("renders fallback proposal content", () => { render( , ); @@ -75,4 +79,24 @@ describe("AgentProposalCard", () => { expect(screen.getByText("Keep behavior stable")).toBeTruthy(); expect(screen.getByText("bar")).toBeTruthy(); }); + + it("shows specialist agent badges when supplied", () => { + render( + , + ); + + expect(screen.getByText("INFER")).toBeTruthy(); + expect(screen.getByText("agent_task")).toBeTruthy(); + }); }); diff --git a/client/src/__tests__/assistantActionCard.test.js b/client/src/__tests__/assistantActionCard.test.js new file mode 100644 index 00000000..c26d19e6 --- /dev/null +++ b/client/src/__tests__/assistantActionCard.test.js @@ -0,0 +1,42 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import AssistantActionCard from "../components/chat/AssistantActionCard"; + +jest.mock("antd", () => ({ + Button: ({ children, ...props }) => ( + + ), + Space: ({ children }) =>
{children}
, + Tag: ({ children }) => {children}, + Typography: { + Text: ({ children }) => {children}, + }, +})); + +describe("AssistantActionCard", () => { + it("renders the action metadata and triggers execution", () => { + const onRun = jest.fn(); + const action = { + id: "open-training", + label: "Open Training", + description: "Jump to training with staged labels.", + variant: "primary", + client_effects: { + navigate_to: "training", + }, + }; + + render(); + + expect(screen.getByText("Open Training")).toBeTruthy(); + expect( + screen.getByText("Jump to training with staged labels."), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Run in app" })); + expect(onRun).toHaveBeenCalledWith(action); + }); +}); diff --git a/client/src/__tests__/assistantCommandCard.test.js b/client/src/__tests__/assistantCommandCard.test.js new file mode 100644 index 00000000..03f1593f --- /dev/null +++ b/client/src/__tests__/assistantCommandCard.test.js @@ -0,0 +1,45 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import AssistantCommandCard from "../components/chat/AssistantCommandCard"; + +jest.mock("antd", () => ({ + Button: ({ children, ...props }) => ( + + ), + Space: ({ children }) =>
{children}
, + Tag: ({ children }) => {children}, + Typography: { + Text: ({ children }) => {children}, + }, +})); + +describe("AssistantCommandCard", () => { + it("renders a terminal-style command block and runs it", () => { + const onRun = jest.fn(); + const command = { + id: "prime-training", + title: "Prime the training screen", + description: "Move the UI into training setup mode.", + command: + 'app open training\napp training labels set "/tmp/corrected.tif"', + run_label: "Execute", + client_effects: { + navigate_to: "training", + set_training_label_path: "/tmp/corrected.tif", + }, + }; + + render(); + + expect(screen.getByText("Prime the training screen")).toBeTruthy(); + expect(screen.queryByText(/app open training/)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Route" })); + expect(screen.getByText(/app open training/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Execute" })); + expect(onRun).toHaveBeenCalledWith(command); + }); +}); diff --git a/client/src/api.js b/client/src/api.js index 344e3319..f3010b37 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -1,13 +1,117 @@ import axios from "axios"; -import { message } from "antd"; import yaml from "js-yaml"; +import { logClientEvent } from "./logging/appEventLog"; +import { + detectConfigDiagnostics, + summarizeConfigText, +} from "./logging/configLogSummary"; import { setInferenceExecutionDefaults, setInferenceOutputPath, setTrainingOutputPath, } from "./configSchema"; -const BASE_URL = `${process.env.REACT_APP_SERVER_PROTOCOL || "http"}://${process.env.REACT_APP_SERVER_URL || "localhost:4242"}`; +const API_LEGACY_PREFIXES = ["/api/workflows", "/api/files", "/api/app"]; + +const removeTrailingSlash = (value) => value.replace(/\/+$/, ""); + +const getDefaultBaseUrl = () => { + if ( + typeof window !== "undefined" && + window.location?.origin && + !window.location.hostname.match(/^(localhost|127\.0\.0\.1)$/) + ) { + return `${window.location.origin}/api`; + } + + return `${process.env.REACT_APP_SERVER_PROTOCOL || "http"}://${process.env.REACT_APP_SERVER_URL || "localhost:4242"}`; +}; + +const BASE_URL = removeTrailingSlash( + process.env.REACT_APP_API_BASE_URL || getDefaultBaseUrl(), +); + +const getBasePath = (baseUrl) => { + if (!baseUrl) return ""; + const protocolMatch = baseUrl.match(/^[a-z][a-z\d+\-.]*:\/\//i); + if (protocolMatch && protocolMatch.index === 0) { + try { + return new URL(baseUrl).pathname || ""; + } catch {} + } + + const originEnd = baseUrl.indexOf("/"); + return originEnd === -1 ? "" : baseUrl.slice(originEnd); +}; + +const BASE_PATH = getBasePath(BASE_URL); +const getLegacyApiBasePrefix = (basePath) => { + const candidates = [...API_LEGACY_PREFIXES, "/api"]; + return ( + candidates.find( + (prefix) => + basePath === prefix || + basePath.startsWith(`${prefix}/`) || + basePath.startsWith(`${prefix}?`) || + basePath.startsWith(`${prefix}#`), + ) || null + ); +}; + +const BASE_LEGACY_API_PREFIX = + getLegacyApiBasePrefix(BASE_PATH) || getLegacyApiBasePrefix(BASE_URL); + +const shouldStripLegacyApiPrefix = (path) => { + const normalizedPath = path.startsWith("/") ? path : `/${String(path || "")}`; + + if (!BASE_LEGACY_API_PREFIX) return false; + if (BASE_LEGACY_API_PREFIX === "/api") { + return ( + normalizedPath === "/api" || + normalizedPath.startsWith("/api/") || + normalizedPath.startsWith("/api?") || + normalizedPath.startsWith("/api#") + ); + } + + return ( + normalizedPath === BASE_LEGACY_API_PREFIX || + normalizedPath.startsWith(`${BASE_LEGACY_API_PREFIX}/`) || + normalizedPath.startsWith(`${BASE_LEGACY_API_PREFIX}?`) || + normalizedPath.startsWith(`${BASE_LEGACY_API_PREFIX}#`) + ); +}; + +const canonicalizeApiPath = (path) => { + const normalized = String(path || ""); + const match = normalized.match(/^([^?#]*)([?#].*)?$/); + const rawPath = match?.[1] || ""; + const suffix = match?.[2] || ""; + const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; + + if (!shouldStripLegacyApiPrefix(normalizedPath)) { + return `${normalizedPath}${suffix}`; + } + + const stripLength = BASE_LEGACY_API_PREFIX?.length || 0; + const deduped = + normalizedPath.length > stripLength + ? normalizedPath.slice(stripLength) + : "/"; + return `${deduped}${suffix}`; +}; + +export const buildApiUrl = (path) => `${BASE_URL}${canonicalizeApiPath(path)}`; + +const DEBUG_API_LOGS = + process.env.REACT_APP_DEBUG_API_LOGS === "1" || + process.env.REACT_APP_DEBUG_API_LOGS === "true"; + +const apiDebugLog = (...args) => { + if (DEBUG_API_LOGS) { + console.log(...args); + } +}; // Create axios instance without auth headers—app runs as guest by default. export const apiClient = axios.create({ @@ -15,6 +119,106 @@ export const apiClient = axios.create({ withCredentials: true, }); +const summarizePayload = (payload) => { + if (!payload) return { hasBody: false }; + if (typeof payload === "string") { + return { hasBody: true, bodyLength: payload.length }; + } + if (payload instanceof FormData) { + return { hasBody: true, bodyType: "FormData" }; + } + if (typeof payload === "object") { + return { + hasBody: true, + bodyType: Array.isArray(payload) ? "array" : "object", + keys: Object.keys(payload).slice(0, 20), + }; + } + return { hasBody: true, bodyType: typeof payload }; +}; + +const attachApiLogging = (instance, source) => { + instance.interceptors.request.use( + (config) => { + config.metadata = { + startedAt: + typeof performance !== "undefined" ? performance.now() : Date.now(), + }; + logClientEvent("api_request", { + level: "INFO", + message: `${String(config.method || "get").toUpperCase()} ${config.url}`, + source, + data: { + method: String(config.method || "get").toUpperCase(), + url: config.url, + baseURL: config.baseURL, + ...summarizePayload(config.data), + }, + }); + return config; + }, + (error) => { + logClientEvent("api_request_setup_failed", { + level: "ERROR", + message: error.message || "Axios request setup failed", + source, + data: { error: error.message }, + }); + return Promise.reject(error); + }, + ); + + instance.interceptors.response.use( + (response) => { + const startedAt = response.config?.metadata?.startedAt; + const endedAt = + typeof performance !== "undefined" ? performance.now() : Date.now(); + logClientEvent("api_response", { + level: "INFO", + message: `${String(response.config?.method || "get").toUpperCase()} ${response.config?.url} -> ${response.status}`, + source, + data: { + method: String(response.config?.method || "get").toUpperCase(), + url: response.config?.url, + status: response.status, + latencyMs: + startedAt !== undefined + ? Number((endedAt - startedAt).toFixed(2)) + : null, + }, + }); + return response; + }, + (error) => { + const config = error.config || {}; + const startedAt = config.metadata?.startedAt; + const endedAt = + typeof performance !== "undefined" ? performance.now() : Date.now(); + logClientEvent("api_response_error", { + level: "ERROR", + message: + error.message || + `${String(config.method || "get").toUpperCase()} ${config.url} failed`, + source, + data: { + method: String(config.method || "get").toUpperCase(), + url: config.url, + status: error.response?.status, + latencyMs: + startedAt !== undefined + ? Number((endedAt - startedAt).toFixed(2)) + : null, + detail: error.response?.data?.detail || null, + }, + }); + return Promise.reject(error); + }, + ); +}; + +attachApiLogging(apiClient, "apiClient"); +attachApiLogging(axios, "axios"); + const buildFilePath = (file) => { if (!file) return ""; if (typeof file === "string") return file; @@ -35,6 +239,9 @@ const getErrorDetailMessage = (detail) => { return detail.map(getErrorDetailMessage).filter(Boolean).join("; "); } if (typeof detail === "object") { + if (detail.user_message) { + return getErrorDetailMessage(detail.user_message); + } const nestedUpstream = detail.upstream_body !== undefined ? getErrorDetailMessage(detail.upstream_body) @@ -52,9 +259,14 @@ const getErrorDetailMessage = (detail) => { return String(detail); }; -export async function getNeuroglancerViewer(image, label, scales, workflowId = null) { +export async function getNeuroglancerViewer( + image, + label, + scales, + workflowId = null, +) { try { - const url = `${BASE_URL}/neuroglancer`; + const url = buildApiUrl("/neuroglancer"); if (hasBrowserFile(image)) { const formData = new FormData(); formData.append( @@ -86,15 +298,86 @@ export async function getNeuroglancerViewer(image, label, scales, workflowId = n const res = await axios.post(url, data); return res.data; } catch (error) { - message.error( - 'Invalid Data Path(s). Be sure to include all "/" and that data path is correct.', + handleError(error); + } +} + +export async function getNeuroglancerProofreadingViewer({ + image, + label, + scales, + workflowId = null, + sessionId = null, + activeInstanceId = null, + initialVoxel = null, +} = {}) { + try { + const url = buildApiUrl("/neuroglancer/proofread"); + const res = await axios.post( + url, + JSON.stringify({ + image: buildFilePath(image), + label: buildFilePath(label), + scales, + workflow_id: workflowId, + session_id: sessionId, + active_instance_id: activeInstanceId, + initial_voxel: initialVoxel, + }), ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getInstanceVolumePreview( + sessionId, + instanceId, + maxPoints = 30000, +) { + try { + const res = await apiClient.get( + canonicalizeApiPath("/eh/detection/instance-volume-preview"), + { + params: { + session_id: sessionId, + instance_id: instanceId, + max_points: maxPoints, + }, + }, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getInstanceMeshPreview( + sessionId, + instanceId, + maxFaces = 60000, +) { + try { + const res = await apiClient.get( + canonicalizeApiPath("/eh/detection/instance-mesh-preview"), + { + params: { + session_id: sessionId, + instance_id: instanceId, + max_faces: maxFaces, + }, + }, + ); + return res.data; + } catch (error) { + handleError(error); } } export async function checkFile(file) { try { - const url = `${BASE_URL}/check_files`; + const url = buildApiUrl("/check_files"); const data = JSON.stringify({ folderPath: file.folderPath || "", name: file.name, @@ -120,7 +403,7 @@ function handleError(error) { export async function makeApiRequest(url, method, data = null) { try { - const fullUrl = `${BASE_URL}/${url}`; + const fullUrl = buildApiUrl(url); const config = { method, url: fullUrl, @@ -146,11 +429,14 @@ export async function startModelTraining( outputPath, configOriginPath = "", workflowId = null, + autoParameters = false, + inputImagePath = "", + inputLabelPath = "", ) { try { - console.log("[API] ===== Starting Training Configuration ====="); - console.log("[API] logPath:", logPath); - console.log("[API] outputPath:", outputPath); + apiDebugLog("[API] ===== Starting Training Configuration ====="); + apiDebugLog("[API] logPath:", logPath); + apiDebugLog("[API] outputPath:", outputPath); // Parse the YAML config and inject the outputPath let configToSend = trainingConfig; @@ -161,8 +447,8 @@ export async function startModelTraining( setTrainingOutputPath(configObj, outputPath); configToSend = yaml.dump(configObj); - console.log("[API] Injected training output path:", outputPath); - console.log( + apiDebugLog("[API] Injected training output path:", outputPath); + apiDebugLog( "[API] Modified config preview:", configToSend.substring(0, 500), ); @@ -171,6 +457,18 @@ export async function startModelTraining( "[API] Failed to parse/modify YAML, using original config:", e, ); + logClientEvent("training_config_transform_failed", { + level: "WARNING", + message: "Failed to transform training config before request", + source: "api", + data: { + error: e.message || "unknown error", + configOriginPath, + outputPath, + logPath, + workflowId, + }, + }); } } else { console.warn( @@ -184,11 +482,35 @@ export async function startModelTraining( trainingConfig: configToSend, configOriginPath, workflow_id: workflowId, + autoParameters: Boolean(autoParameters), + auto_parameters: Boolean(autoParameters), + inputImagePath, + inputLabelPath, }); - console.log("[API] Request payload size:", data.length, "bytes"); - console.log("[API] Note: TensorBoard will monitor outputPath, not logPath"); - console.log("[API] ========================================="); + apiDebugLog("[API] Request payload size:", data.length, "bytes"); + apiDebugLog("[API] Note: TensorBoard will monitor outputPath, not logPath"); + apiDebugLog("[API] ========================================="); + + const configSummary = summarizeConfigText(configToSend, "training"); + const diagnostics = detectConfigDiagnostics(configSummary); + logClientEvent("training_api_payload_prepared", { + level: diagnostics.length ? "WARNING" : "INFO", + message: "Training API payload prepared", + source: "api", + data: { + workflowId, + configOriginPath, + outputPath, + logPath, + inputImagePath, + inputLabelPath, + autoParameters: Boolean(autoParameters), + requestBytes: data.length, + configSummary, + diagnostics, + }, + }); return makeApiRequest("start_model_training", "post", data); } catch (error) { @@ -198,7 +520,7 @@ export async function startModelTraining( export async function stopModelTraining() { try { - await axios.post(`${BASE_URL}/stop_model_training`); + await axios.post(buildApiUrl("/stop_model_training")); } catch (error) { handleError(error); } @@ -206,7 +528,7 @@ export async function stopModelTraining() { export async function getTrainingStatus() { try { - const res = await axios.get(`${BASE_URL}/training_status`); + const res = await axios.get(buildApiUrl("/training_status")); return res.data; } catch (error) { handleError(error); @@ -215,7 +537,7 @@ export async function getTrainingStatus() { export async function getTrainingLogs() { try { - const res = await axios.get(`${BASE_URL}/training_logs`); + const res = await axios.get(buildApiUrl("/training_logs")); return res.data; } catch (error) { handleError(error); @@ -226,6 +548,10 @@ export async function getTensorboardURL() { return makeApiRequest("get_tensorboard_url", "get"); } +export async function getTensorboardStatus() { + return makeApiRequest("get_tensorboard_status", "get"); +} + export async function startTensorboard(logPath) { const query = logPath ? `?${new URLSearchParams({ logPath }).toString()}` @@ -239,43 +565,44 @@ export async function startModelInference( checkpointPath, configOriginPath = "", workflowId = null, + inputImagePath = "", ) { - console.log("\n========== API.JS: START_MODEL_INFERENCE CALLED =========="); - console.log("[API] Function arguments:"); - console.log("[API] - inferenceConfig type:", typeof inferenceConfig); - console.log( + apiDebugLog("\n========== API.JS: START_MODEL_INFERENCE CALLED =========="); + apiDebugLog("[API] Function arguments:"); + apiDebugLog("[API] - inferenceConfig type:", typeof inferenceConfig); + apiDebugLog( "[API] - inferenceConfig length:", inferenceConfig?.length || "N/A", ); - console.log("[API] - outputPath:", outputPath); - console.log("[API] - outputPath type:", typeof outputPath); - console.log("[API] - checkpointPath:", checkpointPath); - console.log("[API] - checkpointPath type:", typeof checkpointPath); + apiDebugLog("[API] - outputPath:", outputPath); + apiDebugLog("[API] - outputPath type:", typeof outputPath); + apiDebugLog("[API] - checkpointPath:", checkpointPath); + apiDebugLog("[API] - checkpointPath type:", typeof checkpointPath); try { - console.log("[API] ===== Starting Inference Configuration ====="); + apiDebugLog("[API] ===== Starting Inference Configuration ====="); // Parse the YAML config and inject the outputPath let configToSend = inferenceConfig; if (inferenceConfig) { try { - console.log("[API] Parsing YAML config..."); + apiDebugLog("[API] Parsing YAML config..."); const configObj = yaml.load(inferenceConfig) || {}; - console.log("[API] ✓ YAML parsed successfully"); + apiDebugLog("[API] ✓ YAML parsed successfully"); if (outputPath) { setInferenceOutputPath(configObj, outputPath); - console.log("[API] ✓ Injected inference output path:", outputPath); + apiDebugLog("[API] ✓ Injected inference output path:", outputPath); } setInferenceExecutionDefaults(configObj); - console.log("[API] ✓ Applied inference runtime defaults"); + apiDebugLog("[API] ✓ Applied inference runtime defaults"); // Convert back to YAML - console.log("[API] Converting back to YAML..."); + apiDebugLog("[API] Converting back to YAML..."); configToSend = yaml.dump(configObj); - console.log("[API] ✓ YAML conversion successful"); - console.log( + apiDebugLog("[API] ✓ YAML conversion successful"); + apiDebugLog( "[API] Modified config preview (first 500 chars):", configToSend.substring(0, 500), ); @@ -284,6 +611,18 @@ export async function startModelInference( console.error("[API] Error type:", e.constructor.name); console.error("[API] Error message:", e.message); console.warn("[API] Falling back to original config"); + logClientEvent("inference_config_transform_failed", { + level: "WARNING", + message: "Failed to transform inference config before request", + source: "api", + data: { + error: e.message || "unknown error", + configOriginPath, + outputPath, + checkpointPath, + workflowId, + }, + }); configToSend = inferenceConfig; } } else { @@ -292,43 +631,61 @@ export async function startModelInference( ); } - console.log("[API] Building request payload..."); + apiDebugLog("[API] Building request payload..."); const payload = { arguments: { checkpoint: checkpointPath, }, outputPath, + inputImagePath, inferenceConfig: configToSend, configOriginPath, workflow_id: workflowId, }; - console.log("[API] Payload structure:"); - console.log( + apiDebugLog("[API] Payload structure:"); + apiDebugLog( "[API] - arguments.checkpoint:", payload.arguments.checkpoint, ); - console.log("[API] - outputPath:", payload.outputPath); - console.log( + apiDebugLog("[API] - outputPath:", payload.outputPath); + apiDebugLog( "[API] - inferenceConfig length:", payload.inferenceConfig?.length, ); const data = JSON.stringify(payload); - console.log("[API] Request payload size:", data.length, "bytes"); - console.log( + apiDebugLog("[API] Request payload size:", data.length, "bytes"); + apiDebugLog( "[API] JSON payload preview (first 300 chars):", data.substring(0, 300), ); - console.log("[API] Calling makeApiRequest..."); - console.log("[API] Target endpoint: start_model_inference"); - console.log("[API] Method: POST"); - console.log("[API] ========================================="); + const configSummary = summarizeConfigText(configToSend, "inference"); + const diagnostics = detectConfigDiagnostics(configSummary); + logClientEvent("inference_api_payload_prepared", { + level: diagnostics.length ? "WARNING" : "INFO", + message: "Inference API payload prepared", + source: "api", + data: { + workflowId, + configOriginPath, + outputPath, + checkpointPath, + requestBytes: data.length, + configSummary, + diagnostics, + }, + }); + + apiDebugLog("[API] Calling makeApiRequest..."); + apiDebugLog("[API] Target endpoint: start_model_inference"); + apiDebugLog("[API] Method: POST"); + apiDebugLog("[API] ========================================="); const result = await makeApiRequest("start_model_inference", "post", data); - console.log("[API] ✓ makeApiRequest returned:", result); - console.log("========== API.JS: END START_MODEL_INFERENCE ==========\n"); + apiDebugLog("[API] ✓ makeApiRequest returned:", result); + apiDebugLog("========== API.JS: END START_MODEL_INFERENCE ==========\n"); return result; } catch (error) { console.error( @@ -345,7 +702,7 @@ export async function startModelInference( export async function getInferenceStatus() { try { - const res = await axios.get(`${BASE_URL}/inference_status`); + const res = await axios.get(buildApiUrl("/inference_status")); return res.data; } catch (error) { handleError(error); @@ -354,7 +711,21 @@ export async function getInferenceStatus() { export async function getInferenceLogs() { try { - const res = await axios.get(`${BASE_URL}/inference_logs`); + const res = await axios.get(buildApiUrl("/inference_logs")); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function syncWorkflowInferenceRuntime(workflowId, body = {}) { + try { + const res = await apiClient.post( + canonicalizeApiPath( + `/api/workflows/${workflowId}/sync-inference-runtime`, + ), + body, + ); return res.data; } catch (error) { handleError(error); @@ -363,7 +734,7 @@ export async function getInferenceLogs() { export async function stopModelInference() { try { - await axios.post(`${BASE_URL}/stop_model_inference`); + await axios.post(buildApiUrl("/stop_model_inference")); } catch (error) { handleError(error); } @@ -371,7 +742,7 @@ export async function stopModelInference() { export async function queryChatBot(query, conversationId) { try { - const res = await axios.post(`${BASE_URL}/chat/query`, { + const res = await axios.post(buildApiUrl("/chat/query"), { query, conversationId, }); @@ -383,7 +754,7 @@ export async function queryChatBot(query, conversationId) { export async function clearChat() { try { - await axios.post(`${BASE_URL}/chat/clear`); + await axios.post(buildApiUrl("/chat/clear")); } catch (error) { handleError(error); } @@ -393,7 +764,7 @@ export async function clearChat() { export async function listConversations() { try { - const res = await axios.get(`${BASE_URL}/chat/conversations`); + const res = await axios.get(buildApiUrl("/chat/conversations")); return res.data; } catch (error) { handleError(error); @@ -402,7 +773,7 @@ export async function listConversations() { export async function createConversation() { try { - const res = await axios.post(`${BASE_URL}/chat/conversations`); + const res = await axios.post(buildApiUrl("/chat/conversations")); return res.data; } catch (error) { handleError(error); @@ -411,7 +782,7 @@ export async function createConversation() { export async function getConversation(convoId) { try { - const res = await axios.get(`${BASE_URL}/chat/conversations/${convoId}`); + const res = await axios.get(buildApiUrl(`/chat/conversations/${convoId}`)); return res.data; } catch (error) { handleError(error); @@ -420,7 +791,7 @@ export async function getConversation(convoId) { export async function deleteConversation(convoId) { try { - await axios.delete(`${BASE_URL}/chat/conversations/${convoId}`); + await axios.delete(buildApiUrl(`/chat/conversations/${convoId}`)); } catch (error) { handleError(error); } @@ -428,21 +799,28 @@ export async function deleteConversation(convoId) { export async function updateConversationTitle(convoId, title) { try { - const res = await axios.patch(`${BASE_URL}/chat/conversations/${convoId}`, { - title, - }); + const res = await axios.patch( + buildApiUrl(`/chat/conversations/${convoId}`), + { title }, + ); return res.data; } catch (error) { handleError(error); } } -export async function queryHelperChat(taskKey, query, fieldContext) { +export async function queryHelperChat( + taskKey, + query, + fieldContext, + history = [], +) { try { - const res = await axios.post(`${BASE_URL}/chat/helper/query`, { + const res = await axios.post(buildApiUrl("/chat/helper/query"), { taskKey, query, fieldContext, + history, }); return res.data?.response; } catch (error) { @@ -452,7 +830,7 @@ export async function queryHelperChat(taskKey, query, fieldContext) { export async function clearHelperChat(taskKey) { try { - await axios.post(`${BASE_URL}/chat/helper/clear`, { taskKey }); + await axios.post(buildApiUrl("/chat/helper/clear"), { taskKey }); } catch (error) { handleError(error); } @@ -475,7 +853,7 @@ export async function getModelArchitectures() { export async function getPMData() { try { - const res = await apiClient.get("/api/pm/data"); + const res = await apiClient.get(canonicalizeApiPath("/api/pm/data")); return res.data; } catch (error) { handleError(error); @@ -484,7 +862,7 @@ export async function getPMData() { export async function getPMConfig() { try { - const res = await apiClient.get("/api/pm/config"); + const res = await apiClient.get(canonicalizeApiPath("/api/pm/config")); return res.data; } catch (error) { handleError(error); @@ -493,7 +871,7 @@ export async function getPMConfig() { export async function getPMSchema() { try { - const res = await apiClient.get("/api/pm/schema"); + const res = await apiClient.get(canonicalizeApiPath("/api/pm/schema")); return res.data; } catch (error) { handleError(error); @@ -502,7 +880,10 @@ export async function getPMSchema() { export async function updatePMConfig(patch) { try { - const res = await apiClient.post("/api/pm/config", patch); + const res = await apiClient.post( + canonicalizeApiPath("/api/pm/config"), + patch, + ); return res.data; } catch (error) { handleError(error); @@ -511,7 +892,10 @@ export async function updatePMConfig(patch) { export async function savePMData(state) { try { - const res = await apiClient.post("/api/pm/data", state); + const res = await apiClient.post( + canonicalizeApiPath("/api/pm/data"), + state, + ); return res.data; } catch (error) { handleError(error); @@ -520,7 +904,33 @@ export async function savePMData(state) { export async function resetPMData() { try { - const res = await apiClient.post("/api/pm/data/reset"); + const res = await apiClient.post(canonicalizeApiPath("/api/pm/data/reset")); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function mountProjectDirectory({ + directoryPath, + mountName = "", + destinationPath = "root", +}) { + try { + const res = await apiClient.post(canonicalizeApiPath("/files/mount"), { + directory_path: directoryPath, + mount_name: mountName, + destination_path: destinationPath, + }); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function resetFileWorkspace() { + try { + const res = await apiClient.delete(canonicalizeApiPath("/files/workspace")); return res.data; } catch (error) { handleError(error); @@ -531,7 +941,9 @@ export async function resetPMData() { export async function getCurrentWorkflow() { try { - const res = await apiClient.get("/api/workflows/current"); + const res = await apiClient.get( + canonicalizeApiPath("/api/workflows/current"), + ); return res.data; } catch (error) { handleError(error); @@ -540,7 +952,10 @@ export async function getCurrentWorkflow() { export async function updateWorkflow(workflowId, patch) { try { - const res = await apiClient.patch(`/api/workflows/${workflowId}`, patch); + const res = await apiClient.patch( + canonicalizeApiPath(`/api/workflows/${workflowId}`), + patch, + ); return res.data; } catch (error) { handleError(error); @@ -549,7 +964,9 @@ export async function updateWorkflow(workflowId, patch) { export async function listWorkflowEvents(workflowId) { try { - const res = await apiClient.get(`/api/workflows/${workflowId}/events`); + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/events`), + ); return res.data; } catch (error) { handleError(error); @@ -558,7 +975,9 @@ export async function listWorkflowEvents(workflowId) { export async function getWorkflowHotspots(workflowId) { try { - const res = await apiClient.get(`/api/workflows/${workflowId}/hotspots`); + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/hotspots`), + ); return res.data; } catch (error) { handleError(error); @@ -567,7 +986,9 @@ export async function getWorkflowHotspots(workflowId) { export async function getWorkflowImpactPreview(workflowId) { try { - const res = await apiClient.get(`/api/workflows/${workflowId}/impact-preview`); + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/impact-preview`), + ); return res.data; } catch (error) { handleError(error); @@ -576,7 +997,139 @@ export async function getWorkflowImpactPreview(workflowId) { export async function getWorkflowMetrics(workflowId) { try { - const res = await apiClient.get(`/api/workflows/${workflowId}/metrics`); + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/metrics`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getWorkflowProjectProgress(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/project-progress`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getWorkflowOverview(workflowId, { refresh = true } = {}) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/overview`), + { + params: { refresh }, + }, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function updateWorkflowProjectProgressVolume(workflowId, body) { + try { + const res = await apiClient.post( + canonicalizeApiPath( + `/api/workflows/${workflowId}/project-progress/volume-status`, + ), + body, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getWorkflowAgentRecommendation(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/agent/recommendation`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getWorkflowPreflight(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/preflight`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function listWorkflowArtifacts(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/artifacts`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function listWorkflowModelRuns(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/model-runs`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function listWorkflowModelVersions(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/model-versions`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function listWorkflowCorrectionSets(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/correction-sets`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function listWorkflowEvaluationResults(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/evaluation-results`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function computeWorkflowEvaluationResult(workflowId, body) { + try { + const res = await apiClient.post( + canonicalizeApiPath( + `/api/workflows/${workflowId}/evaluation-results/compute`, + ), + body, + ); return res.data; } catch (error) { handleError(error); @@ -585,7 +1138,21 @@ export async function getWorkflowMetrics(workflowId) { export async function exportWorkflowBundle(workflowId) { try { - const res = await apiClient.post(`/api/workflows/${workflowId}/export-bundle`); + const res = await apiClient.post( + canonicalizeApiPath(`/api/workflows/${workflowId}/export-bundle`), + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function startNewWorkflow(body = {}) { + try { + const res = await apiClient.post( + canonicalizeApiPath("/api/workflows/current/reset"), + body, + ); return res.data; } catch (error) { handleError(error); @@ -594,7 +1161,10 @@ export async function exportWorkflowBundle(workflowId) { export async function appendWorkflowEvent(workflowId, event) { try { - const res = await apiClient.post(`/api/workflows/${workflowId}/events`, event); + const res = await apiClient.post( + canonicalizeApiPath(`/api/workflows/${workflowId}/events`), + event, + ); return res.data; } catch (error) { handleError(error); @@ -604,7 +1174,7 @@ export async function appendWorkflowEvent(workflowId, event) { export async function createAgentAction(workflowId, action) { try { const res = await apiClient.post( - `/api/workflows/${workflowId}/agent-actions`, + canonicalizeApiPath(`/api/workflows/${workflowId}/agent-actions`), action, ); return res.data; @@ -613,10 +1183,30 @@ export async function createAgentAction(workflowId, action) { } } -export async function approveAgentAction(workflowId, eventId) { +export async function approveAgentAction(workflowId, eventId, overrides = {}) { + try { + const hasOverrides = + overrides && + typeof overrides === "object" && + Object.keys(overrides).length > 0; + const res = await apiClient.post( + canonicalizeApiPath( + `/api/workflows/${workflowId}/agent-actions/${eventId}/approve`, + ), + hasOverrides ? { overrides } : undefined, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function runWorkflowCommand(workflowId, commandId) { try { const res = await apiClient.post( - `/api/workflows/${workflowId}/agent-actions/${eventId}/approve`, + canonicalizeApiPath( + `/api/workflows/${workflowId}/commands/${commandId}/run`, + ), ); return res.data; } catch (error) { @@ -627,7 +1217,9 @@ export async function approveAgentAction(workflowId, eventId) { export async function rejectAgentAction(workflowId, eventId) { try { const res = await apiClient.post( - `/api/workflows/${workflowId}/agent-actions/${eventId}/reject`, + canonicalizeApiPath( + `/api/workflows/${workflowId}/agent-actions/${eventId}/reject`, + ), ); return res.data; } catch (error) { @@ -635,11 +1227,30 @@ export async function rejectAgentAction(workflowId, eventId) { } } -export async function queryWorkflowAgent(workflowId, query) { +export async function queryWorkflowAgent( + workflowId, + query, + conversationId = null, +) { try { - const res = await apiClient.post(`/api/workflows/${workflowId}/agent/query`, { - query, - }); + const res = await apiClient.post( + canonicalizeApiPath(`/api/workflows/${workflowId}/agent/query`), + { + query, + conversation_id: conversationId, + }, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getWorkflowAgentConversation(workflowId) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/agent/conversation`), + ); return res.data; } catch (error) { handleError(error); diff --git a/client/src/api.test.js b/client/src/api.test.js new file mode 100644 index 00000000..25788c74 --- /dev/null +++ b/client/src/api.test.js @@ -0,0 +1,95 @@ +const BASE_WITH_API_PREFIX = "https://demo.example/api"; +const BASE_WITHOUT_API_PREFIX = "https://demo.example"; + +const loadApiModule = (baseUrl) => { + process.env.REACT_APP_API_BASE_URL = baseUrl; + process.env.REACT_APP_SERVER_PROTOCOL = "https"; + process.env.REACT_APP_SERVER_URL = "demo.example"; + + const apiClientMock = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + put: jest.fn(), + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + }; + + const axiosMock = { + create: jest.fn(() => apiClientMock), + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + put: jest.fn(), + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + }; + + jest.resetModules(); + jest.doMock("axios", () => axiosMock); + jest.doMock("./logging/appEventLog", () => ({ + logClientEvent: jest.fn(), + })); + + const api = require("./api"); + return { api, apiClientMock, axiosMock }; +}; + +describe("api canonicalization", () => { + afterEach(() => { + delete process.env.REACT_APP_API_BASE_URL; + jest.clearAllMocks(); + }); + + it("strips legacy workflow prefix when base URL already ends with /api", async () => { + const { api, apiClientMock } = loadApiModule(BASE_WITH_API_PREFIX); + apiClientMock.get.mockResolvedValue({ data: { id: 42 } }); + + await api.getCurrentWorkflow(); + + expect(apiClientMock.get).toHaveBeenCalledWith("/workflows/current"); + }); + + it("keeps /api/workflows path when base URL does not end with /api", async () => { + const { api, apiClientMock } = loadApiModule(BASE_WITHOUT_API_PREFIX); + apiClientMock.get.mockResolvedValue({ data: { id: 42 } }); + + await api.getCurrentWorkflow(); + + expect(apiClientMock.get).toHaveBeenCalledWith("/api/workflows/current"); + }); + + it("canonicalizes files-style prefixed paths with query strings", () => { + const { api } = loadApiModule(BASE_WITH_API_PREFIX); + + const url = api.buildApiUrl("/api/files?parent=root"); + + expect(url).toBe("https://demo.example/api/files?parent=root"); + }); + + it("canonicalizes training approval/action paths for base URLs with /api/workflows", () => { + const { api, apiClientMock } = loadApiModule( + "https://demo.example/api/workflows", + ); + apiClientMock.post.mockResolvedValue({ data: {} }); + + api.approveAgentAction(99, 123); + expect(apiClientMock.post).toHaveBeenNthCalledWith( + 1, + "/99/agent-actions/123/approve", + undefined, + ); + + api.runWorkflowCommand(99, 321); + expect(apiClientMock.post).toHaveBeenNthCalledWith( + 2, + "/99/commands/321/run", + ); + }); +}); diff --git a/client/src/components/Chatbot.js b/client/src/components/Chatbot.js index 38de04e0..502672d1 100644 --- a/client/src/components/Chatbot.js +++ b/client/src/components/Chatbot.js @@ -6,78 +6,661 @@ import { Typography, Space, Spin, - Popconfirm, - Tooltip, + Tag, + message, } from "antd"; -import { - SendOutlined, - CloseOutlined, - DeleteOutlined, - PlusOutlined, - MessageOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, -} from "@ant-design/icons"; -import { - queryChatBot, - clearChat, - listConversations, - getConversation, - deleteConversation, -} from "../api"; +import { SendOutlined, CloseOutlined } from "@ant-design/icons"; +import { getWorkflowAgentConversation, queryChatBot } from "../api"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import WorkflowTimeline from "./WorkflowTimeline"; import { useWorkflow } from "../contexts/WorkflowContext"; +import AgentProposalCard from "./chat/AgentProposalCard"; +import AssistantActionCard from "./chat/AssistantActionCard"; +import AssistantCommandCard from "./chat/AssistantCommandCard"; +import AssistantTrace from "./chat/AssistantTrace"; +import AgentBadge, { getAgentVisual } from "./chat/AgentVisuals"; +import { logClientEvent } from "../logging/appEventLog"; const { TextArea } = Input; const { Text } = Typography; +const MONO_FONT = + "'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', monospace"; const GREETING = { role: "assistant", content: - "Hello! I'm your AI assistant, built to help you navigate PyTC Client. How can I help you today?", + "I’ll coordinate the workflow and pull in specialist agents for data, visualization, proofreading, training, inference, and evidence when needed. Tell me what you want to do in plain language.", }; -/* ─── helper: truncate a string to `n` chars ─────────────────────────────── */ -const truncate = (str, n = 50) => - str.length > n ? str.slice(0, n).trimEnd() + "…" : str; +const QUICK_NEXT_QUERY = "What should I do next?"; +const CONTINUOUS_CHAT_STORAGE_KEY = "pytc.workflowAssistant.continuousChat.v1"; + +const WORKFLOW_SLASH_COMMANDS = { + "/status": "status", + "/next": "next step", + "/help": "what can the agent do", + "/infer": "run model", + "/inference": "run model", + "/segment": "run model to segment this volume", + "/proofread": "proofread this data", + "/train": "start training", + "/compare": "compare results and compute metrics", + "/metrics": "compare results and compute metrics", + "/export": "export evidence bundle", +}; -const WORKFLOW_QUERY_TERMS = [ +const WORKFLOW_AGENT_KEYWORDS = [ "workflow", - "next", - "stage", - "retrain", - "training", - "corrected", - "proofread", + "project", + "dataset", + "directory", + "folder", + "folders", + "file", + "files", + "volume", + "image", + "label", "mask", + "segmentation", + "segment", + "proofread", + "proofreading", + "train", + "training", + "retrain", "inference", + "infer", + "run model", + "checkpoint", + "config", "visualize", + "visualise", + "vis", + "viz", + "viewer", + "scales", + "voxel", + "resolution", + "status", + "next step", + "what next", + "what should i do", + "what do you need", + "what can you do", + "help me", + "run things", + "mount", + "reset workspace", + "clear workspace", + "monitor", + "logs", + "tensorboard", "evaluate", + "metrics", + "compare", + "export", + "evidence", +]; + +const WORKFLOW_AGENT_PATTERNS = [ + /\b(view|show|open|inspect|see|vis|viz)\b.{0,48}\b(data|volume|volumes|image|images|label|labels|mask|masks|seg|segs|segmentation|segmentations)\b/, + /\blook at\b.{0,48}\b(data|volume|volumes|image|images|label|labels|mask|masks|seg|segs|segmentation|segmentations)\b/, + /\bwhat\b.{0,40}\b(looking at|loaded|open|mounted|project|dataset|volume|data)\b/, + /\bwhere\b.{0,40}\b(are we|am i|is this|in the workflow)\b/, + /\b(run|start|launch)\b.{0,48}\b(app|model|inference|training|proofread|proofreading|viewer|visualization)\b/, +]; + +const WORKFLOW_FOLLOW_UP_PATTERNS = [ + /\b(take a look|look at|show|open|view|inspect|check)\b/, + /\b(it|this|that|those|them|there|here|first|next|again|same one)\b/, + /\b(go ahead|do it|do that|run it|start it|use that|sounds good)\b/, + /^(yes|yeah|yep|ok|okay|sure|please|what|why|how|wait|huh)[\s!.?,]*$/i, +]; + +const toList = (value) => (Array.isArray(value) ? value : []); + +const normalizeWorkflowAgentQuery = (query) => { + const trimmed = query.trim(); + if (!trimmed.startsWith("/")) { + return { agentQuery: query, commandAlias: null }; + } + const [command, ...rest] = trimmed.split(/\s+/); + const alias = WORKFLOW_SLASH_COMMANDS[command.toLowerCase()]; + if (!alias) { + return { agentQuery: query, commandAlias: null }; + } + const suffix = rest.join(" ").trim(); + return { + agentQuery: suffix ? `${alias}: ${suffix}` : alias, + commandAlias: command.toLowerCase(), + }; +}; + +const clientEffectsWithoutRuntime = (effects = {}) => { + if (!effects || typeof effects !== "object") return effects; + const { runtime_action: _runtimeAction, ...rest } = effects; + return rest; +}; + +const runnableAttachmentCount = (message = {}) => + (Array.isArray(message.actions) ? message.actions.length : 0) + + (Array.isArray(message.commands) ? message.commands.length : 0) + + (Array.isArray(message.proposals) ? message.proposals.length : 0); + +const agentIdentityKey = (agent = {}) => + agent.agent_type || + agent.type || + agent.label || + agent.agent_label || + "project_manager"; + +const messageAgents = (message = {}) => { + const agents = []; + const addAgent = (agent) => { + if (!agent) return; + const visual = getAgentVisual(agent); + const key = agentIdentityKey(agent); + if (agents.some((item) => item.key === key)) return; + agents.push({ key, ...visual }); + }; + + (message.trace || []).forEach((item) => addAgent(item)); + (message.actions || []).forEach((action) => { + addAgent(action.specialist_agent || action); + addAgent(action.orchestrator_agent); + }); + (message.proposals || []).forEach((proposal) => { + const payload = proposal.payload || proposal; + addAgent(payload.specialist_agent || payload.action_card?.specialist_agent); + addAgent( + payload.orchestrator_agent || payload.action_card?.orchestrator_agent, + ); + }); + + return agents; +}; + +const STALE_WORKFLOW_CARD_NOTICE = + "That run card belonged to an earlier workflow, so I hid the stale button. Ask me to stage it again and I'll make a fresh one for this project."; + +const STALE_WORKFLOW_CARD_PATTERN = + /(review (?:the )?run card|review run|approve|run in app|launch(?:ing| it)?|run card)/i; + +const proposalRequestFromEvent = (proposal = {}) => { + const payload = proposal.payload || {}; + const params = payload.params || proposal.params || {}; + const action = payload.action || proposal.action || proposal.type; + if (!action || !params || typeof params !== "object") return null; + return { + action, + summary: proposal.summary || `Approve agent proposal: ${action}.`, + payload: params, + }; +}; + +const isProposalNotFoundError = (error) => { + const messageText = String(error?.message || error || "").toLowerCase(); + return ( + messageText.includes("agent proposal not found") || + messageText.includes("404") + ); +}; + +const isGreetingQuery = (query) => + /^(hi|hello|hey|yo|sup|hiya)[\s!.?,]*$/i.test(query.trim()); + +const isGibberishQuery = (query) => { + const lower = query.trim().toLowerCase(); + const compact = lower.replace(/[^a-z0-9]+/g, ""); + if (!compact || lower.startsWith("/")) return false; + const vowelCount = (compact.match(/[aeiou]/g) || []).length; + return compact.length >= 8 && !/\s/.test(lower) && vowelCount <= 2; +}; + +const isWorkflowAgentQuery = (query) => { + const trimmed = query.trim(); + if (!trimmed) return false; + const normalized = normalizeWorkflowAgentQuery(trimmed); + if (normalized.commandAlias) return true; + if (isGreetingQuery(trimmed)) return true; + const lower = trimmed.toLowerCase(); + return ( + WORKFLOW_AGENT_KEYWORDS.some((keyword) => lower.includes(keyword)) || + WORKFLOW_AGENT_PATTERNS.some((pattern) => pattern.test(lower)) + ); +}; + +const getLastAssistantMessage = (messages = []) => { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.role === "assistant") { + return messages[index]; + } + } + return null; +}; + +const isWorkflowFollowUpQuery = (query, messages = [], workflowId = null) => { + const lastAssistant = getLastAssistantMessage(messages); + if (lastAssistant?.source !== "workflow_orchestrator") { + return false; + } + if ( + lastAssistant.workflow_id && + workflowId && + String(lastAssistant.workflow_id) !== String(workflowId) + ) { + return false; + } + const trimmed = query.trim(); + if (!trimmed || trimmed.length > 140) { + return false; + } + const lower = trimmed.toLowerCase(); + return WORKFLOW_FOLLOW_UP_PATTERNS.some((pattern) => pattern.test(lower)); +}; + +const PROMPT_LEAK_MARKERS = [ + "you are the supervisor agent", + "response style for biologists", + "routing — decide", + "routing - decide", + "critical rules:", + "sub-agents:", + "search_documentation", + "delegate_to_training_agent", + "delegate_to_inference_agent", ]; +const parseToolCallJson = (text) => { + try { + const parsed = JSON.parse(text); + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.name !== "string" + ) { + return null; + } + return parsed; + } catch { + return null; + } +}; + +const parseRawToolCall = (content) => { + const text = String(content || "").trim(); + if (text.startsWith("{") && text.endsWith("}")) { + const parsed = parseToolCallJson(text); + if (parsed) return parsed; + } + const embeddedJson = text.match(/\{[\s\S]*"name"\s*:\s*"[^"]+"[\s\S]*\}/); + return embeddedJson ? parseToolCallJson(embeddedJson[0]) : null; +}; + +const normalizeAssistantContent = (content) => { + const toolCall = parseRawToolCall(content); + if (!toolCall) return String(content || ""); + if (toolCall.name === "visualize_volume_pair") { + return ( + "I should not have shown that internal command.\n" + + "Do this: use the workflow action card to choose a volume pair, then open it in Visualize." + ); + } + return ( + "I should not have shown that internal command.\n" + + "Tell me what you want to do in plain language and I will offer a safe app action." + ); +}; + +const sanitizeLoadedMessage = (message, currentWorkflowId = null) => { + if (message.role !== "assistant") return message; + const content = String(message.content || ""); + const normalizedContent = normalizeAssistantContent(content); + const messageWorkflowId = Number( + message.workflow_id || message.workflowId || 0, + ); + const activeWorkflowId = Number(currentWorkflowId || 0); + if ( + messageWorkflowId && + activeWorkflowId && + messageWorkflowId !== activeWorkflowId + ) { + const shouldExplainStrippedCard = + runnableAttachmentCount(message) > 0 && + STALE_WORKFLOW_CARD_PATTERN.test(normalizedContent) && + !normalizedContent.includes(STALE_WORKFLOW_CARD_NOTICE); + return { + ...message, + content: shouldExplainStrippedCard + ? `${normalizedContent}\n\n${STALE_WORKFLOW_CARD_NOTICE}` + : normalizedContent, + actions: [], + commands: [], + proposals: [], + trace: [], + }; + } + if (normalizedContent !== content) { + return { ...message, content: normalizedContent }; + } + const lower = content.toLowerCase(); + const leaked = PROMPT_LEAK_MARKERS.some((marker) => lower.includes(marker)); + if (!leaked) return message; + return { + ...message, + content: + "Hi. I can help run this segmentation loop. Ask me to run the model, proofread masks, use saved edits for training, or compare results.", + }; +}; + +const loadContinuousChatState = (currentWorkflowId = null) => { + if (typeof window === "undefined" || !window.sessionStorage) { + return { activeConvoId: null, messages: [GREETING] }; + } + try { + const raw = window.sessionStorage.getItem(CONTINUOUS_CHAT_STORAGE_KEY); + if (!raw) return { activeConvoId: null, messages: [GREETING] }; + const parsed = JSON.parse(raw); + const storedMessages = Array.isArray(parsed?.messages) + ? parsed.messages + .filter( + (message) => + message && + (message.role === "assistant" || message.role === "user") && + typeof message.content === "string", + ) + .map((message) => sanitizeLoadedMessage(message, currentWorkflowId)) + : []; + return { + activeConvoId: parsed?.activeConvoId || parsed?.conversationId || null, + messages: storedMessages.length ? storedMessages : [GREETING], + }; + } catch { + return { activeConvoId: null, messages: [GREETING] }; + } +}; + +const saveContinuousChatState = ({ activeConvoId, messages }) => { + if (typeof window === "undefined" || !window.sessionStorage) return; + try { + window.sessionStorage.setItem( + CONTINUOUS_CHAT_STORAGE_KEY, + JSON.stringify({ activeConvoId, messages }), + ); + } catch { + // Session persistence is a convenience; chat should still work without it. + } +}; + +const workflowConversationMessageToChatMessage = ( + message, + currentWorkflowId = null, +) => + sanitizeLoadedMessage( + { + role: message.role, + content: message.content, + source: message.source || undefined, + workflow_id: message.workflow_id || currentWorkflowId || undefined, + actions: message.actions || [], + commands: message.commands || [], + proposals: message.proposals || [], + trace: message.trace || [], + }, + currentWorkflowId, + ); + +const byProposalId = (messages = []) => { + const index = {}; + messages.forEach((message) => { + (message?.proposals || []).forEach((proposal) => { + if (proposal?.id == null) return; + index[String(proposal.id)] = { + ...(proposal?.payload ? { payload: proposal.payload } : {}), + ...proposal, + }; + }); + }); + return index; +}; + +const reconcileProposal = (localProposal, hydratedProposal) => { + if (!localProposal?.id || !hydratedProposal) return localProposal; + const approval_status = + hydratedProposal.approval_status || + localProposal.approval_status || + "pending"; + return { + ...localProposal, + ...hydratedProposal, + summary: hydratedProposal.summary || localProposal.summary, + payload: { + ...(localProposal?.payload || {}), + ...(hydratedProposal.payload || {}), + }, + params: { + ...(localProposal?.params || {}), + ...(hydratedProposal.params || {}), + }, + approval_status, + }; +}; + +const reconcileProposalStatuses = (messages = [], hydratedMessages = []) => { + const hydrated = byProposalId(hydratedMessages); + return messages.map((message) => { + if ( + !message || + !Array.isArray(message.proposals) || + !message.proposals.length + ) { + return message; + } + return { + ...message, + proposals: message.proposals.map((proposal) => { + const hydratedProposal = hydrated[String(proposal?.id)]; + if (!hydratedProposal) return proposal; + return reconcileProposal(proposal, hydratedProposal); + }), + }; + }); +}; + /* ═══════════════════════════════════════════════════════════════════════════ */ -function Chatbot({ onClose }) { +function Chatbot({ + onClose, + queuedWorkflowQuery = null, + onQueuedWorkflowQueryConsumed, +}) { /* ── state ─────────────────────────────────────────────────────────────── */ - const [conversations, setConversations] = useState([]); - const [activeConvoId, setActiveConvoId] = useState(null); - const [messages, setMessages] = useState([GREETING]); + const workflowContext = useWorkflow(); + const runClientEffects = workflowContext?.runClientEffects; + const executeAssistantItem = workflowContext?.executeAssistantItem; + const workflow = workflowContext?.workflow; + const initialChatStateRef = useRef(null); + if (initialChatStateRef.current === null) { + initialChatStateRef.current = loadContinuousChatState(workflow?.id); + } + + const [activeConvoId, setActiveConvoId] = useState( + initialChatStateRef.current.activeConvoId, + ); + const [messages, setMessages] = useState( + initialChatStateRef.current.messages, + ); const [inputValue, setInputValue] = useState(""); const [isSending, setIsSending] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(true); - const [isLoadingConvo, setIsLoadingConvo] = useState(false); const lastMessageRef = useRef(null); - const workflowContext = useWorkflow(); + const handledQueuedQueryRef = useRef(null); + const hydratedWorkflowConversationRef = useRef(null); + + const appendLocalProposalToLatestAssistant = useCallback((proposal) => { + if (!proposal?.id) return; + setMessages((prev) => { + const next = [...prev]; + for (let index = next.length - 1; index >= 0; index -= 1) { + if (next[index]?.role !== "assistant") continue; + const proposals = next[index].proposals || []; + next[index] = { + ...next[index], + proposals: [...proposals, proposal], + }; + return next; + } + return prev; + }); + }, []); + + const updateLocalProposalStatus = useCallback( + (proposalId, approvalStatus) => { + if (!proposalId) return; + setMessages((prev) => + prev.map((entry) => { + if (!Array.isArray(entry.proposals) || entry.proposals.length === 0) { + return entry; + } + return { + ...entry, + proposals: entry.proposals.map((proposal) => + proposal.id === proposalId + ? { ...proposal, approval_status: approvalStatus } + : proposal, + ), + }; + }), + ); + }, + [], + ); + + const buildTrainingRunProposal = useCallback((item) => { + const effects = item?.client_effects || {}; + const runtimeAction = effects.runtime_action || {}; + const configPreset = effects.set_training_config_preset || ""; + const imagePath = effects.set_training_image_path || ""; + const labelPath = effects.set_training_label_path || ""; + const outputPath = effects.set_training_output_path || ""; + const parameterMode = runtimeAction.parameter_mode || "agent_default"; + + return { + action: "start_training_run", + summary: `Approve training run: ${item?.title || item?.label || "start training"}.`, + payload: { + client_effects: effects, + action_card: item?.action_card || null, + orchestrator_agent: item?.orchestrator_agent || null, + specialist_agent: item?.specialist_agent || null, + config_preset: configPreset, + image_path: imagePath, + label_path: labelPath, + output_path: outputPath, + parameter_mode: parameterMode, + autopick_parameters: Boolean(runtimeAction.autopick_parameters), + training_volume_subset: effects.training_volume_subset || null, + }, + }; + }, []); + + const buildClientEffectsProposal = useCallback((item) => { + const effects = item?.client_effects || {}; + const label = item?.title || item?.label || item?.id || "assistant action"; + return { + action: "run_client_effects", + summary: `Approve app action: ${label}.`, + payload: { + client_effects: effects, + action_card: item?.action_card || null, + orchestrator_agent: item?.orchestrator_agent || null, + specialist_agent: item?.specialist_agent || null, + item_id: item?.id || null, + item_label: label, + item_type: item?.command ? "command" : "action", + risk_level: item?.risk_level || null, + runtime_action: effects.runtime_action || null, + workflow_action: effects.workflow_action || null, + }, + }; + }, []); + + const populateTrainingReviewForm = useCallback( + async (item, itemId) => { + if (!runClientEffects) return true; + const reviewEffects = clientEffectsWithoutRuntime(item?.client_effects); + try { + await runClientEffects(reviewEffects); + logClientEvent("assistant_item_training_review_populated", { + source: "chatbot", + message: "Assistant populated the training review form", + data: { + workflowId: workflow?.id || null, + activeConvoId, + itemId, + }, + }); + return true; + } catch (error) { + const fallbackEffects = { + navigate_to: reviewEffects?.navigate_to || "training", + set_training_image_path: reviewEffects?.set_training_image_path, + set_training_label_path: reviewEffects?.set_training_label_path, + set_training_output_path: reviewEffects?.set_training_output_path, + set_training_log_path: reviewEffects?.set_training_log_path, + refresh_project_progress: reviewEffects?.refresh_project_progress, + }; + try { + await runClientEffects(fallbackEffects); + logClientEvent("assistant_item_training_review_partial", { + level: "WARN", + source: "chatbot", + message: + error.message || + "Training review form populated without every requested effect", + data: { + workflowId: workflow?.id || null, + activeConvoId, + itemId, + }, + }); + message.warning( + "I filled the run paths, but one review detail needs attention before launch.", + ); + return false; + } catch (fallbackError) { + logClientEvent("assistant_item_training_review_failed", { + level: "ERROR", + source: "chatbot", + message: + fallbackError.message || + error.message || + "Could not populate the training review form", + data: { + workflowId: workflow?.id || null, + activeConvoId, + itemId, + }, + }); + throw error; + } + } + }, + [activeConvoId, runClientEffects, workflow?.id], + ); const shouldUseWorkflowAgent = (query) => { if (!workflowContext?.workflow?.id || !workflowContext?.queryAgent) { return false; } - const lower = query.toLowerCase(); - return WORKFLOW_QUERY_TERMS.some((term) => lower.includes(term)); + if (isGibberishQuery(query)) { + return false; + } + return ( + isWorkflowAgentQuery(query) || + isWorkflowFollowUpQuery(query, messages, workflowContext.workflow.id) + ); }; /* ── scroll ────────────────────────────────────────────────────────────── */ @@ -94,71 +677,211 @@ function Chatbot({ onClose }) { scrollToBottom(); }, [messages, isSending, scrollToBottom]); - /* ── load conversation list on mount ────────────────────────────────────── */ useEffect(() => { + saveContinuousChatState({ activeConvoId, messages }); + }, [activeConvoId, messages]); + + useEffect(() => { + if (!workflow?.id) return; + setMessages((prev) => + prev.map((message) => sanitizeLoadedMessage(message, workflow.id)), + ); + }, [workflow?.id]); + + useEffect(() => { + if (!workflow?.id) return; + if (hydratedWorkflowConversationRef.current === workflow.id) return; + hydratedWorkflowConversationRef.current = workflow.id; + let cancelled = false; - (async () => { - try { - const convos = await listConversations(); - if (!cancelled && convos) setConversations(convos); - } catch { - // server may not be ready yet - } - })(); + getWorkflowAgentConversation(workflow.id) + .then((conversation) => { + if (cancelled || !conversation?.messages?.length) return; + const serverMessages = conversation.messages + .filter( + (message) => + message && + (message.role === "assistant" || message.role === "user") && + typeof message.content === "string", + ) + .map((message) => + workflowConversationMessageToChatMessage(message, workflow.id), + ); + if (!serverMessages.length) return; + setActiveConvoId( + (current) => + current || + conversation.conversation_id || + conversation.conversationId || + null, + ); + setMessages((previousMessages) => { + const hasLocalUserMessages = previousMessages.some( + (message) => message.role === "user", + ); + if (hasLocalUserMessages) { + return reconcileProposalStatuses(previousMessages, serverMessages); + } + return reconcileProposalStatuses(serverMessages, serverMessages); + }); + }) + .catch((error) => { + logClientEvent("workflow_agent_chat_hydration_failed", { + level: "WARNING", + source: "chatbot", + message: error.message || "Could not hydrate workflow chat history.", + data: { workflowId: workflow.id }, + }); + }); return () => { cancelled = true; }; - }, []); + }, [workflow?.id]); - /* ── switch conversation ───────────────────────────────────────────────── */ - const loadConversation = async (convoId) => { - if (convoId === activeConvoId) return; - setIsLoadingConvo(true); - try { - const convo = await getConversation(convoId); - if (!convo) return; - await clearChat(); // reset LangChain in-memory state - setActiveConvoId(convo.id); - const dbMessages = - convo.messages?.map((m) => ({ role: m.role, content: m.content })) ?? - []; - setMessages([GREETING, ...dbMessages]); - } finally { - setIsLoadingConvo(false); - } - }; + const sendWorkflowAgentMessage = useCallback( + async ( + query, + { + displayQuery = query, + source = "chatbot", + hideGreetingActions = true, + } = {}, + ) => { + if (!query.trim() || isSending) return false; + if (!workflowContext?.workflow?.id || !workflowContext?.queryAgent) { + return false; + } - /* ── new chat ──────────────────────────────────────────────────────────── */ - const handleNewChat = async () => { - await clearChat(); - setActiveConvoId(null); - setMessages([GREETING]); - setInputValue(""); - }; + const isGreeting = hideGreetingActions && isGreetingQuery(displayQuery); + const { agentQuery, commandAlias } = normalizeWorkflowAgentQuery(query); + setMessages((prev) => [...prev, { role: "user", content: displayQuery }]); + setIsSending(true); + try { + logClientEvent("workflow_agent_chat_sent", { + source, + message: "Workflow-agent chat query sent", + data: { + workflowId: workflowContext.workflow.id, + conversationId: activeConvoId, + queryPreview: displayQuery.slice(0, 160), + agentQueryPreview: agentQuery.slice(0, 160), + commandAlias, + queryLength: agentQuery.length, + }, + }); + const data = await workflowContext.queryAgent( + agentQuery, + activeConvoId, + ); + const response = + data?.response || + "I could not inspect the workflow state for that request."; + const returnedConvoId = + data?.conversationId ?? data?.conversation_id ?? activeConvoId; + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: response, + source: data?.source || "workflow_orchestrator", + workflow_id: workflowContext.workflow.id, + actions: isGreeting ? [] : data?.actions || [], + commands: isGreeting ? [] : data?.commands || [], + proposals: isGreeting ? [] : data?.proposals || [], + trace: isGreeting ? [] : data?.trace || [], + }, + ]); + if (!activeConvoId && returnedConvoId) { + setActiveConvoId(returnedConvoId); + } + logClientEvent("workflow_agent_chat_completed", { + source, + message: "Workflow-agent chat query completed", + data: { + workflowId: workflowContext.workflow.id, + conversationId: returnedConvoId, + responseSource: data?.source || "workflow_orchestrator", + intent: data?.intent || null, + commandAlias, + actionCount: data?.actions?.length || 0, + commandCount: data?.commands?.length || 0, + proposalCount: data?.proposals?.length || 0, + traceCount: data?.trace?.length || 0, + }, + }); + return true; + } catch (e) { + logClientEvent("chat_send_failed", { + level: "ERROR", + source, + message: e.message || "Error contacting chatbot.", + data: { + workflowId: workflowContext?.workflow?.id || null, + activeConvoId, + queryPreview: displayQuery.slice(0, 160), + }, + }); + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: e.message || "Error contacting chatbot.", + }, + ]); + return false; + } finally { + setIsSending(false); + } + }, + [activeConvoId, isSending, workflowContext], + ); + + useEffect(() => { + if (!queuedWorkflowQuery?.id) return; + if (handledQueuedQueryRef.current === queuedWorkflowQuery.id) return; + if (isSending) return; + + handledQueuedQueryRef.current = queuedWorkflowQuery.id; + onQueuedWorkflowQueryConsumed?.(queuedWorkflowQuery.id); + sendWorkflowAgentMessage(queuedWorkflowQuery.query || QUICK_NEXT_QUERY, { + displayQuery: queuedWorkflowQuery.displayText || QUICK_NEXT_QUERY, + source: queuedWorkflowQuery.source || "quick_next", + hideGreetingActions: false, + }); + }, [ + isSending, + onQueuedWorkflowQueryConsumed, + queuedWorkflowQuery, + sendWorkflowAgentMessage, + ]); /* ── send message ──────────────────────────────────────────────────────── */ const handleSendMessage = async () => { if (!inputValue.trim() || isSending) return; const query = inputValue; setInputValue(""); - setMessages((prev) => [...prev, { role: "user", content: query }]); - setIsSending(true); try { if (shouldUseWorkflowAgent(query)) { - const data = await workflowContext.queryAgent(query); - const response = - data?.response || - "I could not inspect the workflow state for that request."; - setMessages((prev) => [ - ...prev, - { role: "assistant", content: response }, - ]); + await sendWorkflowAgentMessage(query); return; } + setMessages((prev) => [...prev, { role: "user", content: query }]); + setIsSending(true); + logClientEvent("llm_chat_sent", { + source: "chatbot", + message: "General assistant chat query sent", + data: { + workflowId: workflowContext?.workflow?.id || null, + conversationId: activeConvoId, + queryPreview: query.slice(0, 160), + queryLength: query.length, + }, + }); const data = await queryChatBot(query, activeConvoId); - const response = - data?.response || "Sorry, I could not generate a response."; + const response = normalizeAssistantContent( + data?.response || "Sorry, I could not generate a response.", + ); const returnedConvoId = data?.conversationId ?? activeConvoId; setMessages((prev) => [ @@ -171,10 +894,26 @@ function Chatbot({ onClose }) { setActiveConvoId(returnedConvoId); } - // Refresh sidebar so the new / updated conversation appears - const convos = await listConversations(); - if (convos) setConversations(convos); + logClientEvent("llm_chat_completed", { + source: "chatbot", + message: "General assistant chat query completed", + data: { + workflowId: workflowContext?.workflow?.id || null, + conversationId: returnedConvoId, + responseLength: response.length, + }, + }); } catch (e) { + logClientEvent("chat_send_failed", { + level: "ERROR", + source: "chatbot", + message: e.message || "Error contacting chatbot.", + data: { + workflowId: workflowContext?.workflow?.id || null, + activeConvoId, + queryPreview: query.slice(0, 160), + }, + }); setMessages((prev) => [ ...prev, { @@ -194,23 +933,220 @@ function Chatbot({ onClose }) { } }; - /* ── delete conversation ───────────────────────────────────────────────── */ - const handleDeleteConvo = async (convoId, e) => { - if (e) e.stopPropagation(); - await deleteConversation(convoId); - setConversations((prev) => prev.filter((c) => c.id !== convoId)); - if (activeConvoId === convoId) { - await handleNewChat(); + const handleRunAssistantItem = async (item) => { + const itemId = item?.id || item?.title || item?.label || "assistant-item"; + logClientEvent("assistant_item_run_started", { + source: "chatbot", + message: "Assistant in-app item run started", + data: { + workflowId: workflow?.id || null, + activeConvoId, + itemId, + itemLabel: item?.title || item?.label || null, + itemType: item?.command ? "command" : "action", + runtimeActionKind: item?.client_effects?.runtime_action?.kind || null, + }, + }); + try { + const runtimeKind = item?.client_effects?.runtime_action?.kind; + if (item?.requires_approval && workflowContext?.proposeAgentAction) { + const trainingFormReady = + runtimeKind === "start_training" + ? await populateTrainingReviewForm(item, itemId) + : true; + const proposal = + runtimeKind === "start_training" + ? await workflowContext.proposeAgentAction( + buildTrainingRunProposal(item), + ) + : await workflowContext.proposeAgentAction( + buildClientEffectsProposal(item), + ); + appendLocalProposalToLatestAssistant(proposal); + message.info( + runtimeKind === "start_training" + ? trainingFormReady + ? "I filled the training form. Review the run, then approve it to start." + : "I filled the training paths. Check the warning, then approve when it looks right." + : "Review the proposed app action, then approve it to run.", + ); + logClientEvent("assistant_item_training_proposal_created", { + source: "chatbot", + message: "Assistant created approval-gated app proposal", + data: { + workflowId: workflow?.id || null, + activeConvoId, + itemId, + runtimeKind, + riskLevel: item?.risk_level || null, + }, + }); + return; + } + if (executeAssistantItem) { + await executeAssistantItem(item); + logClientEvent("assistant_item_run_completed", { + source: "chatbot", + message: "Assistant in-app item run completed", + data: { workflowId: workflow?.id || null, activeConvoId, itemId }, + }); + return; + } + if (!item?.client_effects || !runClientEffects) return; + await runClientEffects(item.client_effects); + logClientEvent("assistant_item_run_completed", { + source: "chatbot", + message: "Assistant in-app item run completed", + data: { workflowId: workflow?.id || null, activeConvoId, itemId }, + }); + } catch (error) { + logClientEvent("assistant_item_run_failed", { + level: "ERROR", + source: "chatbot", + message: error.message || "Assistant in-app item run failed", + data: { workflowId: workflow?.id || null, activeConvoId, itemId }, + }); + throw error; } }; + const recreateProposalForCurrentWorkflow = useCallback( + async (proposal) => { + const request = proposalRequestFromEvent(proposal); + if (!request || !workflowContext?.proposeAgentAction) { + throw new Error("This proposal is stale and cannot be recreated."); + } + const nextProposal = await workflowContext.proposeAgentAction(request); + appendLocalProposalToLatestAssistant(nextProposal); + return nextProposal; + }, + [appendLocalProposalToLatestAssistant, workflowContext], + ); + + const handleApproveProposal = useCallback( + async (proposal, overrides = {}) => { + if (!proposal?.id || !workflowContext?.approveAgentAction) return; + const activeWorkflowId = Number(workflowContext?.workflow?.id || 0); + const proposalWorkflowId = Number( + proposal.workflow_id || proposal.workflowId || 0, + ); + const isStaleWorkflowProposal = + activeWorkflowId && + proposalWorkflowId && + activeWorkflowId !== proposalWorkflowId; + let approvalId = proposal.id; + let recreated = false; + const approvalOverrides = + overrides && Object.keys(overrides).length > 0 ? overrides : undefined; + + try { + if (isStaleWorkflowProposal) { + const nextProposal = + await recreateProposalForCurrentWorkflow(proposal); + approvalId = nextProposal.id; + recreated = true; + } + if (approvalOverrides) { + await workflowContext.approveAgentAction( + approvalId, + approvalOverrides, + ); + } else { + await workflowContext.approveAgentAction(approvalId); + } + updateLocalProposalStatus(approvalId, "approved"); + if (recreated) { + updateLocalProposalStatus(proposal.id, "superseded"); + } + } catch (error) { + if (!recreated && isProposalNotFoundError(error)) { + try { + const nextProposal = + await recreateProposalForCurrentWorkflow(proposal); + if (approvalOverrides) { + await workflowContext.approveAgentAction( + nextProposal.id, + approvalOverrides, + ); + } else { + await workflowContext.approveAgentAction(nextProposal.id); + } + updateLocalProposalStatus(nextProposal.id, "approved"); + updateLocalProposalStatus(proposal.id, "superseded"); + message.info( + "That run card was stale, so I recreated it and approved the fresh one.", + ); + return; + } catch (retryError) { + logClientEvent("assistant_proposal_recreate_failed", { + level: "ERROR", + source: "chatbot", + message: + retryError.message || "Failed to recreate stale proposal.", + data: { + workflowId: activeWorkflowId || null, + proposalId: proposal.id, + proposalWorkflowId: proposalWorkflowId || null, + }, + }); + message.error( + retryError.message || "Could not approve that proposal.", + ); + return; + } + } + logClientEvent("assistant_proposal_approval_failed", { + level: "ERROR", + source: "chatbot", + message: error.message || "Failed to approve proposal.", + data: { + workflowId: activeWorkflowId || null, + proposalId: proposal.id, + proposalWorkflowId: proposalWorkflowId || null, + }, + }); + message.error(error.message || "Could not approve that proposal."); + } + }, + [ + recreateProposalForCurrentWorkflow, + updateLocalProposalStatus, + workflowContext, + ], + ); + + const handleRejectProposal = useCallback( + async (proposal) => { + if (!proposal?.id || !workflowContext?.rejectAgentAction) return; + try { + await workflowContext.rejectAgentAction(proposal.id); + updateLocalProposalStatus(proposal.id, "rejected"); + } catch (error) { + message.error(error.message || "Could not reject that proposal."); + } + }, + [updateLocalProposalStatus, workflowContext], + ); + /* ── markdown renderers ────────────────────────────────────────────────── */ const mdComponents = { + p: ({ children }) => ( +

{children}

+ ), + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), ul: ({ children }) => ( -
    {children}
+
    {children}
), ol: ({ children }) => ( -
    {children}
+
    {children}
), table: ({ children }) => (
@@ -279,133 +1215,13 @@ function Chatbot({ onClose }) { /* RENDER */ /* ═══════════════════════════════════════════════════════════════════════ */ return ( -
- {/* ── Sidebar ─────────────────────────────────────────────────────── */} - {sidebarOpen && ( -
- {/* header */} -
- - Chats - - - -
- - {/* conversation list */} -
- {conversations.length === 0 && ( - - No past chats yet - - )} - {conversations.map((c) => ( -
loadConversation(c.id)} - style={{ - padding: "8px", - margin: "2px 0", - borderRadius: 6, - cursor: "pointer", - display: "flex", - alignItems: "center", - gap: 8, - background: - c.id === activeConvoId ? "#e6f4ff" : "transparent", - border: - c.id === activeConvoId - ? "1px solid #91caff" - : "1px solid transparent", - transition: "background 0.15s", - }} - onMouseEnter={(e) => { - if (c.id !== activeConvoId) - e.currentTarget.style.background = "#f0f0f0"; - }} - onMouseLeave={(e) => { - if (c.id !== activeConvoId) - e.currentTarget.style.background = "transparent"; - }} - > - - - {truncate(c.title)} - - handleDeleteConvo(c.id, e)} - onCancel={(e) => e?.stopPropagation()} - okText="Delete" - cancelText="Cancel" - > -
- ))} -
-
- )} - +
{/* ── Main chat area ──────────────────────────────────────────────── */}
- {!sidebarOpen && ( - -
- - {/* messages */} -
- {isLoadingConvo ? ( -
- -
- ) : ( - { - const isLast = index === messages.length - 1; - const isUser = message.role === "user"; - return ( - + { + const isLast = index === messages.length - 1; + const isUser = message.role === "user"; + const consultedAgents = isUser ? [] : messageAgents(message); + return ( + +
-
- {isUser ? ( - - {message.content} - - ) : ( + {isUser ? ( + {message.content} + ) : ( + + {consultedAgents.length > 0 && ( +
+ + Agents + + {consultedAgents.map((agent) => ( + + ))} +
+ )} + {message.trace?.length > 0 && ( + + )} {message.content} - )} -
- - ); - }} - /> - )} + {(message.actions?.length > 0 || + message.commands?.length > 0 || + message.proposals?.length > 0) && ( +
+ {message.actions?.map((action) => ( + + ))} + {message.commands?.map((command) => ( + + ))} + {message.proposals?.map((proposal) => { + const payload = proposal?.payload || {}; + const params = payload?.params || {}; + const actionCard = + params?.action_card || + payload?.action_card || + proposal?.action_card || + {}; + const actionTrace = toList( + params?.trace || + payload?.trace || + proposal?.trace || + actionCard?.trace, + ); + const proposalTrace = toList( + proposal.trace || + payload?.trace || + params?.trace, + ); + const specialistAgent = + params?.specialist_agent || + payload?.specialist_agent || + actionCard?.specialist_agent || + proposal?.specialist_agent; + const orchestratorAgent = + params?.orchestrator_agent || + payload?.orchestrator_agent || + actionCard?.orchestrator_agent || + proposal?.orchestrator_agent; + const workflowId = + proposal.workflow_id || + proposal.workflowId || + message.workflow_id; + + return ( + + handleApproveProposal( + { + ...proposal, + workflow_id: workflowId, + }, + overrides, + ) + } + onReject={() => + handleRejectProposal({ + ...proposal, + workflow_id: workflowId, + }) + } + trace={[...actionTrace, ...proposalTrace]} + /> + ); + })} +
+ )} + + )} +
+
+ ); + }} + /> {isSending && }
{/* input */} -
+
+ ), + Button: ({ children, ...props }) => ( + + ), + Input: { + TextArea: ({ autoSize, children, ...props }) => ( + + ), + }, + Space: ({ children }) =>
{children}
, + Tag: ({ children, ...props }) => {children}, + Typography: { + Text: ({ children, strong, type, ...props }) => ( + {children} + ), + }, +})); + +describe("AgentProposalCard", () => { + it("lets users click fields or tap Edit details before approval", () => { + const onApprove = jest.fn(); + const proposal = { + id: 12, + approval_status: "pending", + type: "start_training_run", + config_preset: "/project/configs/base.yaml", + image_path: "/project/subsets/image", + label_path: "/project/subsets/seg", + output_path: "/project/outputs/original", + autopick_parameters: true, + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Edit details" })); + fireEvent.change(screen.getByLabelText("Edit Output"), { + target: { value: "/project/outputs/edited" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Approve with edits" })); + + expect(onApprove).toHaveBeenCalledWith(proposal, { + output_path: "/project/outputs/edited", + }); + + cleanup(); + render(); + fireEvent.click(screen.getByText("/project/outputs/original")); + fireEvent.change(screen.getByLabelText("Edit Output"), { + target: { value: "/project/outputs/edited-direct" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Approve with edits" })); + + expect(onApprove).toHaveBeenLastCalledWith(proposal, { + output_path: "/project/outputs/edited-direct", + }); + }); + + it("exposes client-effect paths as editable approval fields", () => { + const onApprove = jest.fn(); + const proposal = { + id: 13, + approval_status: "pending", + type: "run_client_effects", + item_label: "Run inference", + client_effects: { + set_inference_output_path: "/project/outputs/inference", + }, + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Edit details" })); + fireEvent.change(screen.getByLabelText("Edit Output"), { + target: { value: "/project/outputs/inference-edited" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Approve with edits" })); + + expect(onApprove).toHaveBeenCalledWith(proposal, { + inference_output_path: "/project/outputs/inference-edited", + }); + }); + + it("disables actions after decision is recorded", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Approve" }).disabled).toBe(true); + expect(screen.getByRole("button", { name: "Reject" }).disabled).toBe(true); + expect(screen.queryByRole("button", { name: "Edit details" })).toBeNull(); + }); + + it("keeps long labels and specialist agent badges visible", () => { + render( + , + ); + + expect( + screen.getByText( + "specialist_inference_validation_and_retraining_review_pipeline", + ).className, + ).toContain("workflow-proposal-card__type-tag"); + expect(screen.getByText("Inference Specialist")).toBeTruthy(); + expect(screen.getByText("bar")).toBeTruthy(); + }); +}); diff --git a/client/src/components/chat/AgentVisuals.js b/client/src/components/chat/AgentVisuals.js new file mode 100644 index 00000000..4cd04f97 --- /dev/null +++ b/client/src/components/chat/AgentVisuals.js @@ -0,0 +1,174 @@ +import React from "react"; +import { Tag } from "antd"; +import { + BarChartOutlined, + BugOutlined, + ExperimentOutlined, + EyeOutlined, + FileDoneOutlined, + FolderOpenOutlined, + ProjectOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; + +const ICONS = { + bar_chart: BarChartOutlined, + bug: BugOutlined, + experiment: ExperimentOutlined, + eye: EyeOutlined, + file_done: FileDoneOutlined, + folder: FolderOpenOutlined, + project: ProjectOutlined, + thunderbolt: ThunderboltOutlined, +}; + +const ALLOWED_BORDER_STYLES = new Set([ + "solid", + "dotted", + "double", + "dashed", + "thick", + "dashdot", + "top", + "rail", +]); + +const normalizeAgentText = (value) => { + if (typeof value !== "string") return ""; + return value.trim(); +}; + +const normalizeAgentColor = (value) => { + const text = normalizeAgentText(value); + if (!text) return DEFAULT_AGENT_VISUAL.color; + return text; +}; + +const normalizeAgentIconKey = (value) => { + const text = normalizeAgentText(value); + return ICONS[text] ? text : DEFAULT_AGENT_VISUAL.icon_key; +}; + +const normalizeBorderStyle = (value) => { + const text = normalizeAgentText(value).toLowerCase(); + return ALLOWED_BORDER_STYLES.has(text) + ? text + : DEFAULT_AGENT_VISUAL.border_style; +}; + +export const DEFAULT_AGENT_VISUAL = { + label: "Project Manager", + shortLabel: "PM", + color: "#111827", + icon_key: "project", + border_style: "solid", +}; + +export function getAgentVisual(agent = {}) { + const explicitLabel = + normalizeAgentText(agent.agent_label) || normalizeAgentText(agent.label); + const shortLabel = + normalizeAgentText(agent.agent_short_label) || + normalizeAgentText(agent.short_label) || + normalizeAgentText(agent.shortLabel); + + return { + label: explicitLabel || DEFAULT_AGENT_VISUAL.label, + shortLabel: shortLabel || explicitLabel || DEFAULT_AGENT_VISUAL.shortLabel, + color: normalizeAgentColor(agent.agent_color || agent.color), + iconKey: normalizeAgentIconKey(agent.agent_icon_key || agent.icon_key), + borderStyle: normalizeBorderStyle( + agent.agent_border_style || agent.border_style, + ), + }; +} + +export function getAgentBorderStyles(borderStyle, color, width = 4) { + const accent = color || DEFAULT_AGENT_VISUAL.color; + const base = { + borderLeft: `${width}px solid ${accent}`, + }; + + switch (borderStyle) { + case "dotted": + return { borderLeft: `${width}px dotted ${accent}` }; + case "double": + return { borderLeft: `${Math.max(width + 1, 5)}px double ${accent}` }; + case "dashed": + return { borderLeft: `${width}px dashed ${accent}` }; + case "thick": + return { borderLeft: `${Math.max(width + 2, 6)}px solid ${accent}` }; + case "dashdot": + return { + borderLeft: `${width}px solid ${accent}`, + borderTop: `2px dashed ${accent}`, + }; + case "top": + return { + borderLeft: `${width}px solid ${accent}`, + borderTop: `3px solid ${accent}`, + }; + case "rail": + return { + borderLeft: `${width}px solid ${accent}`, + boxShadow: `inset ${width + 2}px 0 0 rgba(0, 166, 166, 0.14)`, + }; + default: + return base; + } +} + +function AgentIcon({ iconKey }) { + const Icon = ICONS[iconKey] || ICONS.project; + return
) : type === "folder" ? ( - + ) : ( ); @@ -1179,8 +1957,12 @@ function FilesManager() { textAlign: viewMode === "grid" ? "center" : "left", cursor: "pointer", borderRadius: 4, - backgroundColor: isSelected ? "#e6f7ff" : "transparent", - border: isSelected ? "1px solid #1890ff" : "1px solid transparent", + backgroundColor: isSelected + ? "var(--seg-selection-fill, rgba(63, 55, 201, 0.12))" + : "transparent", + border: isSelected + ? "1px solid var(--seg-accent-primary, #3f37c9)" + : "1px solid transparent", display: viewMode === "list" ? "flex" : "block", alignItems: "center", userSelect: "none", @@ -1229,9 +2011,10 @@ function FilesManager() { padding: "2px 6px", fontSize: 10, borderRadius: 10, - background: "#f0f5ff", - color: "#2f54eb", - border: "1px solid #adc6ff", + background: "var(--seg-accent-primary-soft, #f0efff)", + color: "var(--seg-accent-primary, #3f37c9)", + border: + "1px solid var(--seg-accent-primary-border, #c7c2ff)", }} > Mounted @@ -1253,7 +2036,7 @@ function FilesManager() { margin: viewMode === "grid" ? 8 : 0, textAlign: viewMode === "grid" ? "center" : "left", borderRadius: 4, - border: "1px solid #1890ff", + border: "1px solid var(--seg-accent-primary, #3f37c9)", display: viewMode === "list" ? "flex" : "block", alignItems: "center", }} @@ -1270,7 +2053,7 @@ function FilesManager() {
@@ -1298,88 +2081,1598 @@ function FilesManager() { const currentFolders = folders.filter((f) => f.parent === currentFolder); const currentFiles = files[currentFolder] || []; - const getContextMenuItems = () => { - if (contextMenu?.type === "container") { - return [ - { key: "new_folder", label: "Create Folder", icon: }, - { key: "upload", label: "Upload File...", icon: }, - { - key: "mount_project", - label: "Mount Project Directory...", - icon: , + const readStoredProjectMemory = async (directoryPath) => { + if (!directoryPath) return null; + try { + const response = await apiClient.get("/files/project-context", { + params: { directory_path: directoryPath }, + }); + return response?.data?.profile || null; + } catch (error) { + logClientEvent("project_context_profile_read_failed", { + level: "WARN", + source: "files_manager", + message: "Stored project context could not be read", + data: { + directoryPath, + error: error?.message || String(error), }, - ]; - } - const items = []; - const selectedKey = selectedItems.length === 1 ? selectedItems[0] : null; - const selectedFolder = selectedKey - ? folders.find((f) => f.key === selectedKey) - : null; - const isMountedProjectFolder = Boolean( - selectedFolder && - selectedFolder.parent === "root" && - selectedFolder.physical_path, - ); - // Only show preview for files, not folders - if (selectedItems.length === 1) { - const isFolder = folders.some((f) => f.key === selectedKey); - if (!isFolder) { - items.push({ key: "preview", label: "Preview", icon: }); - } - } - if (isMountedProjectFolder) { - items.push({ - key: "unmount_project", - label: "Unmount Project", - danger: true, - icon: , }); + return null; } - if (selectedItems.length === 1) { - const selectedFile = Object.values(files) - .flat() - .find((f) => f.key === selectedKey); - const selectedFolder = folders.find((f) => f.key === selectedKey); - const hasPhysicalPath = - (selectedFile && - selectedFile.physical_path && - selectedFile.physical_path.startsWith("/")) || - (selectedFolder && - selectedFolder.physical_path && - selectedFolder.physical_path.startsWith("/")); - if (hasPhysicalPath) { - items.push({ - key: "reveal_in_finder", - label: "Open in Finder", - icon: , - }); - } - } - items.push( - { - key: "rename", - label: "Rename", - icon: , - disabled: selectedItems.length > 1, - }, + }; + + const openProjectSetupConfirmation = async (suggestion, source) => { + const roles = toProjectRelativeRoles( + suggestion?.directory_path, + getProjectRoleDefaultsFromSuggestion(suggestion), + ); + const volumeSets = getProjectVolumeSetsFromSuggestion(suggestion); + const projectAudit = getProjectAuditFromSuggestion(suggestion); + const storedProjectMemory = await readStoredProjectMemory( + suggestion?.directory_path, + ); + const storedSemanticContext = storedProjectMemory?.semantic_context || {}; + const storedDescription = + storedSemanticContext.freeform_note || + storedProjectMemory?.project_description || + ""; + const projectContextDefaults = + getProjectContextDefaultsFromSuggestion(suggestion); + const initialProjectContextDraft = getProjectContextDraftWithSharedFacts( { - key: "copy", - label: `Copy${selectedItems.length > 1 ? ` (${selectedItems.length})` : ""}`, - icon: , + suggestion, }, + storedDescription, { - key: "delete", - label: `Delete${selectedItems.length > 1 ? ` (${selectedItems.length})` : ""}`, - danger: true, - icon: , + ...projectContextDefaults, + ...storedSemanticContext, }, - { type: "divider" }, - { key: "properties", label: "Properties", icon: }, ); - return items; + const missingInitialContextFields = editableProjectContextMissingFields( + initialProjectContextDraft, + ); + const startsWithMapping = + missingInitialContextFields.length === 0 && Boolean(roles.image); + setPendingProjectSetup({ + source, + suggestion, + roles, + setupStep: startsWithMapping ? "mapping" : "semantic", + projectDescription: storedDescription, + projectDescriptionDraft: storedDescription, + projectContextDraft: initialProjectContextDraft, + projectContextDefaults, + projectAudit, + useContextDefaults: + startsWithMapping || Boolean(storedSemanticContext.use_defaults), + semanticFeedbackTurns: [], + lastSemanticResponse: storedDescription + ? "I found saved project context. Review it before continuing." + : startsWithMapping + ? "I filled the project basics from the files I checked." + : "", + mappingFeedback: "", + feedbackTurns: [], + lastFeedbackResponse: "", + storedProjectMemory, + }); + logClientEvent("project_role_confirmation_opened", { + source: "files_manager", + message: "Project role confirmation opened", + data: { + setupSource: source, + suggestionId: suggestion?.id || null, + projectName: suggestion?.name || null, + profileMode: suggestion?.profile?.schema?.mode || null, + volumeSetCount: volumeSets.length, + auditSummary: projectAudit?.summary || null, + projectContextDefaults, + missingInitialContextFields, + skippedSemanticSetup: startsWithMapping, + hasImage: Boolean(roles.image), + hasLabel: Boolean(roles.label), + hasPrediction: Boolean(roles.prediction), + hasCheckpoint: Boolean(roles.checkpoint), + hasStoredProjectMemory: Boolean(storedProjectMemory), + }, + }); }; - const handleUploadClick = () => { + const setPendingProjectDescription = (nextValue) => { + setPendingProjectSetup((current) => { + if (!current) return current; + return { + ...current, + projectDescriptionDraft: nextValue, + useContextDefaults: false, + }; + }); + }; + + const setPendingMappingFeedback = (nextValue) => { + setPendingProjectSetup((current) => { + if (!current) return current; + return { + ...current, + mappingFeedback: nextValue, + }; + }); + }; + + const setPendingRoleValue = (roleKey, nextValue) => { + setPendingProjectSetup((current) => { + if (!current) return current; + return { + ...current, + roles: { + ...current.roles, + [roleKey]: nextValue, + }, + }; + }); + }; + + const setPendingProjectContextValue = (contextKey, nextValue) => { + setPendingProjectSetup((current) => { + if (!current) return current; + const currentDraft = current.projectContextDraft || {}; + return { + ...current, + projectContextDraft: { + ...currentDraft, + [contextKey]: nextValue, + ...(contextKey === "voxel_size_nm" + ? { voxel_size_source: "project_setup_confirmation" } + : {}), + }, + }; + }); + }; + + const setPendingSetupStep = (setupStep) => { + setPendingProjectSetup((current) => + current ? { ...current, setupStep } : current, + ); + }; + + const getPendingProjectContextDefaults = (setup) => + setup?.projectContextDefaults || + getProjectContextDefaultsFromSuggestion(setup?.suggestion); + + const handleProjectContextContinue = (options = {}) => { + const setupSnapshot = pendingProjectSetupRef.current || pendingProjectSetup; + if (!setupSnapshot) return; + const useDefaults = Boolean(options.useDefaults); + const currentAnswer = useDefaults + ? "" + : String(setupSnapshot.projectDescriptionDraft || "").trim(); + const combinedProjectDescription = appendProjectContextAnswer( + setupSnapshot.projectDescription, + currentAnswer, + ); + const hasProjectDescription = Boolean(combinedProjectDescription); + const defaultContext = getProjectContextDraftWithSharedFacts( + setupSnapshot, + combinedProjectDescription, + getPendingProjectContextDefaults(setupSnapshot), + ); + const draftContext = getProjectContextDraftWithSharedFacts( + setupSnapshot, + combinedProjectDescription, + setupSnapshot.projectContextDraft, + ); + const draftMissing = editableProjectContextMissingFields(draftContext); + const hasCompleteDraft = draftMissing.length === 0; + let assessment = evaluateProjectContextCompleteness( + combinedProjectDescription, + { + useDefaults: useDefaults || setupSnapshot.useContextDefaults, + defaultContext: hasCompleteDraft ? draftContext : defaultContext, + }, + ); + if (hasCompleteDraft) { + assessment = { + ...assessment, + complete: true, + context: { + ...assessment.context, + ...draftContext, + ...(useDefaults || setupSnapshot.useContextDefaults + ? { use_defaults: true } + : {}), + }, + known: { + ...(assessment.known || {}), + ...draftContext, + }, + missing: [], + next_question: "", + }; + } + if (!useDefaults && !hasProjectDescription && !hasCompleteDraft) { + message.warning("Fill the project basics or add a note."); + return; + } + const response = describeProjectContextAssessment(assessment); + const shouldAdvance = + assessment.complete || useDefaults || setupSnapshot.useContextDefaults; + const turn = { + user: currentAnswer, + agent: response, + missing: assessment.missing, + complete: assessment.complete, + use_defaults: useDefaults || setupSnapshot.useContextDefaults, + accumulated_context: combinedProjectDescription, + timestamp: new Date().toISOString(), + }; + + logClientEvent("project_context_reviewed", { + source: "files_manager", + message: "Project context reviewed before mapping confirmation", + data: { + suggestionId: setupSnapshot.suggestion?.id || null, + projectName: setupSnapshot.suggestion?.name || null, + complete: assessment.complete, + missing: assessment.missing, + nextQuestion: assessment.next_question, + context: assessment.context, + accumulatedContextPreview: combinedProjectDescription.slice(0, 400), + useDefaults: useDefaults || setupSnapshot.useContextDefaults, + }, + }); + + setPendingProjectSetup((current) => { + if (!current) return current; + return { + ...current, + projectDescription: combinedProjectDescription, + projectDescriptionDraft: "", + projectContextDraft: cleanEditableProjectContext( + assessment.context, + combinedProjectDescription, + ), + setupStep: shouldAdvance ? "mapping" : "semantic", + useContextDefaults: useDefaults || current.useContextDefaults, + semanticFeedbackTurns: [...(current.semanticFeedbackTurns || []), turn], + lastSemanticResponse: shouldAdvance ? "" : response, + }; + }); + }; + + const handleProjectSetupFeedbackSubmit = async () => { + const setupSnapshot = pendingProjectSetupRef.current || pendingProjectSetup; + if (!setupSnapshot) return; + const feedback = String(setupSnapshot.mappingFeedback || "").trim(); + if (!feedback) return; + + const result = buildProjectSetupFeedbackResult(setupSnapshot); + const turn = { + user: result.feedback, + agent: result.response, + applied_changes: result.appliedChanges, + timestamp: new Date().toISOString(), + }; + + setProjectSetupFeedbackSaving(true); + setPendingProjectSetup((current) => { + if (!current) return current; + return { + ...current, + roles: result.nextRoles, + mappingFeedback: "", + feedbackTurns: [...(current.feedbackTurns || []), turn], + lastFeedbackResponse: result.response, + }; + }); + pendingProjectSetupRef.current = { + ...setupSnapshot, + roles: result.nextRoles, + mappingFeedback: "", + feedbackTurns: [...(setupSnapshot.feedbackTurns || []), turn], + lastFeedbackResponse: result.response, + }; + + logClientEvent("project_role_confirmation_feedback_submitted", { + source: "files_manager", + message: "Project setup feedback submitted", + data: { + setupSource: setupSnapshot.source, + suggestionId: setupSnapshot.suggestion?.id || null, + projectName: setupSnapshot.suggestion?.name || null, + feedback: result.feedback, + response: result.response, + appliedChanges: result.appliedChanges, + }, + }); + + Promise.resolve( + workflowContext.appendEvent?.({ + actor: "user", + event_type: "dataset.setup_feedback", + stage: workflowContext.workflow?.stage || "setup", + summary: "Project setup feedback reviewed before confirmation.", + payload: { + source: "file_management_project_confirmation", + setup_source: setupSnapshot.source, + suggestion_id: setupSnapshot.suggestion?.id || null, + directory_path: setupSnapshot.suggestion?.directory_path || null, + feedback: result.feedback, + agent_response: result.response, + applied_changes: result.appliedChanges, + roles_after_feedback: result.nextRoles, + }, + }), + ).finally(() => setProjectSetupFeedbackSaving(false)); + }; + + const closeProjectSetupConfirmation = () => { + setPendingProjectSetup(null); + setRolePickerTarget(null); + }; + + const handleRolePickerSelect = (item) => { + if (!rolePickerTarget) return; + const rootPath = pendingProjectSetup?.suggestion?.directory_path || ""; + setPendingRoleValue( + rolePickerTarget, + stripProjectRoot(rootPath, getSelectedFilePath(item)), + ); + setRolePickerTarget(null); + }; + + const handleConfirmProjectSetup = async () => { + if (!pendingProjectSetup || projectSetupSaving) return; + const { suggestion, roles, source, projectDescription } = + pendingProjectSetup; + if (!String(roles.image || "").trim()) { + message.warning("Choose an image volume before starting the project."); + return; + } + if (!workflowContext?.updateWorkflow) { + message.warning("Workflow state is not ready yet."); + return; + } + + const rootPath = suggestion?.directory_path || ""; + const profile = suggestion?.profile || {}; + const volumeSets = getProjectVolumeSetsFromSuggestion(suggestion); + const feedbackTurns = pendingProjectSetup.feedbackTurns || []; + const semanticFeedbackTurns = + pendingProjectSetup.semanticFeedbackTurns || []; + const projectDescriptionText = String(projectDescription || "").trim(); + const existingMetadata = workflowContext.workflow?.metadata || {}; + const projectContextMetadata = buildProjectContextMetadata( + projectDescriptionText, + existingMetadata, + { + useDefaults: pendingProjectSetup.useContextDefaults, + defaultContext: getPendingProjectContextDefaults(pendingProjectSetup), + projectContextDraft: pendingProjectSetup.projectContextDraft, + }, + ); + const contextAssessment = evaluateProjectContextCompleteness( + projectDescriptionText, + { + useDefaults: pendingProjectSetup.useContextDefaults, + defaultContext: getPendingProjectContextDefaults(pendingProjectSetup), + }, + ); + const projectBrief = buildProjectBrief({ + context: projectContextMetadata, + roles, + suggestion, + volumeSets, + }); + if (!contextAssessment.complete && !projectDescriptionText) { + setPendingProjectSetup((current) => + current + ? { + ...current, + setupStep: "semantic", + lastSemanticResponse: + describeProjectContextAssessment(contextAssessment), + } + : current, + ); + message.warning("Describe the project or use defaults."); + return; + } + const workflowPatch = buildWorkflowPatchFromConfirmedProjectRoles({ + rootPath, + roles, + metadata: projectContextMetadata + ? { + ...existingMetadata, + project_context: projectContextMetadata, + } + : null, + }); + const patch = workflowPatch; + if (!patch.image_path) { + message.warning("Choose an image volume before starting the project."); + return; + } + + setProjectSetupSaving(true); + try { + await workflowContext.updateWorkflow(workflowPatch); + const projectMemoryProfile = buildProjectMemoryProfile({ + suggestion, + roles, + workflowPatch, + projectDescription: projectDescriptionText, + projectContext: projectContextMetadata, + projectBrief, + projectAudit: pendingProjectSetup.projectAudit, + semanticTurns: semanticFeedbackTurns, + mappingTurns: feedbackTurns, + source, + existingProfile: pendingProjectSetup.storedProjectMemory, + }); + try { + await apiClient.put("/files/project-context", { + directory_path: rootPath, + profile: projectMemoryProfile, + }); + } catch (storageError) { + console.warn("Failed to persist project context profile", storageError); + logClientEvent("project_context_profile_write_failed", { + level: "WARN", + source: "files_manager", + message: "Project context profile could not be written", + data: { + suggestionId: suggestion?.id || null, + projectName: suggestion?.name || null, + directoryPath: rootPath, + error: storageError?.message || String(storageError), + }, + }); + } + await workflowContext.appendEvent?.({ + actor: "user", + event_type: "dataset.loaded", + stage: workflowContext.workflow?.stage || "setup", + summary: `Confirmed project data for ${suggestion.name}.`, + payload: { + source: "file_management_project_confirmation", + setup_source: source, + suggestion_id: suggestion.id, + directory_path: rootPath, + mounted_root_id: suggestion.mounted_root_id || null, + profile_mode: suggestion.profile?.schema?.mode || null, + profile_schema_version: + suggestion.profile?.schema?.schema_version || null, + project_description: projectDescriptionText, + project_context: projectContextMetadata, + project_brief: projectBrief, + project_audit: pendingProjectSetup.projectAudit || null, + context_facts: pendingProjectSetup.projectAudit?.context_facts || [], + semantic_context_assessment: contextAssessment, + semantic_context_turns: semanticFeedbackTurns, + setup_feedback_turns: feedbackTurns, + detected_role_counts: profile.counts || {}, + detected_role_directories: + profile.role_directories || profile.schema?.role_directories || {}, + detected_volume_sets: volumeSets, + confirmed_roles: roles, + workflow_patch: workflowPatch, + }, + }); + await Promise.allSettled([ + workflowContext.refreshPreflight?.(), + workflowContext.refreshAgentRecommendation?.(), + ]); + logClientEvent("project_role_confirmation_completed", { + source: "files_manager", + message: "Project roles confirmed and registered", + data: { + setupSource: source, + suggestionId: suggestion?.id || null, + projectName: suggestion?.name || null, + projectDescription: projectDescriptionText, + projectContext: projectContextMetadata, + projectBrief, + contextComplete: contextAssessment.complete, + workflowPatchKeys: Object.keys(workflowPatch), + }, + }); + message.success("Project data confirmed."); + closeProjectSetupConfirmation(); + } catch (error) { + console.warn("Failed to register confirmed project roles", error); + logClientEvent("project_role_confirmation_failed", { + level: "ERROR", + source: "files_manager", + message: error.message || "Project role confirmation failed", + data: { + suggestionId: suggestion?.id || null, + projectName: suggestion?.name || null, + }, + }); + message.error("Failed to register project data."); + } finally { + setProjectSetupSaving(false); + } + }; + + const renderDetectedProjectStructure = () => { + if (!pendingProjectSetup) return null; + const suggestion = pendingProjectSetup.suggestion || {}; + const profile = suggestion.profile || {}; + const volumeSets = getProjectVolumeSetsFromSuggestion(suggestion); + const roleDirectories = + profile.role_directories || profile.schema?.role_directories || {}; + const roleSummaries = ["image", "label", "prediction"] + .map((role) => { + const directory = roleDirectories[role]?.[0]; + if (!directory) return null; + return { + role, + label: + role === "image" + ? "Images" + : role === "label" + ? "Labels" + : "Predictions", + ...directory, + }; + }) + .filter(Boolean); + + const activeRoles = pendingProjectSetup.roles || {}; + const chosenImage = activeRoles.image ? basename(activeRoles.image) : ""; + const chosenLabel = activeRoles.label ? basename(activeRoles.label) : ""; + let detectedSentence = ""; + + if (volumeSets.length > 0) { + const shownSets = volumeSets.slice(0, 3).map((set) => { + const setName = set.name || basename(set.image_root) || "batch"; + const labelText = set.label_root + ? ` and ${pluralize(set.label_count || 0, "label")}` + : ""; + const pairText = set.pair_count + ? `; ${pluralize(set.pair_count, "matched pair")}` + : ""; + return `${setName} (${pluralize(set.image_count || 0, "image")}${labelText}${pairText})`; + }); + detectedSentence = `I found ${pluralize( + volumeSets.length, + "candidate image set", + )}: ${shownSets.join(", ")}${ + volumeSets.length > shownSets.length ? ", and more" : "" + }. I will start with ${ + chosenImage || "the selected image data" + }${chosenLabel ? ` and ${chosenLabel}` : ""}. Is that correct?`; + } else if (roleSummaries.length > 0) { + const summaryText = roleSummaries + .map((item) => `${item.label.toLowerCase()} in ${basename(item.path)}`) + .join(", "); + detectedSentence = `I found ${summaryText}. I will use ${ + chosenImage || "the selected image data" + }${chosenLabel ? ` with ${chosenLabel}` : ""}. Is that correct?`; + } else { + detectedSentence = + "I found a workable project, but I need you to confirm the image data before I use it. Is this mapping correct?"; + } + + return ( +
+
+ What I found +
+
+ {detectedSentence} +
+
+ ); + }; + + const renderProjectRoleFields = () => ( +
+ {PROJECT_CONFIRMATION_ROLES.map((role) => ( +
+
+ {role.label} + {role.required ? ( + * + ) : null} + {role.provenanceOnly ? ( + + optional + + ) : null} +
+ + setPendingRoleValue(role.key, event.target.value) + } + /> + + +
+ ))} +
+ ); + + const renderEditableProjectContextFields = (options = {}) => { + const { + title = "Editable project context", + description = "These are the concrete assumptions the agent absorbed. Edit or clear each one before starting.", + includeNotes = true, + marginBottom = 12, + } = options; + const draft = pendingProjectSetup.projectContextDraft || {}; + return ( +
+
+ {title} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+ {EDITABLE_PROJECT_CONTEXT_FIELDS.map((field) => ( +
+
{field.label}
+ + setPendingProjectContextValue(field.key, event.target.value) + } + /> + +
+ ))} + {includeNotes ? ( +
+
Notes
+ + setPendingProjectContextValue( + "freeform_note", + event.target.value, + ) + } + /> +
+ ) : null} +
+
+ ); + }; + + const renderProjectAuditPanel = () => { + const audit = pendingProjectSetup?.projectAudit; + if (!audit) return null; + const summary = audit.summary || {}; + const facts = (audit.context_facts || []) + .map((fact) => ({ + ...fact, + displayValue: formatAuditFactValue(fact), + })) + .filter((fact) => fact.displayValue) + .slice(0, 4); + const findings = (audit.findings || []).slice(0, 5); + const checkedText = [ + summary.audited_volumes + ? `${summary.audited_volumes} volume${summary.audited_volumes === 1 ? "" : "s"}` + : "", + summary.pair_checks + ? `${summary.pair_checks} image/mask pair${summary.pair_checks === 1 ? "" : "s"}` + : "", + ] + .filter(Boolean) + .join(" and "); + + return ( +
+
+ What I checked automatically +
+
+ {checkedText + ? `I inspected ${checkedText} directly from disk.` + : "I checked project files directly from disk."}{" "} + {summary.warnings || summary.errors + ? `${summary.warnings || 0} warning(s), ${summary.errors || 0} error(s).` + : "No blocking file issues were found in the sampled files."} +
+ {facts.length > 0 && ( +
+ {facts.map((fact) => ( +
+
+ {fact.label || fact.key} +
+
+ {fact.displayValue} +
+
+ {compactProjectPath(compactAuditSource(fact.source)) || + fact.confidence || + "file metadata"} +
+
+ ))} +
+ )} + {findings.length > 0 && ( +
+ {findings.map((finding, index) => { + const tone = + auditFindingStyles[finding.level] || auditFindingStyles.info; + return ( +
+ + {finding.level || "info"} + + + {finding.message} + {finding.path ? ( + + {" "} + {compactProjectPath(finding.path)} + + ) : null} + +
+ ); + })} +
+ )} +
+ ); + }; + + const renderProjectSharedFactsPanel = () => { + if (!pendingProjectSetup) return null; + const draft = pendingProjectSetup.projectContextDraft || {}; + const volumeSplit = + draft.volume_split || + getProjectVolumeSplitFromSuggestion(pendingProjectSetup.suggestion); + const factRows = [ + { + key: "imaging_modality", + label: "Modality", + value: draft.imaging_modality || "Not set", + }, + { + key: "target_structure", + label: "Target", + value: draft.target_structure || "Not set", + }, + { + key: "voxel_size_nm", + label: "Voxel size", + value: formatEditableVoxelSize(draft.voxel_size_nm) || "Not set", + }, + { + key: "volume_split", + label: "Volume split", + value: volumeSplit || "Not set", + }, + { + key: "task_family", + label: "Task family", + value: draft.task_family || "Not set", + }, + { + key: "training_policy", + label: "Training policy", + value: draft.training_policy || "Not set", + }, + ]; + + return ( +
+
+ Shared project facts +
+
+ These are the facts the agent will use to interpret this project. Edit + or confirm them in the controls below. +
+
+ {factRows.map((fact) => ( +
+
+ {fact.label} +
+
{fact.value}
+
+ ))} +
+
+ ); + }; + + const renderDetectedDataSummary = () => { + const profile = pendingProjectSetup?.suggestion?.profile || {}; + const counts = profile.counts || {}; + const volumeSets = getProjectVolumeSetsFromSuggestion( + pendingProjectSetup?.suggestion, + ); + const bestSet = volumeSets + .slice() + .sort((a, b) => Number(b.pair_count || 0) - Number(a.pair_count || 0))[0]; + const items = [ + ["Images", counts.image || counts.volume || 0], + ["Masks", counts.label || 0], + ["Predictions", counts.prediction || 0], + ["Configs", counts.config || 0], + ["Checkpoints", counts.checkpoint || 0], + ]; + return ( +
+
+ Detected data +
+
+ {items.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ {bestSet ? ( +
+ Best detected pairing: {bestSet.name || "image + mask"} with{" "} + {bestSet.pair_count || 0} pair + {Number(bestSet.pair_count || 0) === 1 ? "" : "s"}. +
+ ) : null} +
+ ); + }; + + const renderGuidedContextConfirmations = () => { + const draft = pendingProjectSetup?.projectContextDraft || {}; + return ( +
+
+ Workflow notes +
+
+ I prefilled these from the mounted folder. Edit them in your own + words; they become durable agent context, not just UI labels. +
+
+ {GUIDED_PROJECT_CONTEXT_GROUPS.map((group) => { + const selectedValue = String(draft[group.key] || ""); + return ( +
+
+ + {group.label} + + + {group.helper} + +
+
+ + setPendingProjectContextValue( + group.key, + event.target.value, + ) + } + /> + +
+
+ ); + })} +
+
+ ); + }; + + const renderProjectSetupSemanticStep = () => { + const draft = pendingProjectSetup.projectContextDraft || {}; + const missingFields = editableProjectContextMissingFields(draft); + const missingText = missingFields.length + ? `Still needed: ${missingFields.join(", ")}.` + : "The basics are filled. You can continue or add an optional note."; + return ( +
+
+ I checked the folder first. Confirm what I found, then fill only the + basics I could not infer. +
+ {renderDetectedDataSummary()} + {renderProjectAuditPanel()} + {renderProjectSharedFactsPanel()} + {renderGuidedContextConfirmations()} + {renderEditableProjectContextFields({ + title: "Project basics", + description: + "These three fields give the agent enough context to choose sensible workflow defaults.", + includeNotes: false, + })} + {pendingProjectSetup.lastSemanticResponse && ( +
+ {pendingProjectSetup.lastSemanticResponse} +
+ )} +
+ Anything else I should know? +
+ setPendingProjectDescription(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleProjectContextContinue(); + } + }} + style={{ marginBottom: 0 }} + /> +
+ {missingText} +
+
+ ); + }; + + const renderProjectBriefCard = () => { + const projectContext = buildProjectContextMetadata( + pendingProjectSetup.projectDescription, + workflowContext.workflow?.metadata || {}, + { + useDefaults: pendingProjectSetup.useContextDefaults, + defaultContext: getPendingProjectContextDefaults(pendingProjectSetup), + projectContextDraft: pendingProjectSetup.projectContextDraft, + }, + ); + const projectBrief = buildProjectBrief({ + context: projectContext, + roles: pendingProjectSetup.roles, + suggestion: pendingProjectSetup.suggestion, + volumeSets: getProjectVolumeSetsFromSuggestion( + pendingProjectSetup.suggestion, + ), + }); + return ( +
+
+ Project brief +
+
+ {projectBrief.summary} +
+ {projectBrief.fields.length > 0 && ( +
+ {projectBrief.fields.map((field) => ( +
+
+ {field.label} +
+
+ {String(field.value)} +
+
+ ))} +
+ )} + {projectBrief.next_moves.length > 0 && ( +
+
+ How the agent will use this +
+
    + {projectBrief.next_moves.slice(0, 6).map((move) => ( +
  • {move}
  • + ))} +
+
+ )} + {projectBrief.uncertainties.length > 0 && ( +
+ {projectBrief.uncertainties[0]} +
+ )} +
+ ); + }; + + const renderProjectSetupMappingStep = () => ( +
+
+ Confirm the project context and file mapping before starting. +
+ {renderDetectedProjectStructure()} + {renderDetectedDataSummary()} + {renderProjectAuditPanel()} + {renderProjectSharedFactsPanel()} + {renderGuidedContextConfirmations()} + {renderEditableProjectContextFields()} + {renderProjectBriefCard()} +
+ Correct my file mapping +
+ setPendingMappingFeedback(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleProjectSetupFeedbackSubmit(); + } + }} + style={{ marginBottom: 8 }} + /> +
+ + Press Enter to ask/apply a mapping correction. Shift+Enter adds a + line. + + +
+ {pendingProjectSetup.lastFeedbackResponse && ( +
+ {pendingProjectSetup.lastFeedbackResponse} +
+ )} + {renderProjectRoleFields()} +
+ ); + + const renderProjectSetupBody = () => { + if (!pendingProjectSetup) return null; + if (pendingProjectSetup.setupStep === "mapping") { + return renderProjectSetupMappingStep(); + } + return renderProjectSetupSemanticStep(); + }; + + const renderProjectSetupFooter = () => { + if (!pendingProjectSetup) return null; + const step = pendingProjectSetup.setupStep || "semantic"; + const footer = [ + , + ]; + if (step !== "semantic") { + footer.push( + , + ); + } + if (step === "semantic") { + const hasDraft = Boolean( + String(pendingProjectSetup.projectDescriptionDraft || "").trim(), + ); + const hasReviewableContext = Boolean( + String(pendingProjectSetup.projectDescription || "").trim() && + !pendingProjectSetup.lastSemanticResponse, + ); + const hasCompleteDraft = + editableProjectContextMissingFields( + pendingProjectSetup.projectContextDraft || {}, + ).length === 0; + footer.push( + , + , + ); + return footer; + } + if (step === "mapping") { + footer.push( + , + ); + return footer; + } + footer.push( + , + ); + return footer; + }; + + const renderProjectInitializationEmptyState = () => { + if (currentFolder !== "root") return null; + if (currentFolders.length > 0 || currentFiles.length > 0 || newItemType) { + return null; + } + return ( +
+
+
+ Start a segmentation project +
+
+ Choose a project directory. I’ll ask what the dataset is, inspect + the folder, then ask you to confirm the image and mask mapping + before anything enters workflow state. +
+
+ + +
+
+
+ ); + }; + + const getContextMenuItems = () => { + if (contextMenu?.type === "container") { + return [ + { key: "new_folder", label: "Create Folder", icon: }, + { key: "upload", label: "Upload File...", icon: }, + { + key: "mount_project", + label: "Mount Project Directory...", + icon: , + }, + ]; + } + const items = []; + const selectedKey = selectedItems.length === 1 ? selectedItems[0] : null; + const selectedFolder = selectedKey + ? folders.find((f) => f.key === selectedKey) + : null; + const isMountedProjectFolder = Boolean( + selectedFolder && + selectedFolder.parent === "root" && + selectedFolder.physical_path, + ); + // Only show preview for files, not folders + if (selectedItems.length === 1) { + const isFolder = folders.some((f) => f.key === selectedKey); + if (!isFolder) { + items.push({ key: "preview", label: "Preview", icon: }); + } + } + if (isMountedProjectFolder) { + items.push({ + key: "unmount_project", + label: "Unmount Project", + danger: true, + icon: , + }); + } + if (selectedItems.length === 1) { + const selectedFile = Object.values(files) + .flat() + .find((f) => f.key === selectedKey); + const selectedFolder = folders.find((f) => f.key === selectedKey); + const hasPhysicalPath = + (selectedFile && + selectedFile.physical_path && + selectedFile.physical_path.startsWith("/")) || + (selectedFolder && + selectedFolder.physical_path && + selectedFolder.physical_path.startsWith("/")); + if (hasPhysicalPath) { + items.push({ + key: "reveal_in_finder", + label: "Open in Finder", + icon: , + }); + } + } + items.push( + { + key: "rename", + label: "Rename", + icon: , + disabled: selectedItems.length > 1, + }, + { + key: "copy", + label: `Copy${selectedItems.length > 1 ? ` (${selectedItems.length})` : ""}`, + icon: , + }, + { + key: "delete", + label: `Delete${selectedItems.length > 1 ? ` (${selectedItems.length})` : ""}`, + danger: true, + icon: , + }, + { type: "divider" }, + { key: "properties", label: "Properties", icon: }, + ); + return items; + }; + + const handleUploadClick = () => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; @@ -1416,37 +3709,228 @@ function FilesManager() { input.click(); }; + const mountProjectPath = async (directoryPath, source = "manual_mount") => { + const mountPath = String(directoryPath || "").trim(); + if (!mountPath) return; + + const res = await apiClient.post( + "/files/mount", + { + directory_path: mountPath, + destination_path: "root", + }, + { withCredentials: true }, + ); + + await fetchFolderContents("root", { force: true }); + await loadProjectSuggestions(); + if (res?.data?.mounted_root_id) { + const mountedRootId = String(res.data.mounted_root_id); + await fetchFolderContents(mountedRootId, { force: true }); + handleNavigate(mountedRootId); + } + await openProjectSetupConfirmation( + { + id: res?.data?.mounted_root_id + ? `mounted-${res.data.mounted_root_id}` + : "mounted-project", + name: + res?.data?.mount_name || + mountPath.split(/[\\/]/).filter(Boolean).pop() || + "Mounted project", + directory_path: res?.data?.directory_path || mountPath, + already_mounted: true, + mounted_root_id: res?.data?.mounted_root_id || null, + profile: res?.data?.profile || {}, + }, + source, + ); + message.success(res?.data?.message || "Project mounted."); + }; + const handleMountProjectDirectory = async () => { try { + if (!canOpenLocalFile()) { + await mountProjectPath( + DEFAULT_REMOTE_PROJECT_PATH, + "remote_default_mount", + ); + return; + } const selectedDirectory = await openLocalFile({ properties: ["openDirectory"], }); if (!selectedDirectory) return; - // Mount builds a stable project index now; later this same flow can point to - // cloud-backed project storage while keeping picker + workflow behavior unchanged. + await mountProjectPath(selectedDirectory, "manual_mount"); + } catch (err) { + console.error("Mount directory error", err); + if (err?.message !== "Project path required") { + message.error("Failed to mount project"); + } + } + }; + + const handleSuggestedProject = async (preferredSuggestion = null) => { + const explicitSuggestion = + preferredSuggestion && + typeof preferredSuggestion === "object" && + preferredSuggestion.directory_path + ? preferredSuggestion + : null; + const suggestion = + explicitSuggestion || + projectSuggestions.find((item) => item.recommended) || + projectSuggestions[0]; + if (!suggestion) { + message.info("No suggested local project is available."); + return; + } + if (suggestion.already_mounted && suggestion.mounted_root_id) { + const mountedRootId = String(suggestion.mounted_root_id); + await fetchFolderContents(mountedRootId, { force: true }); + handleNavigate(mountedRootId); + await openProjectSetupConfirmation(suggestion, "suggested_existing"); + message.success(`${suggestion.name} is already mounted.`); + return; + } + try { const res = await apiClient.post( "/files/mount", { - directory_path: selectedDirectory, + directory_path: suggestion.directory_path, destination_path: "root", + mount_name: suggestion.name, }, { withCredentials: true }, ); - await fetchFolderContents("root", { force: true }); + await loadProjectSuggestions(); if (res?.data?.mounted_root_id) { const mountedRootId = String(res.data.mounted_root_id); await fetchFolderContents(mountedRootId, { force: true }); handleNavigate(mountedRootId); } - message.success(res?.data?.message || "Project directory mounted."); - } catch (err) { - console.error("Mount directory error", err); - message.error("Failed to mount project directory"); + await openProjectSetupConfirmation( + { + ...suggestion, + already_mounted: true, + mounted_root_id: + res?.data?.mounted_root_id || suggestion.mounted_root_id, + profile: res?.data?.profile || suggestion.profile || {}, + }, + "suggested_mount", + ); + message.success(res?.data?.message || `${suggestion.name} mounted.`); + } catch (error) { + console.error("Suggested project mount error", error); + message.error(`Failed to mount ${suggestion.name}`); + } + }; + + chooseProjectDataRef.current = async (source = "assistant_choose_data") => { + const suggestions = await loadProjectSuggestions(); + const suggestion = + suggestions.find((item) => item.recommended && item.already_mounted) || + suggestions.find((item) => item.already_mounted) || + suggestions.find((item) => item.recommended) || + suggestions[0]; + + if (suggestion) { + await handleSuggestedProject(suggestion); + return; + } + + await fetchFolderContents("root", { force: true }); + const mountedRoot = foldersRef.current.find( + (folder) => folder.parent === "root" && folder.physical_path, + ); + if (mountedRoot) { + await fetchFolderContents(mountedRoot.key, { force: true }); + handleNavigate(mountedRoot.key); + await openProjectSetupConfirmation( + { + id: `mounted-${mountedRoot.key}`, + name: mountedRoot.title, + directory_path: mountedRoot.physical_path, + already_mounted: true, + mounted_root_id: Number(mountedRoot.key), + profile: {}, + }, + source, + ); + return; } + + message.info("Mount a project directory first."); }; + const pendingRuntimeAction = workflowContext?.pendingRuntimeAction; + const consumeRuntimeAction = workflowContext?.consumeRuntimeAction; + + useEffect(() => { + const action = pendingRuntimeAction; + if (!action || action.kind !== "choose_project_data") return; + if (handledChooseDataActionRef.current === action.id) return; + handledChooseDataActionRef.current = action.id; + + const run = async () => { + logClientEvent("files_choose_project_data_started", { + source: "files_manager", + message: "Assistant requested project data selection.", + data: { actionId: action.id || null }, + }); + try { + await chooseProjectDataRef.current?.("assistant_choose_data"); + logClientEvent("files_choose_project_data_completed", { + source: "files_manager", + message: "Assistant project data selection action was opened.", + data: { actionId: action.id || null }, + }); + consumeRuntimeAction?.(action.id); + } catch (error) { + logClientEvent("files_choose_project_data_failed", { + level: "ERROR", + source: "files_manager", + message: error?.message || "Could not open project data selection.", + data: { actionId: action.id || null }, + }); + message.error("Could not open project data selection."); + consumeRuntimeAction?.(action.id); + } + }; + + run(); + }, [consumeRuntimeAction, pendingRuntimeAction]); + + useEffect(() => { + const workflowMetadata = workflowContext.workflow?.metadata || {}; + const hasProjectContext = Boolean(workflowMetadata.project_context); + const shouldCollectProjectContext = + workflowMetadata.needs_project_context || !hasProjectContext; + const suggestedProject = + projectSuggestions.find((item) => item.recommended) || + projectSuggestions[0]; + if ( + autoProjectSetupOpenedRef.current || + pendingProjectSetup || + !shouldCollectProjectContext || + !suggestedProject || + !suggestedProject.already_mounted + ) { + return; + } + autoProjectSetupOpenedRef.current = true; + handleSuggestedProject(); + // This should run once after the fixed demo project is mounted and the + // workflow has no project context yet. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + pendingProjectSetup, + projectSuggestions, + workflowContext.workflow?.metadata, + ]); + const handleResetWorkspace = () => { Modal.confirm({ title: "Reset workspace?", @@ -1766,6 +4250,19 @@ function FilesManager() { > Mount Project + } title="More file actions" />
- {/* Content Area */}
)} + {!loadingParents.includes(currentFolder) && + renderProjectInitializationEmptyState()} {!loadingParents.includes(currentFolder) && currentFolders.length === 0 && currentFiles.length === 0 && + currentFolder !== "root" && !newItemType && ( -
- -
- )} +
+ +
+ )} {currentFolders.map((f) => renderItem(f, "folder"))} {renderNewFolderPlaceholder()} {currentFiles.map((f) => renderItem(f, "file"))} @@ -1834,8 +4333,9 @@ function FilesManager() { top: Math.min(selectionBox.startY, selectionBox.currentY), width: Math.abs(selectionBox.currentX - selectionBox.startX), height: Math.abs(selectionBox.currentY - selectionBox.startY), - backgroundColor: "rgba(24, 144, 255, 0.2)", - border: "1px solid #1890ff", + backgroundColor: + "var(--seg-selection-fill, rgba(63, 55, 201, 0.12))", + border: "1px solid var(--seg-accent-primary, #3f37c9)", pointerEvents: "none", zIndex: 100, }} @@ -1901,6 +4401,35 @@ function FilesManager() {
)} + {/* Project role confirmation */} + + {renderProjectSetupBody()} + + + setRolePickerTarget(null)} + onSelect={handleRolePickerSelect} + title={`Select ${ + PROJECT_CONFIRMATION_ROLES.find( + (role) => role.key === rolePickerTarget, + )?.label || "project file" + }`} + selectionType="fileOrDirectory" + /> + {/* Preview Modal */} {propertiesData.isFolder ? ( - + ) : ( )} diff --git a/client/src/views/FilesManager.test.js b/client/src/views/FilesManager.test.js index d52f54e3..457b5ae7 100644 --- a/client/src/views/FilesManager.test.js +++ b/client/src/views/FilesManager.test.js @@ -1,9 +1,10 @@ import React from "react"; -import { render, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import FilesManager from "./FilesManager"; import { AppContext } from "../contexts/GlobalContext"; import { apiClient } from "../api"; +import { openLocalFile } from "../electronApi"; jest.mock("../api", () => ({ apiClient: { @@ -16,11 +17,51 @@ jest.mock("../api", () => ({ })); jest.mock("../electronApi", () => ({ + canOpenLocalFile: jest.fn(() => true), openLocalFile: jest.fn(), revealInFinder: jest.fn(), })); jest.mock("../components/FileTreeSidebar", () => () => null); +jest.mock( + "../components/FilePickerModal", + () => + ({ visible, onSelect, title }) => + visible ? ( +
+
{title}
+ +
+ ) : null, +); + +jest.mock("../logging/appEventLog", () => ({ + logClientEvent: jest.fn(), +})); + +const mockWorkflowContext = { + workflow: { id: 1, stage: "setup" }, + updateWorkflow: jest.fn(), + appendEvent: jest.fn(), + refreshPreflight: jest.fn(), + refreshAgentRecommendation: jest.fn(), + pendingRuntimeAction: null, + consumeRuntimeAction: jest.fn(), +}; + +jest.mock("../contexts/WorkflowContext", () => ({ + useWorkflow: () => mockWorkflowContext, +})); jest.mock("@ant-design/icons", () => { const Icon = () => ; @@ -43,9 +84,19 @@ jest.mock("@ant-design/icons", () => { jest.mock("antd", () => { const React = require("react"); - const Button = ({ children, ...props }) => ; + const Button = ({ children, icon, loading, type, ...props }) => ( + + ); const Dropdown = ({ children }) =>
{children}
; - const Input = React.forwardRef((props, ref) => ); + const Input = React.forwardRef((props, ref) => ( + + )); + Input.TextArea = React.forwardRef((props, ref) => ( +