Phase 1: OpenAI API Token Extraction
- Add stream_options: { include_usage: true } to all streaming API calls
- Capture chunk.usage from final streaming chunks and response.usage from non-streaming
- Extend OpenAiChatIterationResult with optional usage field
- Update buildStats() to accept and return real token counts from usage data
- Wire iteration.usage through chat() to both buildStats() call sites
Phase 2: Agent Loop Stats Propagation
- Rename inputTokens/outputTokens to masterInputTokens/masterOutputTokens, add masterThinkingTokens
- Accumulate response.stats.tokenCounts after each master AI call
- Delete all Math.ceil(length/4) crude approximations (master and subagent loops)
- Track startTime/durationMs and emit IWorkOrderCompleteStats with workOrderComplete
- Subagent loop uses response.stats?.tokenCounts instead of Math.ceil
Phase 3: Database Model Changes
- Add contextWindowUsage field to IChatSession, ChatSessionSchema, and frontend ChatSession
- Initialize contextWindowUsage: 0 on session creation
Phase 4: Persist Stats on Turn Completion
- drone-session: accept IWorkOrderCompleteStats, persist turn stats, walk subagent records for aggregate
- drone-session: $inc session stats and contextWindowUsage, add formatDurationLabel() helper
- code-session: accept and forward stats, update in-memory session stats
- message-queue: Redis replay handles 4th stats arg
- Update WorkOrderCompleteMessage type in @gadget/api to accept stats parameter
Phase 5: UI — Context Window Fuel Gauge
- Add contextWindowUsage prop to SessionPanel
- Add fuel gauge bar with E→F labels, green/yellow/red zones, token count display
- Visible for ALL provider types (not gated by apiType)
Phase 6: Frontend Streaming State
- Add IWorkOrderCompleteStats interface to frontend api.ts
- handleWorkOrderComplete accepts stats, updates turn stats and session contextWindowUsage
- Pass contextWindowUsage prop to SessionPanel
499 lines
12 KiB
TypeScript
499 lines
12 KiB
TypeScript
const API_BASE = "";
|
|
|
|
const TOKEN_KEY = "dtp_auth_token";
|
|
const USER_KEY = "dtp_user";
|
|
|
|
let isRefreshing = false;
|
|
let refreshPromise: Promise<string> | null = null;
|
|
|
|
/**
|
|
* Callback invoked after a successful token refresh so that other
|
|
* modules (e.g., the socket client) can update their stored JWT.
|
|
* Set via `setOnTokenRefreshed()`.
|
|
*/
|
|
let onTokenRefreshedCallback: ((newToken: string) => void) | null = null;
|
|
|
|
/**
|
|
* Register a callback to be invoked whenever the JWT is refreshed.
|
|
* Used by the socket client to update its auth token for reconnections.
|
|
*/
|
|
export function setOnTokenRefreshed(cb: (newToken: string) => void): void {
|
|
onTokenRefreshedCallback = cb;
|
|
}
|
|
|
|
export interface ApiResponse<T = unknown> {
|
|
success: boolean;
|
|
message?: string;
|
|
user?: T;
|
|
token?: string;
|
|
data?: T;
|
|
}
|
|
|
|
function getToken(): string | null {
|
|
try {
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setToken(token: string): void {
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
}
|
|
|
|
function signOut(): void {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(USER_KEY);
|
|
window.location.href = "/";
|
|
}
|
|
|
|
/**
|
|
* Check if the current JWT's `exp` claim is within the refresh threshold.
|
|
* Decodes the payload (base64) without cryptographic verification.
|
|
* Returns true if the token will expire within `marginMs` milliseconds.
|
|
*/
|
|
function isTokenExpiringSoon(token: string, marginMs = 5 * 60 * 1000): boolean {
|
|
try {
|
|
const parts = token.split(".");
|
|
if (parts.length < 2) return true;
|
|
const payload = JSON.parse(atob(parts[1]!));
|
|
if (!payload.exp) return true;
|
|
const expiresAt = payload.exp * 1000; // seconds → ms
|
|
return Date.now() > (expiresAt - marginMs);
|
|
} catch {
|
|
return true; // if we can't decode it, treat it as expiring
|
|
}
|
|
}
|
|
|
|
async function refreshAuthToken(): Promise<string> {
|
|
const response = await fetch(`${API_BASE}/api/v1/auth/renew-token`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Token refresh failed: ${response.status}`);
|
|
}
|
|
|
|
const text = await response.text();
|
|
try {
|
|
const json = JSON.parse(text) as ApiResponse & { token?: string; data?: { token?: string } };
|
|
if (!json.success) {
|
|
throw new Error(json.message || "Token refresh failed");
|
|
}
|
|
/*
|
|
* The API v1 renew-token endpoint returns { success: true, token: "..." }
|
|
* at the top level (not nested under `data`). Handle both formats for
|
|
* robustness: check json.token first, then fall back to json.data?.token.
|
|
*/
|
|
const newToken = json.token ?? json.data?.token;
|
|
if (!newToken) {
|
|
throw new Error("Token refresh response missing token");
|
|
}
|
|
return newToken;
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message.includes("Token refresh")) throw err;
|
|
throw new Error(`Invalid refresh response: ${text.slice(0, 200)}`);
|
|
}
|
|
}
|
|
|
|
async function request<T>(
|
|
method: string,
|
|
path: string,
|
|
body?: Record<string, unknown>,
|
|
retryCount = 0,
|
|
): Promise<T> {
|
|
let token = getToken();
|
|
|
|
/*
|
|
* Proactive token refresh: if the JWT's `exp` claim shows it will expire
|
|
* within 5 minutes, refresh it before making the request. This avoids
|
|
* unnecessary 401 errors and the resulting socket disconnections.
|
|
*/
|
|
if (token && isTokenExpiringSoon(token)) {
|
|
try {
|
|
if (!isRefreshing) {
|
|
isRefreshing = true;
|
|
refreshPromise = refreshAuthToken();
|
|
}
|
|
token = (await refreshPromise)!;
|
|
setToken(token);
|
|
onTokenRefreshedCallback?.(token);
|
|
isRefreshing = false;
|
|
refreshPromise = null;
|
|
} catch {
|
|
isRefreshing = false;
|
|
refreshPromise = null;
|
|
// Don't sign out on proactive refresh failure — the token may still
|
|
// be valid at the DB level even if the JWT exp is close. Let the
|
|
// reactive 401 handler below deal with it if needed.
|
|
}
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
|
|
if (token) {
|
|
headers["Authorization"] = `Bearer ${token}`;
|
|
}
|
|
|
|
const options: RequestInit = {
|
|
method,
|
|
headers,
|
|
credentials: "include",
|
|
};
|
|
|
|
if (body) {
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}${path}`, options);
|
|
|
|
if (response.status === 401 && retryCount === 0) {
|
|
try {
|
|
if (!isRefreshing) {
|
|
isRefreshing = true;
|
|
refreshPromise = refreshAuthToken();
|
|
}
|
|
|
|
const newToken = (await refreshPromise)!;
|
|
setToken(newToken);
|
|
onTokenRefreshedCallback?.(newToken);
|
|
isRefreshing = false;
|
|
refreshPromise = null;
|
|
|
|
return request<T>(method, path, body, retryCount + 1);
|
|
} catch {
|
|
isRefreshing = false;
|
|
refreshPromise = null;
|
|
signOut();
|
|
throw new Error("Session expired. Please sign in again.");
|
|
}
|
|
}
|
|
|
|
const text = await response.text();
|
|
|
|
if (!text) {
|
|
throw new Error("Empty response");
|
|
}
|
|
|
|
try {
|
|
var json = JSON.parse(text) as ApiResponse<T>;
|
|
} catch {
|
|
throw new Error(`Invalid JSON: ${text.slice(0, 200)}`);
|
|
}
|
|
|
|
if (!json.success) {
|
|
throw new Error(json.message || "Request failed");
|
|
}
|
|
|
|
if (json.token !== undefined && json.user !== undefined) {
|
|
return json as T;
|
|
}
|
|
if (json.data !== undefined) {
|
|
return json.data as T;
|
|
}
|
|
return json as T;
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string) => request<T>("GET", path),
|
|
post: <T>(path: string, body?: Record<string, unknown>) =>
|
|
request<T>("POST", path, body),
|
|
put: <T>(path: string, body: Record<string, unknown>) =>
|
|
request<T>("PUT", path, body),
|
|
delete: <T>(path: string) => request<T>("DELETE", path),
|
|
};
|
|
|
|
export interface User {
|
|
_id: string;
|
|
email: string;
|
|
displayName: string;
|
|
flags: string[];
|
|
persona?: string;
|
|
}
|
|
|
|
export interface AuthResponse {
|
|
user: User;
|
|
token: string;
|
|
}
|
|
|
|
export interface ProjectSkill {
|
|
_id: string;
|
|
name: string;
|
|
content: string;
|
|
modes: string[];
|
|
}
|
|
|
|
export interface ProjectTask {
|
|
_id: string;
|
|
name: string;
|
|
provider: string;
|
|
selectedModel: string;
|
|
mode: string;
|
|
crontab: string;
|
|
content: string;
|
|
enabled: boolean;
|
|
lastRun?: string;
|
|
}
|
|
|
|
export interface Project {
|
|
_id: string;
|
|
createdAt: string;
|
|
user: string;
|
|
status: "active" | "inactive" | "archived";
|
|
name: string;
|
|
slug: string;
|
|
gitUrl?: string;
|
|
description?: string;
|
|
system?: string;
|
|
skills: ProjectSkill[];
|
|
tasks: ProjectTask[];
|
|
}
|
|
|
|
export const userApi = {
|
|
updateSettings: (data: {
|
|
displayName?: string;
|
|
currentPassword?: string;
|
|
password?: string;
|
|
persona?: string;
|
|
}) => api.put<User>("/api/v1/user/settings", data),
|
|
};
|
|
|
|
export const projectApi = {
|
|
getAll: () => api.get<Project[]>("/api/v1/projects"),
|
|
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),
|
|
create: (data: { name: string; slug: string; gitUrl?: string }) =>
|
|
api.post<Project>("/api/v1/projects", data),
|
|
pull: (gitUrl: string) =>
|
|
api.post<Project>("/api/v1/projects/pull", { gitUrl }),
|
|
update: (
|
|
id: string,
|
|
data: Partial<{
|
|
name: string;
|
|
slug: string;
|
|
gitUrl: string;
|
|
status: string;
|
|
description: string;
|
|
system: string;
|
|
skills: ProjectSkill[];
|
|
tasks: ProjectTask[];
|
|
}>,
|
|
) => api.put<Project>(`/api/v1/projects/${id}`, data),
|
|
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
|
};
|
|
|
|
export interface SubProcessStat {
|
|
pid: number;
|
|
command: string;
|
|
args: string[];
|
|
projectId: string;
|
|
projectSlug: string;
|
|
projectName: string;
|
|
status: "running" | "stopped" | "error";
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
stdoutFileName: string;
|
|
stderrFileName: string;
|
|
}
|
|
|
|
export interface DroneRegistration {
|
|
_id: string;
|
|
hostname: string;
|
|
workspaceDir: string;
|
|
status: "starting" | "available" | "busy" | "offline";
|
|
user: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export const droneApi = {
|
|
getAll: () => api.get<DroneRegistration[]>("/api/v1/drone/registration"),
|
|
getForManager: () =>
|
|
api.get<DroneRegistration[]>("/api/v1/drone/registration/manager"),
|
|
terminate: (registrationId: string) =>
|
|
api.post<ApiResponse>(
|
|
`/api/v1/drone/registration/${registrationId}/terminate`,
|
|
),
|
|
};
|
|
|
|
export interface AiModelCapabilities {
|
|
canCallTools: boolean;
|
|
hasVision: boolean;
|
|
hasEmbedding: boolean;
|
|
hasThinking: boolean;
|
|
isInstructTuned: boolean;
|
|
}
|
|
|
|
export interface AiModelSettings {
|
|
temperature?: number;
|
|
topP?: number;
|
|
topK?: number;
|
|
numCtx?: number;
|
|
}
|
|
|
|
export interface AiModel {
|
|
id: string;
|
|
name: string;
|
|
parameterCount?: number;
|
|
parameterLabel?: string;
|
|
contextWindow?: number;
|
|
capabilities: AiModelCapabilities;
|
|
settings?: AiModelSettings;
|
|
}
|
|
|
|
export interface AiProvider {
|
|
_id: string;
|
|
name: string;
|
|
apiType: "ollama" | "openai";
|
|
baseUrl: string;
|
|
enabled: boolean;
|
|
models: AiModel[];
|
|
lastModelRefresh: string;
|
|
}
|
|
|
|
export const providerApi = {
|
|
getAll: () => api.get<AiProvider[]>("/api/v1/providers"),
|
|
get: (id: string) => api.get<AiProvider>(`/api/v1/providers/${id}`),
|
|
};
|
|
|
|
export enum ChatSessionMode {
|
|
Plan = "plan",
|
|
Build = "build",
|
|
Test = "test",
|
|
Ship = "ship",
|
|
Develop = "dev",
|
|
}
|
|
|
|
export interface ChatSessionStats {
|
|
turnCount: number;
|
|
toolCallCount: number;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
}
|
|
|
|
export interface ChatSession {
|
|
_id: string;
|
|
createdAt: string;
|
|
lastMessageAt?: string;
|
|
user: string | any;
|
|
project: string | any;
|
|
name: string;
|
|
mode: ChatSessionMode;
|
|
provider: string | AiProvider;
|
|
selectedModel: string;
|
|
reasoningEffort?: string;
|
|
numCtx?: number;
|
|
contextWindowUsage: number;
|
|
stats: ChatSessionStats;
|
|
pins: Array<{ _id?: string; content: string }>;
|
|
}
|
|
|
|
export interface ChatTurnStats {
|
|
toolCallCount: number;
|
|
inputTokens: number;
|
|
thinkingTokenCount: number;
|
|
responseTokens: number;
|
|
durationMs: number;
|
|
durationLabel: string;
|
|
}
|
|
|
|
export interface IWorkOrderCompleteStats {
|
|
masterInputTokens: number;
|
|
masterOutputTokens: number;
|
|
masterThinkingTokens: number;
|
|
toolCallCount: number;
|
|
durationMs: number;
|
|
}
|
|
|
|
export interface ChatTurnPrompts {
|
|
user: string;
|
|
system?: string;
|
|
}
|
|
|
|
export interface ChatTurnBlockThinking {
|
|
mode: 'thinking';
|
|
createdAt: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface ChatTurnBlockResponding {
|
|
mode: 'responding';
|
|
createdAt: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface ChatTurnBlockTool {
|
|
mode: 'tool';
|
|
createdAt: string;
|
|
content: {
|
|
callId: string;
|
|
name: string;
|
|
parameters?: string;
|
|
response?: string;
|
|
subagent?: {
|
|
agentId: string;
|
|
thinking: string;
|
|
response: string;
|
|
toolCalls: Array<{
|
|
callId: string;
|
|
name: string;
|
|
parameters?: string;
|
|
response?: string;
|
|
}>;
|
|
stats: ChatTurnStats;
|
|
};
|
|
};
|
|
}
|
|
|
|
export type ChatTurnBlock = ChatTurnBlockThinking | ChatTurnBlockResponding | ChatTurnBlockTool;
|
|
|
|
export interface ChatTurn {
|
|
_id: string;
|
|
createdAt: string;
|
|
user: string | any;
|
|
project: string | any;
|
|
session: string | ChatSession;
|
|
provider: string | AiProvider;
|
|
llm: string;
|
|
numCtx?: number;
|
|
mode: ChatSessionMode;
|
|
status: "processing" | "finished" | "aborted" | "error";
|
|
prompts: ChatTurnPrompts;
|
|
blocks: ChatTurnBlock[];
|
|
errorMessage?: string;
|
|
toolCalls: Array<{
|
|
callId: string;
|
|
name: string;
|
|
parameters?: string;
|
|
response?: string;
|
|
subagent?: ChatTurnBlockTool['content']['subagent'];
|
|
}>;
|
|
subagents: any[];
|
|
stats: ChatTurnStats;
|
|
}
|
|
|
|
export const chatSessionApi = {
|
|
getAll: (projectId?: string, limit?: number) =>
|
|
api.get<ChatSession[]>(
|
|
`/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}${limit ? `${projectId ? "&" : "?"}limit=${limit}` : ""}`,
|
|
),
|
|
get: (id: string) => api.get<ChatSession>(`/api/v1/chat-sessions/${id}`),
|
|
create: (data: {
|
|
projectId: string;
|
|
providerId: string;
|
|
selectedModel: string;
|
|
mode?: ChatSessionMode;
|
|
name?: string;
|
|
}) => api.post<ChatSession>("/api/v1/chat-sessions", data),
|
|
update: (id: string, data: Partial<ChatSession>) =>
|
|
api.put<ChatSession>(`/api/v1/chat-sessions/${id}`, data),
|
|
delete: (id: string) => api.delete<void>(`/api/v1/chat-sessions/${id}`),
|
|
getTurns: (id: string) =>
|
|
api.get<ChatTurn[]>(`/api/v1/chat-sessions/${id}/turns`),
|
|
};
|