diff --git a/docs/abort-controller.md b/docs/abort-controller.md index 3046d71..98dfbf5 100644 --- a/docs/abort-controller.md +++ b/docs/abort-controller.md @@ -16,27 +16,33 @@ or is aborted, the abort signal mechanism is cleaned up end-to-end. ``` [Frontend] --abortWorkOrder(cb)--> [CodeSession] --abortWorkOrder(cb)--> [Drone] - | - AgentService - .abortCurrentWorkOrder() - | - abortController.abort() - | - cb(true, "Abort signaled") - | + | + AgentService + .abortCurrentWorkOrder() + | + abortController.abort() + | + AiService.abort() + (calls AI provider's + abort() which for + Ollama terminates the + HTTP request immediately) + | + cb(true, "Abort signaled") + | [Frontend] <------- callback received, show "Aborting..." -----------------------+ - | - (AbortError thrown - in AI provider) - | - AgentService catches - AbortError, emits - workOrderComplete(turnId, true, "aborted") - | + | + (AbortError thrown + in AI provider) + | + AgentService catches + AbortError, emits + workOrderComplete(turnId, true, "aborted") + | [DroneSession] --workOrderComplete(turnId, true, "aborted")--> [CodeSession] - | | - turn.status = turn.status = "aborted" - ChatTurnStatus.Aborted displayed subtly + | | + turn.status = turn.status = "aborted" + ChatTurnStatus.Aborted displayed subtly ``` **Key design decision**: `workOrderComplete` is used with `success=true` and @@ -74,6 +80,10 @@ No `SocketEvents` changes needed — the drone listens on its own socket. - `IAiChatOptions` - `IAiGenerateOptions` +Also added a default (no-op) `abort()` method on the `AiApi` abstract class. +Provider-specific implementations override this to forcefully terminate +in-progress HTTP requests. + **`packages/ai/src/ollama.ts`** — In both `generate()` and `chat()`: 1. Pre-request check: `if (options.signal?.aborted) throw ...` @@ -81,6 +91,9 @@ No `SocketEvents` changes needed — the drone listens on its own socket. 3. Per-chunk check at top of `for await` loop: `if (options.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError")` +Additionally, `OllamaAiApi` overrides `abort()` to call `this.client.abort()`, +which immediately terminates the underlying HTTP request to the Ollama server. + **`packages/ai/src/openai.ts`** — Same pattern in: - `generate()` — pre-check, pass `signal` to SDK, per-chunk in `for await` @@ -108,7 +121,18 @@ and is detected downstream via `error.name === "AbortError"`. ``` - `finally` block sets `this.abortController = null` - Public method `abortCurrentWorkOrder(): boolean` — calls - `this.abortController?.abort()`, returns `true` if there was a controller + `this.abortController?.abort()` then `AiService.abort()`, returns `true` if + there was a controller. The `AiService.abort()` call provides provider-specific + forceful abort (e.g., `OllamaAiApi.abort()` → `this.client.abort()`). + +**`gadget-drone/src/services/ai.ts`**: + +- Property: `private activeApi: AiApi | null = null` — tracks the most recently + created `AiApi` instance +- `getApi()` stores each created instance on `this.activeApi` +- `chat()` and `generate()` clear `this.activeApi = null` in `finally` blocks +- Public method `abort(): void` — calls `this.activeApi?.abort()`, then clears + the reference **`gadget-drone/src/gadget-drone.ts`**: @@ -193,10 +217,11 @@ status: "processing" | "finished" | "aborted" | "error"; | `.gitignore` | Add root-level log file patterns | | `packages/api/src/messages/ide.ts` | New `AbortWorkOrderCallback`, `AbortWorkOrderMessage` | | `packages/api/src/messages/socket.ts` | Register `abortWorkOrder` in both event interfaces | -| `packages/ai/src/api.ts` | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions` | -| `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through | +| `packages/ai/src/api.ts` | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions`; `abort()` on `AiApi` | +| `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through; `abort()` override | | `packages/ai/src/openai.ts` | Same for `generate()`, `readStreamingChatCompletion()`, `readNonStreamingChatCompletion()` | -| `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` | +| `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` calls `AiService.abort()` | +| `gadget-drone/src/services/ai.ts` | Track active `AiApi` instance, `abort()` method, cleanup in `finally` blocks | | `gadget-drone/src/gadget-drone.ts` | `onAbortWorkOrder` socket handler | | `gadget-code/src/lib/code-session.ts` | Forward `abortWorkOrder` to drone | | `gadget-code/src/lib/drone-session.ts` | Detect `"aborted"` in `workOrderComplete` | @@ -214,9 +239,16 @@ status: "processing" | "finished" | "aborted" | "error"; event or change the `workOrderComplete` signature. - The `AbortController` is created per-process() call and cleaned up in `finally`. It is never shared across work orders. -- The abort callback returns immediately after the controller is signaled. The - AbortError propagates asynchronously through the SDK stream and is caught - separately in the agent loop. +- The abort callback returns immediately after the controller is signaled and + `AiService.abort()` is called. The AbortError propagates asynchronously + through the SDK stream and is caught separately in the agent loop. +- `AiService.abort()` provides provider-specific forceful abort (e.g., Ollama's + `client.abort()` terminates the HTTP request immediately). This is called + _in addition to_ the `AbortController.abort()` signal so that both the + AbortSignal chain and the provider-specific abort mechanism are triggered. +- The active `AiApi` instance is tracked on `AiService.activeApi` and is set to + `null` in the `finally` block of each `chat()`/`generate()` call, so the + reference is never stale. - Subagent loops also receive the abort signal (via `this.abortController?.signal`) and abort naturally — no special subagent abort handling needed. - The Esc handler is a global `window` keydown listener. It is registered ONLY diff --git a/gadget-code/frontend/src/pages/ProjectManager.tsx b/gadget-code/frontend/src/pages/ProjectManager.tsx index b7a158f..84a7d94 100644 --- a/gadget-code/frontend/src/pages/ProjectManager.tsx +++ b/gadget-code/frontend/src/pages/ProjectManager.tsx @@ -12,6 +12,7 @@ import { providerApi, } from "../lib/api"; import { socketClient } from "../lib/socket"; +import gabAiImage from "./assets/gab-ai.jpeg"; interface ProjectManagerProps { user: User | null; @@ -231,7 +232,11 @@ interface ProjectInspectorProps { onUpdate: () => void; } -function ProjectInspector({ project, onDelete, onUpdate }: ProjectInspectorProps) { +function ProjectInspector({ + project, + onDelete, + onUpdate, +}: ProjectInspectorProps) { const [deleting, setDeleting] = useState(false); const [editing, setEditing] = useState(false); @@ -279,7 +284,9 @@ function ProjectInspector({ project, onDelete, onUpdate }: ProjectInspectorProps
Slug
-
{project.slug}
+
+ {project.slug} +
@@ -652,118 +659,152 @@ function NewChatSessionModal({ } }; + const affiliateHref = "https://gab.ai/?ref=c7182f6e"; + return (
-
-

New Chat Session

- {selectedDrone && ( -
-
Selected Drone
-
- {selectedDrone.hostname} +
+
+ + Gab AI + +

+ Need big-brain inference for this session? +

+

+ Sign up for Gab AI and get 250 credits to see if it's right for you! +

+ + + +
+
+

New Chat Session

+ {selectedDrone && ( +
+
Selected Drone
+
+ {selectedDrone.hostname} +
+
+ {selectedDrone.workspaceDir} +
-
- {selectedDrone.workspaceDir} -
-
- )} -
-
- - setName(e.target.value)} - className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none" - placeholder="Auto-generated from first prompt" - /> -
- -
- - -
- - {selectedProvider && ( + )} +
+ setName(e.target.value)} + className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none" + placeholder="Auto-generated from first prompt" + /> +
+ +
+ - {selectedProvider.models.length === 0 && ( -

- No models discovered. Run `pnpm cli provider probe{" "} - {selectedProvider._id}` to discover models. -

- )}
- )} -
- - -
+ {selectedProvider && ( +
+ + + {selectedProvider.models.length === 0 && ( +

+ No models discovered. Run `pnpm cli provider probe{" "} + {selectedProvider._id}` to discover models. +

+ )} +
+ )} -
- - -
-
+
+ + +
+ +
+ + +
+ +
); @@ -774,7 +815,9 @@ export default function ProjectManager({ user }: ProjectManagerProps) { const { slug } = useParams(); const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); - const [selectedDrone, setSelectedDrone] = useState(null); + const [selectedDrone, setSelectedDrone] = useState( + null, + ); const [loading, setLoading] = useState(true); const [showNewForm, setShowNewForm] = useState(false); @@ -839,7 +882,10 @@ export default function ProjectManager({ user }: ProjectManagerProps) { console.error("Failed to lock drone session"); return; } - localStorage.setItem('dtp_drone_registration', JSON.stringify(selectedDrone)); + localStorage.setItem( + "dtp_drone_registration", + JSON.stringify(selectedDrone), + ); navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`); } catch (err) { console.error("Failed to open chat session", err); diff --git a/gadget-code/frontend/src/pages/assets/gab-ai.jpeg b/gadget-code/frontend/src/pages/assets/gab-ai.jpeg new file mode 100644 index 0000000..b0333aa Binary files /dev/null and b/gadget-code/frontend/src/pages/assets/gab-ai.jpeg differ diff --git a/gadget-code/frontend/src/types/assets.d.ts b/gadget-code/frontend/src/types/assets.d.ts new file mode 100644 index 0000000..0942e81 --- /dev/null +++ b/gadget-code/frontend/src/types/assets.d.ts @@ -0,0 +1,29 @@ +declare module "*.jpeg" { + const src: string; + export default src; +} + +declare module "*.jpg" { + const src: string; + export default src; +} + +declare module "*.png" { + const src: string; + export default src; +} + +declare module "*.gif" { + const src: string; + export default src; +} + +declare module "*.svg" { + const src: string; + export default src; +} + +declare module "*.webp" { + const src: string; + export default src; +} diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index 4a49c74..46dabfd 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -337,6 +337,7 @@ class AgentService extends GadgetService { abortCurrentWorkOrder(): boolean { if (!this.abortController) return false; this.abortController.abort(); + AiService.abort(); return true; } diff --git a/gadget-drone/src/services/ai.ts b/gadget-drone/src/services/ai.ts index 40d29f0..2d438da 100644 --- a/gadget-drone/src/services/ai.ts +++ b/gadget-drone/src/services/ai.ts @@ -18,6 +18,7 @@ const aiEnv: IAiEnvironment = { import { IAiProvider as DbAiProvider, GadgetId, type IDroneModelConfig } from "@gadget/api"; import { GadgetService } from "../lib/service.js"; import { + AiApi, type IAiChatOptions, type IAiChatResponse, type IAiGenerateOptions, @@ -47,6 +48,8 @@ class AiService extends GadgetService { return "svc:ai"; } + private activeApi: AiApi | null = null; + async start(): Promise { this.log.info("started"); } @@ -103,7 +106,11 @@ class AiService extends GadgetService { }); const api = this.getApi(config); const modelConfig: IAiModelConfig = { ...model, provider: config }; - return api.generate(modelConfig, options, streamCallback); + try { + return await api.generate(modelConfig, options, streamCallback); + } finally { + this.activeApi = null; + } } async chat( @@ -121,11 +128,23 @@ class AiService extends GadgetService { }); const api = this.getApi(config); const modelConfig: IAiModelConfig = { ...model, provider: config }; - return await api.chat(modelConfig, options, streamCallback); + try { + return await api.chat(modelConfig, options, streamCallback); + } finally { + this.activeApi = null; + } } getApi(provider: AiProviderConfig) { - return createAiApi(aiEnv, provider, this.log); + const api = createAiApi(aiEnv, provider, this.log); + this.activeApi = api; + return api; + } + + /** Forcefully abort any in-progress request on the active API instance. */ + abort(): void { + this.activeApi?.abort(); + this.activeApi = null; } } diff --git a/packages/ai/src/api.ts b/packages/ai/src/api.ts index 3c956d2..a0726a0 100644 --- a/packages/ai/src/api.ts +++ b/packages/ai/src/api.ts @@ -196,6 +196,15 @@ export abstract class AiApi { streamCallback?: IAiResponseStreamFn, ): Promise; + /** + * Forcefully abort any in-progress API request. + * Provider-specific implementations (e.g., Ollama) should terminate + * the underlying HTTP request immediately. Default is no-op. + */ + abort(): void { + // Override in provider-specific implementations + } + protected assertNonEmptyChatResponse(response: IAiChatResponse): void { const hasResponse = response.response.trim().length > 0; const hasThinking = !!response.thinking?.trim(); diff --git a/packages/ai/src/ollama.ts b/packages/ai/src/ollama.ts index f8613ce..2bea71e 100644 --- a/packages/ai/src/ollama.ts +++ b/packages/ai/src/ollama.ts @@ -34,6 +34,11 @@ export class OllamaAiApi extends AiApi { }); } + /** Forcefully abort any in-progress Ollama API request. */ + override abort(): void { + this.client.abort(); + } + async listModels(): Promise { const response = await this.client.list(); const models = response.models.map((model) => {