From 17e2915883889cf618de076aa7f1689f509e43cb Mon Sep 17 00:00:00 2001 From: Petr Date: Fri, 15 May 2026 14:21:07 +0200 Subject: [PATCH 1/3] feat: lighter Drawer scrim (#286) + Run-now button on dashboard scheduled-agents tile (#292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #286 — Workspace detail overlay reported by yustme as 'looks like the left half of the screen crashed'. The Drawer scrim was bg-zinc-950/90 (90% opaque) with a stale comment claiming 70% would 'let underlying buttons bleed through and break modality'. That concern was visual, not interactive — the click-catch
blocks pointer events at the layout level regardless of scrim opacity. Vojta's read was right: 90% reads as 'broken layout', not 'modal opened'. New scrim: bg-zinc-900/50 in light mode, bg-black/70 in dark mode, both retain backdrop-blur-sm. Light-mode white background dims visibly without erasing context; dark-mode zinc-950 page needs the heavier 70% black to register against the slightly-lighter scrim. #292 — Dashboard 'Scheduled agents' tile gets an inline 'run' button per row (Agents page already had one). New ScheduledAgentRow extracted from the inline
  • map. The button fires POST /agents/{id}/run (blocking variant, same endpoint /agents/{id}/run path already used by the Agents page Run drawer for non-streaming use), then invalidates the ['agents'] and ['agent-runs', id] query keys so the row's last_run_at + status pill flip live without a manual reload. Deliberate choice: blocking call, not /run/stream + drawer. The tile is a glance-and-move-on surface; users who want to watch tool_use events in real time use the full Run drawer on Agents page (which already does the SSE attach + replay). --- web/frontend/src/components/Drawer.tsx | 11 +++-- web/frontend/src/pages/Dashboard.tsx | 64 ++++++++++++++++++++------ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/web/frontend/src/components/Drawer.tsx b/web/frontend/src/components/Drawer.tsx index 4b4a750e..30a3b952 100644 --- a/web/frontend/src/components/Drawer.tsx +++ b/web/frontend/src/components/Drawer.tsx @@ -58,11 +58,14 @@ export function Drawer({ const isTailwindClass = width.startsWith("max-w-"); const widthClass = isTailwindClass ? width : ""; const widthStyle = isTailwindClass ? undefined : { maxWidth: width }; - // Near-opaque overlay (90% black-ish) — 70% let the page content under the - // drawer bleed through enough to break modality, especially on the agent-task - // table where action buttons are right under the click-catch area. + // Semi-transparent scrim. The earlier 90% opacity looked like the left half + // of the screen had crashed (#286) — Vojta reported the page felt broken, + // not modal. Dropping to 50% (light) / 70% (dark) + blur restores the "I + // opened a modal on top" depth cue without losing modality. The click-catch + // div below is what actually blocks interaction with underlying buttons; + // opacity is purely visual signal. return createPortal( -
    +
    @@ -378,3 +368,51 @@ function SuggestedAction({ ); } + +/** + * One row in the dashboard's Scheduled agents tile (#292). The Run button + * fires the persisted task via POST /agents/{id}/run (blocking, same + * machinery as the cron trigger) and invalidates the ["agents"] query on + * completion so the row's last-run status refreshes inline. We deliberately + * use the blocking endpoint instead of the SSE /run/stream one: the tile + * is a glance-and-move-on surface, not a live progress viewer — users who + * want to watch tool_use events use the full Run drawer on the Agents page. + */ +function ScheduledAgentRow({ task }: { task: AgentTask }) { + const qc = useQueryClient(); + const runMu = useMutation({ + mutationFn: () => api.post(`/agents/${task.id}/run`, {}), + onSettled: () => { + // Refresh the tile (and any agent-runs lists on other pages) so + // last_run_at and the status pill flip from stale to current. + qc.invalidateQueries({ queryKey: ["agents"] }); + qc.invalidateQueries({ queryKey: ["agent-runs", task.id] }); + }, + }); + return ( +
  • + + {task.name} + {task.cron} + +
  • + ); +} From 17a4dc5c82b1f0f796e2f9003e848b88193cf022 Mon Sep 17 00:00:00 2001 From: Petr Date: Fri, 15 May 2026 14:56:13 +0200 Subject: [PATCH 2/3] feat: Local AI tile replaces Kai (#300), bump to 0.41.1, changelog + gotchas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the lighter Drawer scrim (#286) and dashboard Run-now button (#292) already in the previous commit; this commit adds the Local AI tile that replaces Kai on the dashboard (per the closing rationale on Backend: - POST /ai/chat/stream — third instance of the stateless-helper layer (after /agents/prompt/improve/stream and /workspaces/sql/improve/ stream). Builds a generic chat meta-prompt and forwards SSE events from stream_ai_agent_events 1:1 — no SQL-style post-processing on the done event, no fence-stripping (markdown renders verbatim). - build_local_ai_meta_prompt in agent_runner.py — the most generic of the three helper meta-prompts. No output-shape constraint, no single-task framing. Grounds the AI as a kbagent co-pilot with kbagent CLI on PATH + 'run kbagent context for command docs' pointer instead of inlining the ~70 KB skill into every request. Frontend: - pages/LocalAi.tsx — chat surface with CLI selector, abort, markdown rendering, per-message transparency panels (meta-prompt + tool_use activity log). - Dashboard hero rewired: 'Ask the local AI…' input drops the message into UIState.pendingLocalAiMessage and navigates to /localai; the Local AI page auto-fires on mount. Avoids duplicating chat plumbing on the dashboard. - Sidebar: 'Kai Chat' → 'Local AI'; page id 'kai' → 'localai'. - pages/Kai.tsx deleted (Kai BACKEND endpoints remain available for callers; only the UI surface was swapped). Architecture note: the chat helper consciously stays on layer 2 (stateless helpers) instead of layer 3 (persistent agent tasks). Multi- turn history is a v2 follow-up that can either inject prior turns into the meta-prompt (still layer 2) or migrate the chat to a persistent agent task (layer 3) — the wire is the same. Version: 0.41.0 was taken upstream by PR #293 (semantic-layer command group), so this PR bumps to 0.41.1 after rebasing on top. Changelog: new 0.41.1 entry covering Drawer scrim, Run-now button, Local AI tile + endpoint, and gotchas.md notes. Gotchas (since v0.41.1): - Kai Chat web UI is gone; replaced by Local AI tile. /kai/* backend endpoints remain available — only the nav entry / dashboard tile flipped from Kai (per-project blocking) to Local AI (cross-project SSE-streaming through claude/codex/gemini). - Dashboard's ▶ run button uses POST /agents/{id}/run (blocking) while the Agents page Run drawer uses POST /agents/{id}/run/stream (SSE with late-attach). Both persist the same AgentRun record. Tests: +15 in tests/test_local_ai_chat.py (11 meta-prompt content, 4 SSE endpoint integration with mocked stream_ai_agent_events). Total suite: 3155 passed. --- .claude-plugin/marketplace.json | 2 +- plugins/kbagent/.claude-plugin/plugin.json | 2 +- .../skills/kbagent/references/gotchas.md | 60 ++ pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 6 + src/keboola_agent_cli/server/__init__.py | 2 + src/keboola_agent_cli/server/agent_runner.py | 94 +++ .../server/routers/ai_chat.py | 129 ++++ tests/test_local_ai_chat.py | 306 ++++++++++ uv.lock | 2 +- web/frontend/src/App.tsx | 6 +- web/frontend/src/layout/Sidebar.tsx | 2 +- web/frontend/src/pages/Dashboard.tsx | 56 +- web/frontend/src/pages/Kai.tsx | 557 ------------------ web/frontend/src/pages/LocalAi.tsx | 497 ++++++++++++++++ web/frontend/src/state.tsx | 22 +- 16 files changed, 1150 insertions(+), 595 deletions(-) create mode 100644 src/keboola_agent_cli/server/routers/ai_chat.py create mode 100644 tests/test_local_ai_chat.py delete mode 100644 web/frontend/src/pages/Kai.tsx create mode 100644 web/frontend/src/pages/LocalAi.tsx diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a5743afd..604c6be8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.41.1", + "version": "0.41.9", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 8d096f0a..579b0d3d 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.41.1", + "version": "0.41.9", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 6488a9e1..8cfdbfc9 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -1,5 +1,65 @@ # Gotchas -- Response Parsing and Common Pitfalls +## Web UI `Kai Chat` is gone — replaced by `Local AI` (since v0.41.9) + +The web UI dashboard tile / left-nav entry previously labelled **Kai +Chat** has been replaced by **Local AI** (PR #301, follow-up to #291 +closed-wontfix and #288 closed-wontfix). The new tile is backed by +`POST /ai/chat/stream`, a third instance of the same stateless-helper +pattern as `POST /agents/prompt/improve/stream` and +`POST /workspaces/sql/improve/stream`. It spawns the user's local +`claude` / `codex` / `gemini` CLI with a meta-prompt grounding it as +a kbagent co-pilot. + +**Why the swap:** + +- Kai requires a **master** Storage API token. `kbagent org setup` + generates non-master tokens by default for security reasons, so any + project registered via that path had its Kai tile broken. +- Kai is per-project; cross-project work (lineage, migration assistant, + multi-project comparison) was structurally impossible inside Kai. +- The local AI uses any Storage token kbagent already has AND handles + multi-project flags natively (`--project NAME`). + +**What stays:** + +- `POST /kai/chat` and the rest of the `/kai/*` backend endpoints + remain available for HTTP callers that explicitly want Kai's + per-project session-state API. Only the dashboard UI tile + left + nav entry was swapped. `kbagent kai ping|preflight|ask|chat` + CLI commands are unchanged. + +**Implication for AI agents:** + +- If your script targets the web UI (e.g. screen-scraping or Playwright + automation), the page id changed from `kai` to `localai` in + `UIState.page` and the route from `KaiPage` to `LocalAiPage`. The + endpoint flipped from `POST /kai/chat` (blocking JSON) to + `POST /ai/chat/stream` (SSE) -- different wire protocol, different + envelope. + +## Dashboard `▶ run` button on scheduled agents uses BLOCKING `/agents/{id}/run`, NOT the SSE stream (since v0.41.9) + +The dashboard's Scheduled agents tile gained an inline `▶ run` button +per row (issue #292). It fires `POST /agents/{task_id}/run` -- the +blocking variant -- and invalidates the `['agents']` query cache on +completion so the row's `last_run_at` + status pill refresh inline. + +The Agents PAGE (`/agents`) uses a different code path: when its `▶` +button fires, it opens the Run drawer that streams via +`POST /agents/{task_id}/run/stream` (SSE with late-attach support). + +**Pick the right endpoint:** + +- Need live tool_use / token-cost / `stream-json` events as they + arrive? Use `/agents/{id}/run/stream`. +- Just need "fire and forget; tell me when it's done; let me move on"? + Use `/agents/{id}/run`. This is what the dashboard tile uses. + +Both endpoints persist the same `AgentRun` record on disk; the blocking +endpoint returns it once the run completes, the SSE endpoint streams +events and emits a final `done` SSE frame mirroring the same record. + ## Semantic-layer constraint `rule` is a STRING, not an object (since v0.41.0) - The `sl-builder` skill docs (in `04_AI_Kit/ai-kit/`) describe range diff --git a/pyproject.toml b/pyproject.toml index b033f7ac..69ad6a2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.41.1" +version = "0.41.9" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 1f4db4e4..d66a26d3 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,12 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.41.9": [ + "Fix: Workspace detail Drawer scrim (#286). The earlier 90% opacity (`bg-zinc-950/90`) read as a broken layout -- Vojta reported 'the left half of the screen looks crashed'. The original concern (clickthrough on agent-task buttons beneath the scrim) was misdiagnosed: the click-catch `
    ` blocks pointer events at the layout level regardless of scrim opacity, so the dimming is purely visual. New scrim: `bg-zinc-900/50` in light mode + `bg-black/70` in dark mode, both retain `backdrop-blur-sm`. Restores the 'I opened a modal on top' depth cue without breaking modality.", + "New: Dashboard 'Scheduled agents' tile gets an inline `run` button per row (#292). The Agents page already had it; the dashboard tile didn't. New `ScheduledAgentRow` component extracted from the inline list; the button fires `POST /agents/{id}/run` (blocking variant) and invalidates the `['agents']` and `['agent-runs', id]` query keys so `last_run_at` + status pill flip live without a manual reload. Deliberate choice of blocking-vs-SSE: the tile is a glance-and-move-on surface, not a live progress viewer -- users who want to watch tool_use events use the full Run drawer on the Agents page.", + "New: Dashboard 'Local AI' tile replaces the per-project 'Kai Chat' (#300, follow-up to #291 wontfix). New page at `/localai` is a generic chat surface backed by the user's local `claude` / `codex` / `gemini` CLI -- same `stream_ai_agent_events` machinery as the workspace SQL helper and agent prompt helper, built as the third instance of the same stateless-helper layer (`/agents/prompt/improve/stream` + `/workspaces/sql/improve/stream` + `/ai/chat/stream`). Why: Kai requires a master Storage token to work, and `kbagent org setup` produces non-master tokens by default for security reasons (#291 closed wontfix). The Local AI tile works against any Storage token kbagent already has, and -- unlike Kai -- handles cross-project work natively via `--project NAME` flags. The dashboard hero 'Ask ' input drops the typed message into `UIState.pendingLocalAiMessage` and navigates to `/localai`, which auto-fires the request on mount; full chat plumbing (CLI selector, project picker, transparency panels for the meta-prompt + tool_use activity log, markdown rendering with code blocks, abort button) lives on the dedicated page. The `build_local_ai_meta_prompt` builder is the most generic of the three helper meta-prompts: no output-shape constraint (chat renders markdown verbatim, no fence-stripping needed), no single-task framing -- just 'you are running inside kbagent serve, here is the user's question, run real commands to answer'. The kbagent skill (~70 KB of docs) is NOT inlined into every request; the AI is told to run `kbagent context` on demand instead, mirroring how Claude Code's plugin loader bootstraps the skill. Kai backend endpoints (`/kai/*`) remain available for callers that explicitly want Kai's per-project chat with API session state; only the dashboard tile / left-nav entry was swapped. The old `pages/Kai.tsx` is deleted; the new `pages/LocalAi.tsx` is feature-equivalent (single-shot for v1, multi-turn history forwarded into the prompt is the v2 follow-up). 15 unit tests cover the meta-prompt content (user message verbatim, project / branch hints, serve-URL fast-path, no output-shape contract, markdown contract) and SSE endpoint integration (init carries meta_prompt, done event flows through unmodified -- no SQL-style post-processing, error path surfaces as `done` with `status: error`).", + "Plugin docs: `plugins/kbagent/skills/kbagent/references/gotchas.md` gains `(since v0.41.9)` notes for the Local AI tile replacement (Kai backend stays but the nav entry moves) and the dashboard 'Run' button vs Agents page 'Run' button (the dashboard uses blocking `POST /agents/{id}/run`; the Agents page uses SSE `POST /agents/{id}/run/stream` with live progress + late-attach -- pick the right one for the UX). No new CLI commands; `CLAUDE.md ## All CLI Commands` unchanged.", + ], "0.41.1": [ "Fix: startup auto-update hook now preserves the optional `[server]` extras. Before this release, `kbagent serve --ui` could trigger an auto-update that ran a bare `uv tool install --upgrade git+...` (no `--with` flag), silently dropping the FastAPI + uvicorn extras a user originally installed with `--with 'keboola-agent-cli[server]'`. The next line of the same boot would then refuse to start with `ModuleNotFoundError: No module named 'fastapi'`. The fix in v0.40.2 only patched the explicit `kbagent update` command (`version_service._update_kbagent`); the startup hook in `auto_update._perform_update` was left running the old bare command. Now both paths delegate to a shared `build_kbagent_upgrade_command()` helper that probes `importlib.util.find_spec('fastapi')` and pairs `--with 'keboola-agent-cli[server]'` with `--force` when extras are detected. Two new tests pin the behavior in both directions (extras -> `--force --with`, no-extras -> plain `--upgrade`).", "Fix: `kbagent version` now persists the freshly-fetched `latest_version` (and MCP version + install method) to the auto-update cache. Before this release, `get_versions()` made a live GitHub round-trip but did NOT write the result back to `~/.config/keboola-agent-cli/version_cache.json`. The 1-hour TTL'd cache stayed pinned to whatever value the auto-update hook last wrote -- so `kbagent version` would correctly show `v0.41.0 available` while a follow-up `kbagent serve --ui` on the same machine still auto-updated to whatever stale version the cache held (e.g. 0.40.3). Combined with the extras-drop bug above, this produced the worst-case scenario reported by users: `kbagent version` says new release available, `kbagent serve --ui` upgrades to a different older release and breaks. Now `get_versions()` writes the cache (lazy-imported to avoid a circular import) at the end of every successful fetch; write failures are caught and logged at debug level so the version command never crashes on a read-only HOME / disk-full / permission edge case.", diff --git a/src/keboola_agent_cli/server/__init__.py b/src/keboola_agent_cli/server/__init__.py index f10ed25f..ebfac984 100644 --- a/src/keboola_agent_cli/server/__init__.py +++ b/src/keboola_agent_cli/server/__init__.py @@ -32,6 +32,7 @@ from .dependencies import ServiceRegistry, install_registry from .routers import ( agents, + ai_chat, branches, components, configs, @@ -220,6 +221,7 @@ async def _generic_handler(_request, exc: Exception): app.include_router(data_apps.router) app.include_router(mcp.router) app.include_router(kai.router) + app.include_router(ai_chat.router) app.include_router(encrypt.router) app.include_router(search.router) app.include_router(semantic_layer.router) diff --git a/src/keboola_agent_cli/server/agent_runner.py b/src/keboola_agent_cli/server/agent_runner.py index 69ea2f3a..37683eea 100644 --- a/src/keboola_agent_cli/server/agent_runner.py +++ b/src/keboola_agent_cli/server/agent_runner.py @@ -546,6 +546,100 @@ def clean_prompt_helper_response(text: str) -> str: return text.strip() +def build_local_ai_meta_prompt( + *, + message: str, + project: str | None = None, + branch_id: int | None = None, + serve_url: str | None = None, +) -> str: + """Compose the meta-prompt for the dashboard Local AI chat (#300). + + This is the most generic of the three helper meta-prompts in this + module: it does NOT pin an output shape (unlike SQL helper which + must emit raw SQL) and does NOT pin a task shape (unlike the agent + prompt helper which rewrites a draft). It simply tells the AI: + "you are running inside kbagent serve, you have the kbagent CLI on + PATH, here is what the user wants — answer it." + + The user's local Claude / codex / gemini install handles markdown + rendering on the UI side, so the prompt encourages markdown output + rather than the spartan output contract that the SQL / prompt + helpers enforce. + + The kbagent-skill content (workflow knowledge, gotchas, command + reference) is NOT inlined verbatim — it is ~70 KB of documentation + that would balloon every chat round trip. Instead the AI is told + to run ``kbagent context`` to load the full documentation on demand, + mirroring how Claude Code's plugin loader bootstraps the skill. + """ + message_clean = message.strip() + project_block = ( + f"- Active project: {project!r} (use `--project {project}` on `kbagent` " + "commands; multi-project commands also accept multiple `--project` flags)" + if project + else "- Active project: (none — multi-project mode. Ask the user to " + "pick one if a single-project answer is required, or use explicit " + "`--project NAME` flags / `kbagent project list` to discover)" + ) + branch_block = ( + f"- Active branch: #{branch_id} (use `--branch {branch_id}` where supported)" + if branch_id + else "- Active branch: main (production)" + ) + serve_block = ( + f"- `kbagent http get|post /...` reaches the running serve at {serve_url}. " + "Env vars `KBAGENT_SERVE_URL` + `KBAGENT_SERVE_TOKEN` are pre-set, so " + "this is the fastest path for read queries against the live API." + if serve_url + else "- `kbagent http get|post /...` reaches the running serve when " + "`KBAGENT_SERVE_URL` + `KBAGENT_SERVE_TOKEN` are set (which they are " + "inside this subprocess)." + ) + return f"""\ +You are a Keboola data engineer's AI co-pilot, running inside +`kbagent serve`. The user types questions in a chat box on the dashboard +and you answer them by running real `kbagent` commands and summarising +the results — no guessing, no fabrication. + +TOOLS AVAILABLE: +- `kbagent` CLI is on PATH and pre-configured for the user's workspace + (same `config.json` the serve uses; same Keboola projects). +- Run `kbagent context` FIRST when you need to discover the full command + inventory or workflow knowledge. It dumps the kbagent skill (commands, + gotchas, workflows) into your context on demand — designed for AI + consumption. +- Add `--json` to ANY command for machine-parseable output (every + `kbagent` command supports it). +{serve_block} + +USER CONTEXT: +{project_block} +{branch_block} + +USER'S MESSAGE: +{message_clean} + +HOW TO ANSWER: +- If the question is concrete ("list failed jobs", "show config X"), + run the relevant `kbagent` command, parse the result, and answer. +- If the question is open-ended ("what should I clean up?"), discover + first (run a relevant `--json` command, scan the result), then + summarise with specific findings. +- Cross-project work is a first-class flag: most commands accept + multiple `--project NAME` flags. Don't artificially constrain to a + single project unless the question is single-project. + +OUTPUT FORMAT: +- Markdown. Use code blocks for SQL / commands you ran or recommend. +- Tables when comparing multiple projects / configs / rows. +- Be concrete: cite specific IDs, project aliases, timestamps. Avoid + vague "you might want to..." — say what to run and what to expect. +- If you cannot answer (Kai-required feature, missing token, blocked + by permissions), say so explicitly and name the missing piece. +""" + + def _now_utc() -> datetime: return datetime.now(UTC).replace(microsecond=0) diff --git a/src/keboola_agent_cli/server/routers/ai_chat.py b/src/keboola_agent_cli/server/routers/ai_chat.py new file mode 100644 index 00000000..d2396ae1 --- /dev/null +++ b/src/keboola_agent_cli/server/routers/ai_chat.py @@ -0,0 +1,129 @@ +"""Local AI chat endpoint (#300). + +Backs the dashboard's Local AI tile -- a generic chat surface that spawns +the user's local ``claude`` / ``codex`` / ``gemini`` CLI with a meta-prompt +telling it "you are an AI co-pilot for kbagent; run `kbagent context` +first if you need command docs, then answer the user's question." + +Distinct from: + +- ``/agents/prompt/improve/stream`` -- rewrites a draft prompt into a + polished single-shot prompt body. +- ``/workspaces/sql/improve/stream`` -- writes SQL grounded in a specific + workspace context. + +This endpoint is the freeform variant -- no output shape constraint, no +single-task framing. Used by the dashboard Local AI chat that replaces +the Kai tile for projects without a master Storage token (#291 wontfix +rationale). + +Same SSE wire format as the other helpers (init / stdout / stderr / +done events) so the React side can reuse the streaming progress renderer. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ..dependencies import ServiceRegistry, get_registry + +router = APIRouter(prefix="/ai", tags=["ai-chat"]) + + +class AiChatRequest(BaseModel): + """Input for the /ai/chat/stream endpoint. + + Single-shot: each request is independent. Conversation history is kept + on the React side as scrollback; it is NOT forwarded to the AI on the + next message (yet). Multi-turn with persisted history is tracked as + a follow-up feature. + """ + + cli: str # claude | codex | gemini -- same recipe as ai_agent runs + message: str + project: str | None = None + branch_id: int | None = None + extra_args: list[str] = [] + + +def _sse(event: str, data: dict[str, Any]) -> bytes: + """Encode a single SSE frame (event + data).""" + return f"event: {event}\ndata: {json.dumps(data, default=str)}\n\n".encode() + + +@router.post("/chat/stream") +async def chat_stream( + body: AiChatRequest, + registry: ServiceRegistry = Depends(get_registry), +) -> StreamingResponse: + """Stream a local-AI chat response back to the dashboard. + + Build a generic chat meta-prompt grounded in the user's active + project / branch, hand it to the chosen CLI via + ``stream_ai_agent_events``, and forward the SSE events through to + the client. The final ``done`` event mirrors the shape used by + other helpers; the React side renders the assistant's text + + tool_use activity log in real time. + """ + from ..agent_runner import ( + build_local_ai_meta_prompt, + stream_ai_agent_events, + ) + + message = body.message.strip() + if not message: + raise HTTPException(status_code=400, detail="message must not be empty") + + meta_prompt = build_local_ai_meta_prompt( + message=message, + project=body.project, + branch_id=body.branch_id, + serve_url=getattr(registry, "serve_url", None), + ) + params: dict[str, Any] = { + "cli": body.cli, + "prompt": meta_prompt, + "extra_args": body.extra_args, + # Chat answers should resolve in under a minute typically; + # 5-minute cap protects against a stuck CLI camping on the SSE + # connection. Aligns with the other helper endpoints. + "timeout": 300.0, + } + + async def gen() -> AsyncIterator[bytes]: + yield _sse( + "init", + { + "kind": "local_ai_chat", + "cli": body.cli, + "project": body.project, + "branch_id": body.branch_id, + # Surface the full meta-prompt so the UI can offer a + # "Show prompt" transparency panel identical to the SQL + # helper's. Debugging is impossible without it. + "meta_prompt": meta_prompt, + "message_preview": message[:200], + }, + ) + try: + async for evt in stream_ai_agent_events(registry, params): + yield _sse(evt["event"], evt["data"]) + except ValueError as exc: + yield _sse("done", {"status": "error", "error": str(exc)}) + except Exception as exc: + yield _sse("done", {"status": "error", "error": str(exc)}) + + return StreamingResponse( + gen(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + }, + ) diff --git a/tests/test_local_ai_chat.py b/tests/test_local_ai_chat.py new file mode 100644 index 00000000..5800a039 --- /dev/null +++ b/tests/test_local_ai_chat.py @@ -0,0 +1,306 @@ +"""Tests for the dashboard Local AI chat endpoint (#300). + +Mirrors the test structure of ``test_workspace_sql_helper.py`` and +``test_agent_prompt_helper.py``: pure-text helper tests (meta-prompt +content) + SSE endpoint integration with mocked +``stream_ai_agent_events``. + +The chat helper is the third instance of the same stateless-helper +pattern (after the SQL helper and the agent prompt helper) -- all three +sit on top of ``stream_ai_agent_events`` and differ only in their +meta-prompt builder and minimal endpoint wiring. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +if importlib.util.find_spec("fastapi") is None: # pragma: no cover + pytest.skip( + "FastAPI not installed; run `uv pip install -e '.[server]'`", + allow_module_level=True, + ) + +from fastapi.testclient import TestClient + +from keboola_agent_cli.server import create_app +from keboola_agent_cli.server.agent_runner import build_local_ai_meta_prompt + +# --------------------------------------------------------------------- +# build_local_ai_meta_prompt +# --------------------------------------------------------------------- + + +class TestBuildLocalAiMetaPrompt: + def test_includes_user_message_verbatim(self) -> None: + prompt = build_local_ai_meta_prompt( + message="How many jobs failed in padak project yesterday?", + ) + assert "How many jobs failed in padak project yesterday?" in prompt + + def test_strips_leading_trailing_whitespace_from_message(self) -> None: + prompt = build_local_ai_meta_prompt(message="\n\n question \n\n") + # The verbatim line should be the trimmed version. + assert "question" in prompt + + def test_project_block_when_project_pinned(self) -> None: + """A pinned project surfaces in USER CONTEXT with the --project + flag hint baked in, so the AI doesn't have to guess. + """ + prompt = build_local_ai_meta_prompt(message="g", project="padak") + assert "Active project: 'padak'" in prompt + assert "--project padak" in prompt + + def test_project_block_when_no_project(self) -> None: + """No-project mode invites the AI to either ask the user OR run + cross-project commands with explicit --project flags. + """ + prompt = build_local_ai_meta_prompt(message="g") + assert "Active project: (none" in prompt + # Cross-project guidance present so the AI doesn't refuse the + # request when no project is pinned. + assert "multi-project" in prompt + assert "--project NAME" in prompt + + def test_branch_id_surfaced_when_set(self) -> None: + prompt = build_local_ai_meta_prompt( + message="g", + project="demo", + branch_id=1234, + ) + assert "Active branch: #1234" in prompt + assert "--branch 1234" in prompt + + def test_branch_falls_back_to_main_when_unset(self) -> None: + prompt = build_local_ai_meta_prompt(message="g") + assert "Active branch: main (production)" in prompt + + def test_serve_url_baked_in_when_provided(self) -> None: + """When the serve URL is known we tell the AI the fast path + (`kbagent http get ...`); otherwise we give the env-var fallback. + """ + prompt = build_local_ai_meta_prompt( + message="g", + serve_url="http://127.0.0.1:8001", + ) + assert "kbagent http get|post" in prompt + assert "http://127.0.0.1:8001" in prompt + + def test_serve_url_omitted_falls_back_to_env_hint(self) -> None: + prompt = build_local_ai_meta_prompt(message="g") + # Without a serve URL the AI still gets the env-var-based hint + # so it knows kbagent http works inside the subprocess. + assert "KBAGENT_SERVE_URL" in prompt + assert "KBAGENT_SERVE_TOKEN" in prompt + + def test_kbagent_context_pointer_present(self) -> None: + """The kbagent skill is NOT inlined into the prompt (it's 70+ KB + of docs). Instead the AI is told to run `kbagent context` + on-demand, mirroring how Claude Code's plugin loader bootstraps + the skill. Pin this so a future refactor doesn't quietly + balloon every chat round-trip by inlining the skill text. + """ + prompt = build_local_ai_meta_prompt(message="g") + assert "kbagent context" in prompt + + def test_output_format_section_present(self) -> None: + """The chat surface renders markdown — pin that contract so a + future change doesn't make the AI emit a wall of plain text. + """ + prompt = build_local_ai_meta_prompt(message="g") + assert "Markdown" in prompt + assert "code blocks" in prompt.lower() or "code block" in prompt.lower() + + def test_no_output_shape_constraint(self) -> None: + """Unlike the SQL helper / prompt helper, the chat helper does + NOT pin an OUTPUT CONTRACT clause that strips fences or insight + blocks -- the chat surface intentionally renders markdown + verbatim including code blocks. Pin this so we don't + accidentally inherit the strict contract from the other + helpers via a copy-paste refactor. + """ + prompt = build_local_ai_meta_prompt(message="g") + # The forbidden-line patterns that the SQL helper uses must NOT + # appear in the chat meta-prompt. + assert "Do NOT wrap" not in prompt + assert "★ Insight" not in prompt + + +# --------------------------------------------------------------------- +# POST /ai/chat/stream +# --------------------------------------------------------------------- + + +def _parse_sse_events(text: str) -> list[tuple[str, str]]: + """Parse SSE wire format into [(event, data_json_string)].""" + out: list[tuple[str, str]] = [] + event = "message" + data = "" + for line in text.splitlines(): + if line == "": + if data: + out.append((event, data)) + event = "message" + data = "" + continue + if line.startswith(":"): + continue + if line.startswith("event:"): + event = line[6:].strip() + elif line.startswith("data:"): + data = line[5:].lstrip() + if data: + out.append((event, data)) + return out + + +@pytest.fixture +def client(tmp_path: Path) -> TestClient: + app = create_app(config_dir=str(tmp_path), auth_token="test-token") + return TestClient(app) + + +class TestChatStreamEndpoint: + def test_empty_message_rejected_400(self, client: TestClient) -> None: + res = client.post( + "/ai/chat/stream", + json={"cli": "claude", "message": " "}, + headers={"Authorization": "Bearer test-token"}, + ) + assert res.status_code == 400, res.text + + def test_init_event_carries_meta_prompt( + self, + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The frontend's 'Show prompt' transparency panel needs the + full meta-prompt that was sent to the CLI. Pin that the init + event carries it so debugging stays possible. + """ + + async def fake_stream(registry: object, params: dict[str, object]): + yield { + "event": "done", + "data": { + "cli": "claude", + "status": "ok", + "exit_code": 0, + "elapsed_seconds": 0.5, + "response": "ok", + "stderr": "", + }, + } + + monkeypatch.setattr( + "keboola_agent_cli.server.agent_runner.stream_ai_agent_events", + fake_stream, + ) + + with client.stream( + "POST", + "/ai/chat/stream", + json={"cli": "claude", "message": "list failing jobs", "project": "demo"}, + headers={"Authorization": "Bearer test-token"}, + ) as res: + assert res.status_code == 200 + body = res.read().decode("utf-8") + + events = _parse_sse_events(body) + import json as _json + + init = next(_json.loads(payload) for evt, payload in events if evt == "init") + assert init["kind"] == "local_ai_chat" + assert init["cli"] == "claude" + assert init["project"] == "demo" + assert "list failing jobs" in init["meta_prompt"] + # Active project hint must render in the meta-prompt so the AI + # has the project context surfaced. + assert "demo" in init["meta_prompt"] + + def test_forwards_stream_events_as_is( + self, + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Chat does NOT post-process the done event (unlike the SQL + helper which adds a cleaned `sql` field). The done event flows + through verbatim — the assistant text accumulator on the + frontend builds the markdown response from the stdout events. + """ + + async def fake_stream(registry: object, params: dict[str, object]): + yield {"event": "stdout", "data": {"raw": "thinking..."}} + yield { + "event": "done", + "data": { + "cli": "claude", + "status": "ok", + "exit_code": 0, + "elapsed_seconds": 1.0, + "response": "Answer with markdown\n```sql\nSELECT 1;\n```", + "stderr": "", + }, + } + + monkeypatch.setattr( + "keboola_agent_cli.server.agent_runner.stream_ai_agent_events", + fake_stream, + ) + + with client.stream( + "POST", + "/ai/chat/stream", + json={"cli": "claude", "message": "ask"}, + headers={"Authorization": "Bearer test-token"}, + ) as res: + assert res.status_code == 200 + body = res.read().decode("utf-8") + + events = _parse_sse_events(body) + names = [e for e, _ in events] + assert "init" in names + assert "stdout" in names + assert "done" in names + + import json as _json + + done = next(_json.loads(payload) for evt, payload in events if evt == "done") + # No SQL-style post-processing: the response field is unmodified + # and there is no added "sql" / "prompt" field. + assert "sql" not in done + assert "prompt" not in done + assert "```sql" in done["response"] + + def test_stream_error_surfaces_as_done_error( + self, + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + async def fake_stream(registry: object, params: dict[str, object]): + raise ValueError("ai_agent.cli must be one of ['claude', 'codex', 'gemini']") + yield # pragma: no cover -- unreachable + + monkeypatch.setattr( + "keboola_agent_cli.server.agent_runner.stream_ai_agent_events", + fake_stream, + ) + + with client.stream( + "POST", + "/ai/chat/stream", + json={"cli": "bogus", "message": "anything"}, + headers={"Authorization": "Bearer test-token"}, + ) as res: + assert res.status_code == 200 + body = res.read().decode("utf-8") + + events = _parse_sse_events(body) + import json as _json + + done = next(_json.loads(payload) for evt, payload in events if evt == "done") + assert done["status"] == "error" + assert "ai_agent.cli" in done["error"] diff --git a/uv.lock b/uv.lock index f72dc32f..f2c03309 100644 --- a/uv.lock +++ b/uv.lock @@ -496,7 +496,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.41.1" +version = "0.41.9" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 4861242d..e33e5edd 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -10,8 +10,8 @@ import { DoctorPage } from "./pages/Doctor"; import { EncryptPage } from "./pages/Encrypt"; import { FlowsPage } from "./pages/Flows"; import { JobsPage } from "./pages/Jobs"; -import { KaiPage } from "./pages/Kai"; import { LineagePage } from "./pages/Lineage"; +import { LocalAiPage } from "./pages/LocalAi"; import { McpPage } from "./pages/Mcp"; import { MembersPage } from "./pages/Members"; import { OrgPage } from "./pages/Org"; @@ -55,8 +55,8 @@ function Router() { return ; case "mcp": return ; - case "kai": - return ; + case "localai": + return ; case "agents": return ; case "search": diff --git a/web/frontend/src/layout/Sidebar.tsx b/web/frontend/src/layout/Sidebar.tsx index b3302a5c..7e8948cb 100644 --- a/web/frontend/src/layout/Sidebar.tsx +++ b/web/frontend/src/layout/Sidebar.tsx @@ -72,7 +72,7 @@ const SECTIONS: Array<{ title: "AI / Tools", items: [ { id: "mcp", label: "MCP Tools", icon: Sparkles }, - { id: "kai", label: "Kai Chat", icon: MessageSquare }, + { id: "localai", label: "Local AI", icon: MessageSquare }, { id: "agents", label: "Agent Tasks", icon: Bot }, ], }, diff --git a/web/frontend/src/pages/Dashboard.tsx b/web/frontend/src/pages/Dashboard.tsx index 8a97111a..2b3789cb 100644 --- a/web/frontend/src/pages/Dashboard.tsx +++ b/web/frontend/src/pages/Dashboard.tsx @@ -40,9 +40,8 @@ function greeting(): string { } export function DashboardPage() { - const { project, setPage } = useUIState(); - const [kaiInput, setKaiInput] = useState(""); - const [kaiResponse, setKaiResponse] = useState(null); + const { project, setPage, setPendingLocalAiMessage } = useUIState(); + const [aiInput, setAiInput] = useState(""); const projectsQ = useQuery<{ projects: Project[] }>({ queryKey: ["projects"], @@ -67,15 +66,19 @@ export function DashboardPage() { enabled: !!project, }); - const kaiMu = useMutation({ - mutationFn: () => - api.post<{ response?: string; message?: string }>("/kai/chat", { - message: kaiInput, - project, - }), - onSuccess: (data) => setKaiResponse(data.response ?? data.message ?? ""), - onError: (err) => setKaiResponse(`Error: ${(err as Error).message}`), - }); + /** + * Hand the typed message off to the Local AI page (#300). Dashboard + * hero stays minimal — full chat plumbing lives on /localai. User + * types → presses Send → navigates with the message pre-loaded; the + * Local AI page auto-fires the request on mount. + */ + const sendToLocalAi = () => { + const msg = aiInput.trim(); + if (!msg) return; + setPendingLocalAiMessage(msg); + setAiInput(""); + setPage("localai"); + }; const projects = projectsQ.data?.projects ?? []; const tasks = agentsQ.data?.tasks ?? []; @@ -97,17 +100,19 @@ export function DashboardPage() { title={greeting()} description={ project - ? `Working in project ${project}. Ask Kai anything below, or jump to a tile.` - : "Pick a project in the top bar to scope per-project tiles." + ? `Working in project ${project}. Ask the local AI anything below, or jump to a tile.` + : "Pick a project in the top bar to scope per-project tiles, or ask cross-project below." } /> - {/* Big Kai prompt -- the hero on Keboola UI dashboard. */} + {/* Hero "ask the local AI" prompt. Hands off to the Local AI page + via UIState.pendingLocalAiMessage; full chat plumbing lives + there, this stays a tiny launchpad. */}
    { e.preventDefault(); - if (kaiInput.trim()) kaiMu.mutate(); + sendToLocalAi(); }} >
    @@ -116,26 +121,21 @@ export function DashboardPage() { className="flex-1 bg-transparent border-0 focus:outline-none text-sm placeholder-zinc-500 dark:placeholder-zinc-600" placeholder={ project - ? `Ask Kai anything about ${project}...` - : "Pick a project first to ask Kai..." + ? `Ask the local AI about ${project}…` + : "Ask the local AI anything across your Keboola projects…" } - value={kaiInput} - onChange={(e) => setKaiInput(e.target.value)} - disabled={!project || kaiMu.isPending} + value={aiInput} + onChange={(e) => setAiInput(e.target.value)} />
    - {kaiResponse ? ( -
    - {kaiResponse} -
    - ) : null}
    {/* Stats row */} diff --git a/web/frontend/src/pages/Kai.tsx b/web/frontend/src/pages/Kai.tsx deleted file mode 100644 index a2c4f396..00000000 --- a/web/frontend/src/pages/Kai.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, MessageSquarePlus, RefreshCw, Send } from "lucide-react"; -import { useEffect, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { api } from "../api/client"; -import { ErrorBox, Loading, PageTitle } from "../components/Empty"; -import { JsonView } from "../components/JsonView"; -import { useUIState } from "../state"; - -interface Message { - role: "user" | "assistant"; - content: string; - meta?: unknown; - pending?: boolean; - error?: boolean; -} - -interface ChatSummary { - id: string; - title: string; - created_at: string | null; -} - -interface PreflightResponse { - project_alias: string; - ok: boolean; - is_master_token: boolean; - has_agent_chat_feature: boolean; - token_description: string | null; - project_id: number | null; - project_name: string | null; - error: string | null; -} - -interface ChatDetailResponse { - project_alias: string; - chat_id: string; - title: string | null; - created_at: string | null; - messages: { id: string; role: string; content: string; created_at: string | null }[]; -} - -// Per-project active chat ID, persisted across page navigations + refresh. -// Keyed on project so switching projects doesn't load the wrong conversation. -function activeChatKey(project: string | null): string { - return `kbagent:kai:active-chat:${project ?? "_"}`; -} - -function loadActiveChat(project: string | null): string | null { - try { - return localStorage.getItem(activeChatKey(project)); - } catch { - return null; - } -} - -function saveActiveChat(project: string | null, chatId: string | null) { - try { - if (chatId) localStorage.setItem(activeChatKey(project), chatId); - else localStorage.removeItem(activeChatKey(project)); - } catch { - // localStorage may be disabled (private mode); silent no-op is fine. - } -} - -/** - * Strip Keboola Connection's `icon:NAME` inline syntax (Font Awesome style) - * out of Kai responses. Keboola's own UI renders them as actual icons; our - * markdown view would otherwise show the raw `icon:database` literal next to - * each heading. We translate common ones to emoji and drop the rest. - */ -const ICON_EMOJI: Record = { - database: "🗄️", - "circle-exclamation": "⚠️", - "circle-info": "ℹ️", - "circle-check": "✅", - "triangle-exclamation": "⚠️", - lightbulb: "💡", - table: "📊", - "chart-bar": "📊", - flask: "🧪", - flow: "🔀", - "hand-pointer": "👉", - bolt: "⚡", - bug: "🐛", - gear: "⚙️", -}; - -function normalizeKaiText(text: string): string { - return text.replace(/icon:([a-z][a-z0-9-]*)\s*/gi, (_match, name: string) => { - const emoji = ICON_EMOJI[name.toLowerCase()]; - return emoji ? `${emoji} ` : ""; - }); -} - -export function KaiPage() { - const { project } = useUIState(); - const qc = useQueryClient(); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [chatId, setChatIdState] = useState(null); - - // Setter that also mirrors the value to localStorage so it survives page - // navigation in the SPA. - function setChatId(id: string | null) { - setChatIdState(id); - saveActiveChat(project, id); - } - - // On mount / project change, restore the active chat ID from localStorage. - // The detail-fetch effect below then populates messages from the server. - useEffect(() => { - const restored = loadActiveChat(project); - setChatIdState(restored); - if (!restored) setMessages([]); - }, [project]); - - const preflightQ = useQuery({ - queryKey: ["kai-preflight", project], - queryFn: () => api.get("/kai/preflight", { query: { project: project ?? undefined } }), - retry: false, - }); - - const pingQ = useQuery({ - queryKey: ["kai-ping", project], - queryFn: () => api.get("/kai/ping", { query: { project: project ?? undefined } }), - retry: false, - // Only attempt /ping once preflight confirms the token is usable — - // otherwise we'd surface a confusing KAI_NOT_ENABLED error on top of the - // (more actionable) preflight banner. - enabled: preflightQ.data?.ok === true, - }); - - const historyQ = useQuery<{ chats: ChatSummary[]; has_more: boolean }>({ - queryKey: ["kai-history", project], - queryFn: () => - api.get("/kai/history", { query: { project: project ?? undefined, limit: 30 } }), - retry: false, - enabled: preflightQ.data?.ok === true, - }); - - // When the user clicks a chat in the sidebar, load its full transcript. - // We restore the conversation into `messages` instead of streaming — - // `/kai/chat/{id}` already returns the parsed message list. - const detailQ = useQuery({ - queryKey: ["kai-chat-detail", project, chatId], - queryFn: () => - api.get(`/kai/chat/${chatId}`, { query: { project: project ?? undefined } }), - enabled: preflightQ.data?.ok === true && !!chatId, - retry: false, - }); - - useEffect(() => { - if (detailQ.data && detailQ.data.chat_id === chatId) { - const restored: Message[] = detailQ.data.messages.map((m) => ({ - role: m.role === "user" ? "user" : "assistant", - content: m.content, - })); - setMessages(restored); - } - }, [detailQ.data, chatId]); - - const sendMu = useMutation({ - mutationFn: (payload: { message: string; chatId: string | null }) => - api.post<{ message?: string; chat_id?: string; response?: string }>("/kai/chat", { - message: payload.message, - chat_id: payload.chatId, - project, - }), - onSuccess: (data) => { - setMessages((m) => { - const next = [...m]; - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === "assistant" && next[i].pending) { - next[i] = { - role: "assistant", - content: data.response ?? data.message ?? "", - meta: data, - }; - return next; - } - } - next.push({ - role: "assistant", - content: data.response ?? data.message ?? "", - meta: data, - }); - return next; - }); - if (data.chat_id) setChatId(data.chat_id); - // Refresh the sidebar so newly-created chats / updated titles appear. - qc.invalidateQueries({ queryKey: ["kai-history", project] }); - }, - onError: (err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - setMessages((m) => { - const next = [...m]; - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === "assistant" && next[i].pending) { - next[i] = { - role: "assistant", - content: `**Kai error:** ${msg}`, - error: true, - }; - return next; - } - } - next.push({ role: "assistant", content: `**Kai error:** ${msg}`, error: true }); - return next; - }); - }, - }); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const text = input.trim(); - if (!text || sendMu.isPending) return; - setMessages((m) => [ - ...m, - { role: "user", content: text }, - { role: "assistant", content: "", pending: true }, - ]); - setInput(""); - sendMu.mutate({ message: text, chatId }); - } - - function handleNewChat() { - setChatId(null); - setMessages([]); - } - - function handleOpenChat(id: string) { - if (id === chatId) return; - setChatId(id); - setMessages([]); // cleared by detail effect once it lands - } - - const preflight = preflightQ.data; - const preflightBlocking = preflight && !preflight.ok; - - return ( -
    - - - {!preflightBlocking ? ( - pingQ.error ? ( - - ) : pingQ.isLoading ? ( - - ) : pingQ.data ? ( -
    connected as {project}
    - ) : null - ) : null} - -
    - qc.invalidateQueries({ queryKey: ["kai-history", project] })} - /> - -
    -
    - {detailQ.isLoading && chatId && messages.length === 0 ? ( - - ) : detailQ.error && chatId ? ( - - ) : messages.length === 0 ? ( -
    - {chatId - ? "(empty conversation)" - : "No messages yet. Ask Kai something about this project."} -
    - ) : ( - messages.map((m, i) => ( -
    -
    -
    {m.role}
    - {m.role === "user" ? ( -
    {m.content}
    - ) : m.pending ? ( -
    - - Kai is thinking... -
    - ) : ( - - )} - {m.meta ? ( -
    - raw - -
    - ) : null} -
    -
    - )) - )} -
    -
    - setInput(e.target.value)} - placeholder={preflightBlocking ? "fix the token first…" : "ask Kai..."} - disabled={sendMu.isPending || !!preflightBlocking} - /> - -
    -
    -
    -
    - ); -} - -function PreflightBanner({ - preflight, - isLoading, - error, -}: { - preflight: PreflightResponse | undefined; - isLoading: boolean; - error: unknown; -}) { - if (isLoading) { - return
    checking token…
    ; - } - if (error) { - return ; - } - if (!preflight) return null; - if (preflight.ok) { - return ( -
    - Token: {preflight.token_description ?? "—"}{" "} - (master token, AI Agent Chat enabled) -
    - ); - } - - // One of the two preconditions failed. Spell out exactly which, with the - // important nouns highlighted in red so the user can scan it in 2 seconds. - return ( -
    -
    - -
    -
    - Kai cannot run with the current token -
    -
      - {!preflight.is_master_token ? ( -
    • - The configured token{" "} - {preflight.token_description ?? "—"} is{" "} - not the master{" "} - token. Kai requires the project's{" "} - - master ("owner") Storage API token - {" "} - — custom tokens cannot access Kai. Re-add the project with the master token. -
    • - ) : null} - {!preflight.has_agent_chat_feature ? ( -
    • - The project is missing the{" "} - AI Agent Chat{" "} - feature flag. Enable it in project settings and try again. -
    • - ) : null} -
    -
    -
    -
    - ); -} - -function ChatHistorySidebar({ - chats, - isLoading, - error, - activeChatId, - onOpen, - onNew, - onRefresh, -}: { - chats: ChatSummary[]; - isLoading: boolean; - error: unknown; - activeChatId: string | null; - onOpen: (id: string) => void; - onNew: () => void; - onRefresh: () => void; -}) { - return ( -
    -
    -
    conversations
    -
    - - -
    -
    - {isLoading ? ( -
    loading…
    - ) : error ? ( -
    {(error as Error).message}
    - ) : chats.length === 0 ? ( -
    No chats yet.
    - ) : ( -
      - {chats.map((c) => { - const active = c.id === activeChatId; - return ( -
    • - -
    • - ); - })} -
    - )} -
    - ); -} - -/** - * Render Kai's markdown answer. - * - * - GFM enabled → tables, strikethrough, task lists work out of the box. - * - Links open in a new tab (Kai often returns deep-links to Keboola UI). - * - No raw HTML allowed (react-markdown default) → safe against injection. - * - `icon:NAME` tokens are pre-processed into emoji because Kai uses Keboola - * Connection's Font Awesome shorthand that our UI doesn't ship. - */ -function KaiMarkdown({ text }: { text: string }) { - const normalized = normalizeKaiText(text); - return ( -
    - ( - - ), - table: (props) => ( -
    - - - ), - thead: (props) => , - th: (props) => ( -
    - ), - td: (props) => ( - - ), - code: (props) => { - const { className, children, ...rest } = props as { - className?: string; - children?: React.ReactNode; - }; - const isBlock = (className ?? "").startsWith("language-"); - if (isBlock) { - return ( -
    -                  
    -                    {children}
    -                  
    -                
    - ); - } - return ( - - {children} - - ); - }, - ul: (props) =>
      , - ol: (props) =>
        , - h1: (props) =>

        , - h2: (props) =>

        , - h3: (props) =>

        , - hr: () =>
        , - p: (props) =>

        , - }} - > - {normalized} - - - ); -} diff --git a/web/frontend/src/pages/LocalAi.tsx b/web/frontend/src/pages/LocalAi.tsx new file mode 100644 index 00000000..ccaf0221 --- /dev/null +++ b/web/frontend/src/pages/LocalAi.tsx @@ -0,0 +1,497 @@ +import { Bot, Eraser, Send, Sparkles, User, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { ssePost, type SsePostHandle } from "../api/client"; +import { ErrorBox, PageTitle } from "../components/Empty"; +import { useUIState } from "../state"; + +/** + * Local AI chat page (#300). + * + * Replaces the per-project Kai tile with a generic chat surface backed by + * the user's local Claude / Codex / Gemini CLI. The backend + * (POST /ai/chat/stream) spawns the chosen CLI with a meta-prompt + * grounding it as a kbagent co-pilot; the same stream_ai_agent_events + * machinery the workspace SQL helper and agent prompt helper already use. + * + * UX: append-only conversation. Each user message starts a new isolated + * AI invocation (single-shot — history is shown in scrollback but is NOT + * forwarded to the next request yet). "New conversation" clears scrollback. + * + * Why not multi-turn yet? Each subprocess is a fresh CLI session — we'd + * need to render previous turns into the meta-prompt by hand. That's the + * v2 follow-up; v1 nails the "ask a Keboola question, get a real answer" + * flow first. + */ + +interface ChatMessage { + role: "user" | "assistant"; + content: string; + activity?: string[]; + metaPrompt?: string; + pending?: boolean; + error?: string; +} + +/** + * AbortError shape detection across browsers (DOMException on standards, + * named Error on some shims). Duplicated from Workspaces.tsx pending a + * shared util module. + */ +function isAbortError(err: unknown): boolean { + if (err instanceof DOMException && err.name === "AbortError") return true; + if (err instanceof Error && err.name === "AbortError") return true; + return Boolean( + err && + typeof err === "object" && + "message" in err && + String((err as { message: unknown }).message).toLowerCase().includes("abort"), + ); +} + +export function LocalAiPage() { + const { project, branchId, pendingLocalAiMessage, setPendingLocalAiMessage } = useUIState(); + const [cli, setCli] = useState<"claude" | "codex" | "gemini">("claude"); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [running, setRunning] = useState(false); + const handleRef = useRef(null); + const scrollRef = useRef(null); + + // Hand-off slot from the Dashboard hero (#300): if the user typed a + // question on /dashboard and clicked Send, the message was dropped + // into UIState.pendingLocalAiMessage. Read it once, fire send() with + // the message passed directly (not via input state — setInput is + // async and the immediate send() would capture stale empty string). + // Then clear the slot so a remount can't fire it twice. + useEffect(() => { + if (!pendingLocalAiMessage) return; + const msg = pendingLocalAiMessage; + setPendingLocalAiMessage(null); + sendRef.current?.(msg); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingLocalAiMessage]); + const sendRef = useRef<((override?: string) => void) | null>(null); + + // Auto-scroll to bottom on new message / streamed content. We attach + // to the wrapper's scrollHeight after every render where messages + // changed. Cheap and avoids the "user scrolled up to read history" + // edge case (since the user has to deliberately scroll up; new + // messages otherwise pin to bottom naturally). + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages]); + + // Abort any in-flight stream on unmount so the backend doesn't keep + // spawning subprocesses for a closed connection. + useEffect(() => { + return () => { + if (handleRef.current) { + handleRef.current.abort(); + handleRef.current = null; + } + }; + }, []); + + const send = (messageOverride?: string) => { + // messageOverride lets the Dashboard hand-off effect bypass the + // input state (which is async and would be stale on the same tick + // setInput was called). Manual Send button goes through input. + const message = (messageOverride ?? input).trim(); + if (!message || running) return; + setInput(""); + setMessages((prev) => [...prev, { role: "user", content: message }]); + // Insert a pending assistant placeholder we will mutate via index as + // events stream in. + const assistantIdx = messages.length + 1; // user just pushed, this is the next slot + setMessages((prev) => [ + ...prev, + { role: "assistant", content: "", pending: true, activity: [] }, + ]); + setRunning(true); + + let assistantText = ""; + const activity: string[] = []; + + const updateAssistant = (patch: Partial) => { + setMessages((prev) => { + const next = [...prev]; + if (next[assistantIdx]) { + next[assistantIdx] = { ...next[assistantIdx], ...patch }; + } + return next; + }); + }; + + const handle = ssePost( + "/ai/chat/stream", + { + cli, + message, + project: project ?? null, + branch_id: branchId, + }, + { + init: (d) => { + const data = (d ?? {}) as Record; + if (typeof data.meta_prompt === "string") { + updateAssistant({ metaPrompt: data.meta_prompt }); + } + }, + stdout: (d) => { + const data = (d ?? {}) as Record; + // Claude stream-json: assistant turns + tool_use + tool_result. + if (data.type === "assistant" && typeof data.message === "object") { + const msg = data.message as Record; + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") continue; + const b = block as Record; + if (b.type === "text" && typeof b.text === "string") { + assistantText += b.text; + updateAssistant({ content: assistantText }); + } else if (b.type === "tool_use") { + const name = typeof b.name === "string" ? b.name : "tool"; + const input = b.input; + const args = + typeof input === "object" && input !== null + ? (() => { + const obj = input as Record; + if (typeof obj.command === "string") return obj.command; + if (typeof obj.description === "string") return obj.description; + return JSON.stringify(obj).slice(0, 200); + })() + : ""; + activity.push(`→ ${name}: ${args}`); + updateAssistant({ activity: [...activity] }); + } + } + } + } else if (data.type === "user" && typeof data.message === "object") { + // Tool results — one-line status only. + const msg = data.message as Record; + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") continue; + const b = block as Record; + if (b.type === "tool_result") { + const isErr = b.is_error === true; + activity.push(` ${isErr ? "✗" : "✓"} tool result${isErr ? " (error)" : ""}`); + updateAssistant({ activity: [...activity] }); + } + } + } + } else if (typeof data.raw === "string") { + // codex / gemini stream raw text lines (no jsonl). + assistantText += (assistantText ? "\n" : "") + data.raw; + updateAssistant({ content: assistantText }); + } + }, + stderr: () => { + /* progress notes — already covered by Activity panel */ + }, + done: (d) => { + const data = (d ?? {}) as Record; + if (data.status === "error") { + updateAssistant({ + pending: false, + error: String(data.error ?? "AI chat failed"), + }); + return; + } + // If the assistant produced no text but the run completed OK + // (e.g. the AI only ran tools and never wrote a summary), pass + // the raw final response through. Otherwise leave the + // streamed text as-is. + if (!assistantText && typeof data.response === "string") { + assistantText = data.response; + } + updateAssistant({ + pending: false, + content: assistantText || "(empty response)", + }); + }, + message: () => { + /* unknown event — ignore */ + }, + }, + ); + handleRef.current = handle; + handle.done + .catch((err) => { + if (isAbortError(err)) return; + updateAssistant({ pending: false, error: (err as Error).message }); + }) + .finally(() => { + setRunning(false); + handleRef.current = null; + }); + }; + // Expose send via a ref so the pendingLocalAiMessage effect can call + // it without re-binding on every render. send itself closes over + // input + running state which is fine: the override path bypasses + // both, and the manual path always runs after a user gesture (so + // state is fresh at click time). + sendRef.current = send; + + const cancel = () => { + if (handleRef.current) { + handleRef.current.abort(); + handleRef.current = null; + } + setRunning(false); + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last && last.role === "assistant" && last.pending) { + next[next.length - 1] = { + ...last, + pending: false, + error: "Cancelled by user.", + }; + } + return next; + }); + }; + + const clearChat = () => { + cancel(); + setMessages([]); + }; + + const placeholder = project + ? `Ask ${cli} about ${project} — e.g. "list jobs that failed in the last 24h"` + : `Ask ${cli} anything about your Keboola projects — pick one in the top bar, or ask cross-project (use --project NAME)`; + + return ( +

        + + CLI: + {(["claude", "codex", "gemini"] as const).map((c) => ( + + ))} + +
        + } + /> + +
        + {messages.length === 0 ? ( + + ) : ( + messages.map((m, i) => ) + )} +
        + +
        { + e.preventDefault(); + send(); + }} + > + +