added Gab AI affiliate link; changed Ollama abort procedure

This commit is contained in:
Rob Colbert 2026-05-15 14:11:45 -04:00
parent 96663f7c88
commit d990d0dd71
8 changed files with 271 additions and 130 deletions

View File

@ -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

View File

@ -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
</div>
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Slug</div>
<div className="font-mono text-text-primary">{project.slug}</div>
<div className="font-mono text-text-primary">
{project.slug}
</div>
</div>
</div>
@ -652,118 +659,152 @@ function NewChatSessionModal({
}
};
const affiliateHref = "https://gab.ai/?ref=c7182f6e";
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-bg-secondary border border-border-default rounded p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">New Chat Session</h3>
{selectedDrone && (
<div className="mb-4 p-3 bg-bg-tertiary border border-border-default rounded">
<div className="text-xs text-text-muted mb-1">Selected Drone</div>
<div className="font-mono text-sm text-text-primary">
{selectedDrone.hostname}
<div className="bg-bg-secondary border border-border-default rounded p-6 w-full max-w-2xl flex gap-6">
<div className="w-48 flex-shrink-0 flex flex-col">
<a
href={affiliateHref}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0"
>
<img src={gabAiImage} alt="Gab AI" className="w-full rounded" />
</a>
<p className="flex-1 flex items-center justify-center text-center text-lg text-text-secondary px-1">
Need big-brain inference for this session?
</p>
<p className="flex-1 flex items-center justify-center text-left text-sm text-text-secondary px-1">
Sign up for Gab AI and get 250 credits to see if it's right for you!
</p>
<a
href={affiliateHref}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0"
>
<button className="w-full px-4 py-2 border border-[#00d178] text-[#00d178] rounded hover:bg-[#00d178] hover:text-white text-sm font-medium transition-colors">
Get Started
</button>
</a>
</div>
<div className="flex-1 border-l border-border-subtle pl-6">
<h3 className="text-lg font-semibold mb-4">New Chat Session</h3>
{selectedDrone && (
<div className="mb-4 p-3 bg-bg-tertiary border border-border-default rounded">
<div className="text-xs text-text-muted mb-1">Selected Drone</div>
<div className="font-mono text-sm text-text-primary">
{selectedDrone.hostname}
</div>
<div className="text-xs text-text-muted truncate">
{selectedDrone.workspaceDir}
</div>
</div>
<div className="text-xs text-text-muted truncate">
{selectedDrone.workspaceDir}
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1">
Session Name (optional)
</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">
AI Provider *
</label>
<select
value={selectedProviderId}
onChange={(e) => {
setSelectedProviderId(e.target.value);
setSelectedModel("");
}}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
>
<option value="">Select a provider</option>
{providers.map((provider) => (
<option key={provider._id} value={provider._id}>
{provider.name} ({provider.apiType})
</option>
))}
</select>
</div>
{selectedProvider && (
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1">
Model *
Session Name (optional)
</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">
AI Provider *
</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
value={selectedProviderId}
onChange={(e) => {
setSelectedProviderId(e.target.value);
setSelectedModel("");
}}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
disabled={selectedProvider.models.length === 0}
>
<option value="">Select a model</option>
{[...selectedProvider.models].sort((a, b) => a.name.localeCompare(b.name)).map((model) => (
<option key={model.id} value={model.id}>
{model.name}{" "}
{model.parameterLabel ? `(${model.parameterLabel})` : ""}
<option value="">Select a provider</option>
{providers.map((provider) => (
<option key={provider._id} value={provider._id}>
{provider.name} ({provider.apiType})
</option>
))}
</select>
{selectedProvider.models.length === 0 && (
<p className="text-xs text-text-muted mt-1">
No models discovered. Run `pnpm cli provider probe{" "}
{selectedProvider._id}` to discover models.
</p>
)}
</div>
)}
<div>
<label className="block text-sm text-text-secondary mb-1">
Mode
</label>
<select
value={mode}
onChange={(e) => setMode(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"
>
<option value="plan">Plan - Planning and brainstorming</option>
<option value="build">Build - Building and coding</option>
<option value="test">Test - Testing and debugging</option>
<option value="ship">Ship - Finalizing and shipping</option>
<option value="dev">Dev - Working on Gadget Code itself</option>
</select>
</div>
{selectedProvider && (
<div>
<label className="block text-sm text-text-secondary mb-1">
Model *
</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(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"
disabled={selectedProvider.models.length === 0}
>
<option value="">Select a model</option>
{[...selectedProvider.models]
.sort((a, b) => a.name.localeCompare(b.name))
.map((model) => (
<option key={model.id} value={model.id}>
{model.name}{" "}
{model.parameterLabel
? `(${model.parameterLabel})`
: ""}
</option>
))}
</select>
{selectedProvider.models.length === 0 && (
<p className="text-xs text-text-muted mt-1">
No models discovered. Run `pnpm cli provider probe{" "}
{selectedProvider._id}` to discover models.
</p>
)}
</div>
)}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={creating || !selectedProviderId || !selectedModel}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 flex-1"
>
{creating ? "Creating..." : "Create Session"}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
>
Cancel
</button>
</div>
</form>
<div>
<label className="block text-sm text-text-secondary mb-1">
Mode
</label>
<select
value={mode}
onChange={(e) => setMode(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"
>
<option value="plan">Plan - Planning and brainstorming</option>
<option value="build">Build - Building and coding</option>
<option value="test">Test - Testing and debugging</option>
<option value="ship">Ship - Finalizing and shipping</option>
<option value="dev">Dev - Working on Gadget Code itself</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={creating || !selectedProviderId || !selectedModel}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 flex-1"
>
{creating ? "Creating..." : "Create Session"}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
@ -774,7 +815,9 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
const { slug } = useParams();
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View File

@ -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;
}

View File

@ -337,6 +337,7 @@ class AgentService extends GadgetService {
abortCurrentWorkOrder(): boolean {
if (!this.abortController) return false;
this.abortController.abort();
AiService.abort();
return true;
}

View File

@ -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<void> {
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;
}
}

View File

@ -196,6 +196,15 @@ export abstract class AiApi {
streamCallback?: IAiResponseStreamFn,
): Promise<IAiChatResponse>;
/**
* 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();

View File

@ -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<IAiModelListResult> {
const response = await this.client.list();
const models = response.models.map((model) => {