huskies: merge 840
This commit is contained in:
@@ -1,795 +0,0 @@
|
|||||||
export type WsRequest =
|
|
||||||
| {
|
|
||||||
type: "chat";
|
|
||||||
messages: Message[];
|
|
||||||
config: ProviderConfig;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "cancel";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "permission_response";
|
|
||||||
request_id: string;
|
|
||||||
approved: boolean;
|
|
||||||
always_allow: boolean;
|
|
||||||
}
|
|
||||||
| { type: "ping" }
|
|
||||||
| {
|
|
||||||
type: "side_question";
|
|
||||||
question: string;
|
|
||||||
context_messages: Message[];
|
|
||||||
config: ProviderConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface WizardStepInfo {
|
|
||||||
step: string;
|
|
||||||
label: string;
|
|
||||||
status: string;
|
|
||||||
content?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WizardStateData {
|
|
||||||
steps: WizardStepInfo[];
|
|
||||||
current_step_index: number;
|
|
||||||
completed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentAssignment {
|
|
||||||
agent_name: string;
|
|
||||||
model: string | null;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PipelineStageItem {
|
|
||||||
story_id: string;
|
|
||||||
name: string | null;
|
|
||||||
error: string | null;
|
|
||||||
merge_failure: string | null;
|
|
||||||
agent: AgentAssignment | null;
|
|
||||||
review_hold: boolean | null;
|
|
||||||
qa: string | null;
|
|
||||||
depends_on: number[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PipelineState {
|
|
||||||
backlog: PipelineStageItem[];
|
|
||||||
current: PipelineStageItem[];
|
|
||||||
qa: PipelineStageItem[];
|
|
||||||
merge: PipelineStageItem[];
|
|
||||||
done: PipelineStageItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WsResponse =
|
|
||||||
| { type: "token"; content: string }
|
|
||||||
| { type: "update"; messages: Message[] }
|
|
||||||
| { type: "session_id"; session_id: string }
|
|
||||||
| { type: "error"; message: string }
|
|
||||||
| {
|
|
||||||
type: "pipeline_state";
|
|
||||||
backlog: PipelineStageItem[];
|
|
||||||
current: PipelineStageItem[];
|
|
||||||
qa: PipelineStageItem[];
|
|
||||||
merge: PipelineStageItem[];
|
|
||||||
done: PipelineStageItem[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "permission_request";
|
|
||||||
request_id: string;
|
|
||||||
tool_name: string;
|
|
||||||
tool_input: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
| { type: "tool_activity"; tool_name: string }
|
|
||||||
| {
|
|
||||||
type: "reconciliation_progress";
|
|
||||||
story_id: string;
|
|
||||||
status: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
/** `.story_kit/project.toml` was modified; re-fetch the agent roster. */
|
|
||||||
| { type: "agent_config_changed" }
|
|
||||||
/** An agent started, stopped, or changed state; re-fetch agent list. */
|
|
||||||
| { type: "agent_state_changed" }
|
|
||||||
| { type: "tool_activity"; tool_name: string }
|
|
||||||
/** Heartbeat response confirming the connection is alive. */
|
|
||||||
| { type: "pong" }
|
|
||||||
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
|
||||||
| { type: "onboarding_status"; needs_onboarding: boolean }
|
|
||||||
/** Sent on connect when a setup wizard is active. */
|
|
||||||
| {
|
|
||||||
type: "wizard_state";
|
|
||||||
steps: WizardStepInfo[];
|
|
||||||
current_step_index: number;
|
|
||||||
completed: boolean;
|
|
||||||
}
|
|
||||||
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
|
||||||
| { type: "thinking_token"; content: string }
|
|
||||||
/** Streaming token from a /btw side question response. */
|
|
||||||
| { type: "side_question_token"; content: string }
|
|
||||||
/** Final signal that the /btw side question has been fully answered. */
|
|
||||||
| { type: "side_question_done"; response: string }
|
|
||||||
/** A single server log entry (bulk on connect, then live). */
|
|
||||||
| { type: "log_entry"; timestamp: string; level: string; message: string }
|
|
||||||
/** A structured pipeline status event from the status broadcaster. */
|
|
||||||
| { type: "status_update"; event: StatusEvent };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A structured pipeline status event emitted by the status broadcaster.
|
|
||||||
*
|
|
||||||
* The discriminant `type` field enables per-event-type rendering without
|
|
||||||
* parsing strings. All fields from the original event are preserved so
|
|
||||||
* future UI stories can add dedicated icons, banners, or filters.
|
|
||||||
*/
|
|
||||||
export type StatusEvent =
|
|
||||||
| {
|
|
||||||
type: "stage_transition";
|
|
||||||
story_id: string;
|
|
||||||
story_name: string | null;
|
|
||||||
from_stage: string;
|
|
||||||
to_stage: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "merge_failure";
|
|
||||||
story_id: string;
|
|
||||||
story_name: string | null;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "story_blocked";
|
|
||||||
story_id: string;
|
|
||||||
story_name: string | null;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "rate_limit_warning";
|
|
||||||
story_id: string;
|
|
||||||
story_name: string | null;
|
|
||||||
agent_name: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "rate_limit_hard_block";
|
|
||||||
story_id: string;
|
|
||||||
story_name: string | null;
|
|
||||||
agent_name: string;
|
|
||||||
reset_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ProviderConfig {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
base_url?: string;
|
|
||||||
enable_tools?: boolean;
|
|
||||||
session_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Role = "system" | "user" | "assistant" | "tool";
|
|
||||||
|
|
||||||
export interface ToolCall {
|
|
||||||
id?: string;
|
|
||||||
type: string;
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
arguments: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Message {
|
|
||||||
role: Role;
|
|
||||||
content: string;
|
|
||||||
tool_calls?: ToolCall[];
|
|
||||||
tool_call_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnthropicModelInfo {
|
|
||||||
id: string;
|
|
||||||
context_window: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkItemContent {
|
|
||||||
content: string;
|
|
||||||
stage: string;
|
|
||||||
name: string | null;
|
|
||||||
agent: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestCaseResult {
|
|
||||||
name: string;
|
|
||||||
status: "pass" | "fail";
|
|
||||||
details: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestResultsResponse {
|
|
||||||
unit: TestCaseResult[];
|
|
||||||
integration: TestCaseResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileEntry {
|
|
||||||
name: string;
|
|
||||||
kind: "file" | "dir";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
path: string;
|
|
||||||
matches: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentCostEntry {
|
|
||||||
agent_name: string;
|
|
||||||
model: string | null;
|
|
||||||
input_tokens: number;
|
|
||||||
output_tokens: number;
|
|
||||||
cache_creation_input_tokens: number;
|
|
||||||
cache_read_input_tokens: number;
|
|
||||||
total_cost_usd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenCostResponse {
|
|
||||||
total_cost_usd: number;
|
|
||||||
agents: AgentCostEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenUsageRecord {
|
|
||||||
story_id: string;
|
|
||||||
agent_name: string;
|
|
||||||
model: string | null;
|
|
||||||
timestamp: string;
|
|
||||||
input_tokens: number;
|
|
||||||
output_tokens: number;
|
|
||||||
cache_creation_input_tokens: number;
|
|
||||||
cache_read_input_tokens: number;
|
|
||||||
total_cost_usd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AllTokenUsageResponse {
|
|
||||||
records: TokenUsageRecord[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandOutput {
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
exit_code: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OAuthStatus {
|
|
||||||
authenticated: boolean;
|
|
||||||
expired: boolean;
|
|
||||||
expires_at: number;
|
|
||||||
has_refresh_token: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare const __HUSKIES_PORT__: string;
|
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
|
||||||
const DEFAULT_WS_PATH = "/ws";
|
|
||||||
|
|
||||||
export function resolveWsHost(
|
|
||||||
isDev: boolean,
|
|
||||||
envPort: string | undefined,
|
|
||||||
locationHost: string,
|
|
||||||
): string {
|
|
||||||
return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
|
||||||
return `${baseUrl}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJson<T>(
|
|
||||||
path: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
baseUrl = DEFAULT_API_BASE,
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await fetch(buildApiUrl(path, baseUrl), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(options.headers ?? {}),
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || `Request failed (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
getCurrentProject(baseUrl?: string) {
|
|
||||||
return requestJson<string | null>("/project", {}, baseUrl);
|
|
||||||
},
|
|
||||||
getKnownProjects(baseUrl?: string) {
|
|
||||||
return requestJson<string[]>("/projects", {}, baseUrl);
|
|
||||||
},
|
|
||||||
forgetKnownProject(path: string, baseUrl?: string) {
|
|
||||||
return requestJson<boolean>(
|
|
||||||
"/projects/forget",
|
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
openProject(path: string, baseUrl?: string) {
|
|
||||||
return requestJson<string>(
|
|
||||||
"/project",
|
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
closeProject(baseUrl?: string) {
|
|
||||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
|
||||||
},
|
|
||||||
getModelPreference(baseUrl?: string) {
|
|
||||||
return requestJson<string | null>("/model", {}, baseUrl);
|
|
||||||
},
|
|
||||||
setModelPreference(model: string, baseUrl?: string) {
|
|
||||||
return requestJson<boolean>(
|
|
||||||
"/model",
|
|
||||||
{ method: "POST", body: JSON.stringify({ model }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
|
|
||||||
const url = new URL(
|
|
||||||
buildApiUrl("/ollama/models", baseUrl),
|
|
||||||
window.location.origin,
|
|
||||||
);
|
|
||||||
if (baseUrlParam) {
|
|
||||||
url.searchParams.set("base_url", baseUrlParam);
|
|
||||||
}
|
|
||||||
return requestJson<string[]>(url.pathname + url.search, {}, "");
|
|
||||||
},
|
|
||||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
|
||||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
|
||||||
},
|
|
||||||
getAnthropicModels(baseUrl?: string) {
|
|
||||||
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
|
|
||||||
},
|
|
||||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
|
||||||
return requestJson<boolean>(
|
|
||||||
"/anthropic/key",
|
|
||||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
readFile(path: string, baseUrl?: string) {
|
|
||||||
return requestJson<string>(
|
|
||||||
"/fs/read",
|
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
writeFile(path: string, content: string, baseUrl?: string) {
|
|
||||||
return requestJson<boolean>(
|
|
||||||
"/fs/write",
|
|
||||||
{ method: "POST", body: JSON.stringify({ path, content }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
listDirectory(path: string, baseUrl?: string) {
|
|
||||||
return requestJson<FileEntry[]>(
|
|
||||||
"/fs/list",
|
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
listDirectoryAbsolute(path: string, baseUrl?: string) {
|
|
||||||
return requestJson<FileEntry[]>(
|
|
||||||
"/io/fs/list/absolute",
|
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
createDirectoryAbsolute(path: string, baseUrl?: string) {
|
|
||||||
return requestJson<boolean>(
|
|
||||||
"/io/fs/create/absolute",
|
|
||||||
{ method: "POST", body: JSON.stringify({ path }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getHomeDirectory(baseUrl?: string) {
|
|
||||||
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
|
||||||
},
|
|
||||||
listProjectFiles(baseUrl?: string) {
|
|
||||||
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
|
|
||||||
},
|
|
||||||
searchFiles(query: string, baseUrl?: string) {
|
|
||||||
return requestJson<SearchResult[]>(
|
|
||||||
"/fs/search",
|
|
||||||
{ method: "POST", body: JSON.stringify({ query }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
execShell(command: string, args: string[], baseUrl?: string) {
|
|
||||||
return requestJson<CommandOutput>(
|
|
||||||
"/shell/exec",
|
|
||||||
{ method: "POST", body: JSON.stringify({ command, args }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cancelChat(baseUrl?: string) {
|
|
||||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
|
||||||
},
|
|
||||||
getWorkItemContent(storyId: string, baseUrl?: string) {
|
|
||||||
return requestJson<WorkItemContent>(
|
|
||||||
`/work-items/${encodeURIComponent(storyId)}`,
|
|
||||||
{},
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getTestResults(storyId: string, baseUrl?: string) {
|
|
||||||
return requestJson<TestResultsResponse | null>(
|
|
||||||
`/work-items/${encodeURIComponent(storyId)}/test-results`,
|
|
||||||
{},
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getTokenCost(storyId: string, baseUrl?: string) {
|
|
||||||
return requestJson<TokenCostResponse>(
|
|
||||||
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
|
|
||||||
{},
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getAllTokenUsage(baseUrl?: string) {
|
|
||||||
return requestJson<AllTokenUsageResponse>("/token-usage", {}, baseUrl);
|
|
||||||
},
|
|
||||||
/** Trigger a server rebuild and restart. */
|
|
||||||
rebuildAndRestart() {
|
|
||||||
return callMcpTool("rebuild_and_restart", {});
|
|
||||||
},
|
|
||||||
/** Approve a story in QA, moving it to merge. */
|
|
||||||
approveQa(storyId: string) {
|
|
||||||
return callMcpTool("approve_qa", { story_id: storyId });
|
|
||||||
},
|
|
||||||
/** Reject a story in QA, moving it back to current with notes. */
|
|
||||||
rejectQa(storyId: string, notes: string) {
|
|
||||||
return callMcpTool("reject_qa", { story_id: storyId, notes });
|
|
||||||
},
|
|
||||||
/** Launch the QA app for a story's worktree. */
|
|
||||||
launchQaApp(storyId: string) {
|
|
||||||
return callMcpTool("launch_qa_app", { story_id: storyId });
|
|
||||||
},
|
|
||||||
/** Delete a story from the pipeline, stopping any running agent and removing the worktree. */
|
|
||||||
deleteStory(storyId: string) {
|
|
||||||
return callMcpTool("delete_story", { story_id: storyId });
|
|
||||||
},
|
|
||||||
/** Fetch OAuth status from the server. */
|
|
||||||
getOAuthStatus() {
|
|
||||||
return requestJson<OAuthStatus>("/oauth/status", {}, "");
|
|
||||||
},
|
|
||||||
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
|
||||||
botCommand(command: string, args: string, baseUrl?: string) {
|
|
||||||
return requestJson<{ response: string }>(
|
|
||||||
"/bot/command",
|
|
||||||
{ method: "POST", body: JSON.stringify({ command, args }) },
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
async function callMcpTool(
|
|
||||||
toolName: string,
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
): Promise<string> {
|
|
||||||
const res = await fetch("/mcp", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "tools/call",
|
|
||||||
params: { name: toolName, arguments: args },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.error) {
|
|
||||||
throw new Error(json.error.message);
|
|
||||||
}
|
|
||||||
const text = json.result?.content?.[0]?.text ?? "";
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatWebSocket {
|
|
||||||
private static sharedSocket: WebSocket | null = null;
|
|
||||||
private static refCount = 0;
|
|
||||||
private socket?: WebSocket;
|
|
||||||
private onToken?: (content: string) => void;
|
|
||||||
private onThinkingToken?: (content: string) => void;
|
|
||||||
private onUpdate?: (messages: Message[]) => void;
|
|
||||||
private onSessionId?: (sessionId: string) => void;
|
|
||||||
private onError?: (message: string) => void;
|
|
||||||
private onPipelineState?: (state: PipelineState) => void;
|
|
||||||
private onPermissionRequest?: (
|
|
||||||
requestId: string,
|
|
||||||
toolName: string,
|
|
||||||
toolInput: Record<string, unknown>,
|
|
||||||
) => void;
|
|
||||||
private onActivity?: (toolName: string) => void;
|
|
||||||
private onReconciliationProgress?: (
|
|
||||||
storyId: string,
|
|
||||||
status: string,
|
|
||||||
message: string,
|
|
||||||
) => void;
|
|
||||||
private onAgentConfigChanged?: () => void;
|
|
||||||
private onAgentStateChanged?: () => void;
|
|
||||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
|
||||||
private onWizardState?: (state: WizardStateData) => void;
|
|
||||||
private onSideQuestionToken?: (content: string) => void;
|
|
||||||
private onSideQuestionDone?: (response: string) => void;
|
|
||||||
private onLogEntry?: (
|
|
||||||
timestamp: string,
|
|
||||||
level: string,
|
|
||||||
message: string,
|
|
||||||
) => void;
|
|
||||||
private onStatusUpdate?: (event: StatusEvent) => void;
|
|
||||||
private onConnected?: () => void;
|
|
||||||
private connected = false;
|
|
||||||
private closeTimer?: number;
|
|
||||||
private wsPath = DEFAULT_WS_PATH;
|
|
||||||
private reconnectTimer?: number;
|
|
||||||
private reconnectDelay = 1000;
|
|
||||||
private shouldReconnect = false;
|
|
||||||
private heartbeatInterval?: number;
|
|
||||||
private heartbeatTimeout?: number;
|
|
||||||
private static readonly HEARTBEAT_INTERVAL = 30_000;
|
|
||||||
private static readonly HEARTBEAT_TIMEOUT = 5_000;
|
|
||||||
|
|
||||||
private _startHeartbeat(): void {
|
|
||||||
this._stopHeartbeat();
|
|
||||||
this.heartbeatInterval = window.setInterval(() => {
|
|
||||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
|
||||||
const ping: WsRequest = { type: "ping" };
|
|
||||||
this.socket.send(JSON.stringify(ping));
|
|
||||||
this.heartbeatTimeout = window.setTimeout(() => {
|
|
||||||
// No pong received within timeout; close socket to trigger reconnect.
|
|
||||||
this.socket?.close();
|
|
||||||
}, ChatWebSocket.HEARTBEAT_TIMEOUT);
|
|
||||||
}, ChatWebSocket.HEARTBEAT_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _stopHeartbeat(): void {
|
|
||||||
window.clearInterval(this.heartbeatInterval);
|
|
||||||
window.clearTimeout(this.heartbeatTimeout);
|
|
||||||
this.heartbeatInterval = undefined;
|
|
||||||
this.heartbeatTimeout = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _buildWsUrl(): string {
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
||||||
const wsHost = resolveWsHost(
|
|
||||||
import.meta.env.DEV,
|
|
||||||
typeof __HUSKIES_PORT__ !== "undefined" ? __HUSKIES_PORT__ : undefined,
|
|
||||||
window.location.host,
|
|
||||||
);
|
|
||||||
return `${protocol}://${wsHost}${this.wsPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _attachHandlers(): void {
|
|
||||||
if (!this.socket) return;
|
|
||||||
this.socket.onopen = () => {
|
|
||||||
this.reconnectDelay = 1000;
|
|
||||||
this._startHeartbeat();
|
|
||||||
this.onConnected?.();
|
|
||||||
};
|
|
||||||
this.socket.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data) as WsResponse;
|
|
||||||
if (data.type === "token") this.onToken?.(data.content);
|
|
||||||
if (data.type === "thinking_token")
|
|
||||||
this.onThinkingToken?.(data.content);
|
|
||||||
if (data.type === "update") this.onUpdate?.(data.messages);
|
|
||||||
if (data.type === "session_id") this.onSessionId?.(data.session_id);
|
|
||||||
if (data.type === "error") this.onError?.(data.message);
|
|
||||||
if (data.type === "pipeline_state")
|
|
||||||
this.onPipelineState?.({
|
|
||||||
backlog: data.backlog,
|
|
||||||
current: data.current,
|
|
||||||
qa: data.qa,
|
|
||||||
merge: data.merge,
|
|
||||||
done: data.done,
|
|
||||||
});
|
|
||||||
if (data.type === "permission_request")
|
|
||||||
this.onPermissionRequest?.(
|
|
||||||
data.request_id,
|
|
||||||
data.tool_name,
|
|
||||||
data.tool_input,
|
|
||||||
);
|
|
||||||
if (data.type === "tool_activity") this.onActivity?.(data.tool_name);
|
|
||||||
if (data.type === "reconciliation_progress")
|
|
||||||
this.onReconciliationProgress?.(
|
|
||||||
data.story_id,
|
|
||||||
data.status,
|
|
||||||
data.message,
|
|
||||||
);
|
|
||||||
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
|
|
||||||
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
|
||||||
if (data.type === "onboarding_status")
|
|
||||||
this.onOnboardingStatus?.(data.needs_onboarding);
|
|
||||||
if (data.type === "wizard_state")
|
|
||||||
this.onWizardState?.({
|
|
||||||
steps: data.steps,
|
|
||||||
current_step_index: data.current_step_index,
|
|
||||||
completed: data.completed,
|
|
||||||
});
|
|
||||||
if (data.type === "side_question_token")
|
|
||||||
this.onSideQuestionToken?.(data.content);
|
|
||||||
if (data.type === "side_question_done")
|
|
||||||
this.onSideQuestionDone?.(data.response);
|
|
||||||
if (data.type === "log_entry")
|
|
||||||
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
|
||||||
if (data.type === "status_update") this.onStatusUpdate?.(data.event);
|
|
||||||
if (data.type === "pong") {
|
|
||||||
window.clearTimeout(this.heartbeatTimeout);
|
|
||||||
this.heartbeatTimeout = undefined;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.onError?.(String(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.socket.onerror = () => {
|
|
||||||
this.onError?.("WebSocket error");
|
|
||||||
};
|
|
||||||
this.socket.onclose = () => {
|
|
||||||
if (this.shouldReconnect && this.connected) {
|
|
||||||
this._scheduleReconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private _scheduleReconnect(): void {
|
|
||||||
window.clearTimeout(this.reconnectTimer);
|
|
||||||
const delay = this.reconnectDelay;
|
|
||||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
|
||||||
this.reconnectTimer = window.setTimeout(() => {
|
|
||||||
this.reconnectTimer = undefined;
|
|
||||||
const wsUrl = this._buildWsUrl();
|
|
||||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
|
||||||
this.socket = ChatWebSocket.sharedSocket;
|
|
||||||
this._attachHandlers();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
connect(
|
|
||||||
handlers: {
|
|
||||||
onToken?: (content: string) => void;
|
|
||||||
onThinkingToken?: (content: string) => void;
|
|
||||||
onUpdate?: (messages: Message[]) => void;
|
|
||||||
onSessionId?: (sessionId: string) => void;
|
|
||||||
onError?: (message: string) => void;
|
|
||||||
onPipelineState?: (state: PipelineState) => void;
|
|
||||||
onPermissionRequest?: (
|
|
||||||
requestId: string,
|
|
||||||
toolName: string,
|
|
||||||
toolInput: Record<string, unknown>,
|
|
||||||
) => void;
|
|
||||||
onActivity?: (toolName: string) => void;
|
|
||||||
onReconciliationProgress?: (
|
|
||||||
storyId: string,
|
|
||||||
status: string,
|
|
||||||
message: string,
|
|
||||||
) => void;
|
|
||||||
onAgentConfigChanged?: () => void;
|
|
||||||
onAgentStateChanged?: () => void;
|
|
||||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
|
||||||
onWizardState?: (state: WizardStateData) => void;
|
|
||||||
onSideQuestionToken?: (content: string) => void;
|
|
||||||
onSideQuestionDone?: (response: string) => void;
|
|
||||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
|
||||||
onStatusUpdate?: (event: StatusEvent) => void;
|
|
||||||
onConnected?: () => void;
|
|
||||||
},
|
|
||||||
wsPath = DEFAULT_WS_PATH,
|
|
||||||
) {
|
|
||||||
this.onToken = handlers.onToken;
|
|
||||||
this.onThinkingToken = handlers.onThinkingToken;
|
|
||||||
this.onUpdate = handlers.onUpdate;
|
|
||||||
this.onSessionId = handlers.onSessionId;
|
|
||||||
this.onError = handlers.onError;
|
|
||||||
this.onPipelineState = handlers.onPipelineState;
|
|
||||||
this.onPermissionRequest = handlers.onPermissionRequest;
|
|
||||||
this.onActivity = handlers.onActivity;
|
|
||||||
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
|
||||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
|
||||||
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
|
||||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
|
||||||
this.onWizardState = handlers.onWizardState;
|
|
||||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
|
||||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
|
||||||
this.onLogEntry = handlers.onLogEntry;
|
|
||||||
this.onStatusUpdate = handlers.onStatusUpdate;
|
|
||||||
this.onConnected = handlers.onConnected;
|
|
||||||
this.wsPath = wsPath;
|
|
||||||
this.shouldReconnect = true;
|
|
||||||
|
|
||||||
if (this.connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.connected = true;
|
|
||||||
ChatWebSocket.refCount += 1;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!ChatWebSocket.sharedSocket ||
|
|
||||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
|
|
||||||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
|
|
||||||
) {
|
|
||||||
const wsUrl = this._buildWsUrl();
|
|
||||||
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
|
||||||
}
|
|
||||||
this.socket = ChatWebSocket.sharedSocket;
|
|
||||||
this._attachHandlers();
|
|
||||||
}
|
|
||||||
|
|
||||||
sendChat(messages: Message[], config: ProviderConfig) {
|
|
||||||
this.send({ type: "chat", messages, config });
|
|
||||||
}
|
|
||||||
|
|
||||||
sendSideQuestion(
|
|
||||||
question: string,
|
|
||||||
contextMessages: Message[],
|
|
||||||
config: ProviderConfig,
|
|
||||||
) {
|
|
||||||
this.send({
|
|
||||||
type: "side_question",
|
|
||||||
question,
|
|
||||||
context_messages: contextMessages,
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.send({ type: "cancel" });
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPermissionResponse(
|
|
||||||
requestId: string,
|
|
||||||
approved: boolean,
|
|
||||||
alwaysAllow = false,
|
|
||||||
) {
|
|
||||||
this.send({
|
|
||||||
type: "permission_response",
|
|
||||||
request_id: requestId,
|
|
||||||
approved,
|
|
||||||
always_allow: alwaysAllow,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.shouldReconnect = false;
|
|
||||||
this._stopHeartbeat();
|
|
||||||
window.clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = undefined;
|
|
||||||
|
|
||||||
if (!this.connected) return;
|
|
||||||
this.connected = false;
|
|
||||||
ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1);
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
if (this.closeTimer) {
|
|
||||||
window.clearTimeout(this.closeTimer);
|
|
||||||
}
|
|
||||||
this.closeTimer = window.setTimeout(() => {
|
|
||||||
if (ChatWebSocket.refCount === 0) {
|
|
||||||
ChatWebSocket.sharedSocket?.close();
|
|
||||||
ChatWebSocket.sharedSocket = null;
|
|
||||||
}
|
|
||||||
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
|
||||||
this.closeTimer = undefined;
|
|
||||||
}, 250);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ChatWebSocket.refCount === 0) {
|
|
||||||
ChatWebSocket.sharedSocket?.close();
|
|
||||||
ChatWebSocket.sharedSocket = null;
|
|
||||||
}
|
|
||||||
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private send(payload: WsRequest) {
|
|
||||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
||||||
this.onError?.("WebSocket is not connected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.socket.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* HTTP transport layer for the Huskies API client.
|
||||||
|
* Provides the low-level `requestJson` helper, the `callMcpTool` function
|
||||||
|
* for MCP JSON-RPC calls, the `resolveWsHost` utility, and the `api`
|
||||||
|
* object exposing all REST endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AllTokenUsageResponse,
|
||||||
|
AnthropicModelInfo,
|
||||||
|
CommandOutput,
|
||||||
|
FileEntry,
|
||||||
|
OAuthStatus,
|
||||||
|
SearchResult,
|
||||||
|
TestResultsResponse,
|
||||||
|
TokenCostResponse,
|
||||||
|
WorkItemContent,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** Base URL prefix for all REST API requests in production. */
|
||||||
|
export const DEFAULT_API_BASE = "/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the WebSocket host to connect to.
|
||||||
|
* In development, uses the injected port (or 3001); in production, uses the
|
||||||
|
* current page's host so the socket connects to the same origin.
|
||||||
|
*/
|
||||||
|
export function resolveWsHost(
|
||||||
|
isDev: boolean,
|
||||||
|
envPort: string | undefined,
|
||||||
|
locationHost: string,
|
||||||
|
): string {
|
||||||
|
return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
|
return `${baseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
baseUrl = DEFAULT_API_BASE,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke an MCP tool via the server's JSON-RPC `/mcp` endpoint.
|
||||||
|
* Returns the first text content block from the tool result, or an empty
|
||||||
|
* string if the result has no content.
|
||||||
|
*/
|
||||||
|
export async function callMcpTool(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch("/mcp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: toolName, arguments: args },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.error) {
|
||||||
|
throw new Error(json.error.message);
|
||||||
|
}
|
||||||
|
const text = json.result?.content?.[0]?.text ?? "";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed REST and MCP wrappers for all Huskies server endpoints. */
|
||||||
|
export const api = {
|
||||||
|
getCurrentProject(baseUrl?: string) {
|
||||||
|
return requestJson<string | null>("/project", {}, baseUrl);
|
||||||
|
},
|
||||||
|
getKnownProjects(baseUrl?: string) {
|
||||||
|
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||||
|
},
|
||||||
|
forgetKnownProject(path: string, baseUrl?: string) {
|
||||||
|
return requestJson<boolean>(
|
||||||
|
"/projects/forget",
|
||||||
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
openProject(path: string, baseUrl?: string) {
|
||||||
|
return requestJson<string>(
|
||||||
|
"/project",
|
||||||
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
closeProject(baseUrl?: string) {
|
||||||
|
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||||
|
},
|
||||||
|
getModelPreference(baseUrl?: string) {
|
||||||
|
return requestJson<string | null>("/model", {}, baseUrl);
|
||||||
|
},
|
||||||
|
setModelPreference(model: string, baseUrl?: string) {
|
||||||
|
return requestJson<boolean>(
|
||||||
|
"/model",
|
||||||
|
{ method: "POST", body: JSON.stringify({ model }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
|
||||||
|
const url = new URL(
|
||||||
|
buildApiUrl("/ollama/models", baseUrl),
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
if (baseUrlParam) {
|
||||||
|
url.searchParams.set("base_url", baseUrlParam);
|
||||||
|
}
|
||||||
|
return requestJson<string[]>(url.pathname + url.search, {}, "");
|
||||||
|
},
|
||||||
|
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||||
|
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||||
|
},
|
||||||
|
getAnthropicModels(baseUrl?: string) {
|
||||||
|
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
|
||||||
|
},
|
||||||
|
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||||
|
return requestJson<boolean>(
|
||||||
|
"/anthropic/key",
|
||||||
|
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readFile(path: string, baseUrl?: string) {
|
||||||
|
return requestJson<string>(
|
||||||
|
"/fs/read",
|
||||||
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
writeFile(path: string, content: string, baseUrl?: string) {
|
||||||
|
return requestJson<boolean>(
|
||||||
|
"/fs/write",
|
||||||
|
{ method: "POST", body: JSON.stringify({ path, content }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
listDirectory(path: string, baseUrl?: string) {
|
||||||
|
return requestJson<FileEntry[]>(
|
||||||
|
"/fs/list",
|
||||||
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
listDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||||
|
return requestJson<FileEntry[]>(
|
||||||
|
"/io/fs/list/absolute",
|
||||||
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||||
|
return requestJson<boolean>(
|
||||||
|
"/io/fs/create/absolute",
|
||||||
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getHomeDirectory(baseUrl?: string) {
|
||||||
|
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
||||||
|
},
|
||||||
|
listProjectFiles(baseUrl?: string) {
|
||||||
|
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
|
||||||
|
},
|
||||||
|
searchFiles(query: string, baseUrl?: string) {
|
||||||
|
return requestJson<SearchResult[]>(
|
||||||
|
"/fs/search",
|
||||||
|
{ method: "POST", body: JSON.stringify({ query }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
execShell(command: string, args: string[], baseUrl?: string) {
|
||||||
|
return requestJson<CommandOutput>(
|
||||||
|
"/shell/exec",
|
||||||
|
{ method: "POST", body: JSON.stringify({ command, args }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cancelChat(baseUrl?: string) {
|
||||||
|
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||||
|
},
|
||||||
|
getWorkItemContent(storyId: string, baseUrl?: string) {
|
||||||
|
return requestJson<WorkItemContent>(
|
||||||
|
`/work-items/${encodeURIComponent(storyId)}`,
|
||||||
|
{},
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getTestResults(storyId: string, baseUrl?: string) {
|
||||||
|
return requestJson<TestResultsResponse | null>(
|
||||||
|
`/work-items/${encodeURIComponent(storyId)}/test-results`,
|
||||||
|
{},
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getTokenCost(storyId: string, baseUrl?: string) {
|
||||||
|
return requestJson<TokenCostResponse>(
|
||||||
|
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
|
||||||
|
{},
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getAllTokenUsage(baseUrl?: string) {
|
||||||
|
return requestJson<AllTokenUsageResponse>("/token-usage", {}, baseUrl);
|
||||||
|
},
|
||||||
|
/** Trigger a server rebuild and restart. */
|
||||||
|
rebuildAndRestart() {
|
||||||
|
return callMcpTool("rebuild_and_restart", {});
|
||||||
|
},
|
||||||
|
/** Approve a story in QA, moving it to merge. */
|
||||||
|
approveQa(storyId: string) {
|
||||||
|
return callMcpTool("approve_qa", { story_id: storyId });
|
||||||
|
},
|
||||||
|
/** Reject a story in QA, moving it back to current with notes. */
|
||||||
|
rejectQa(storyId: string, notes: string) {
|
||||||
|
return callMcpTool("reject_qa", { story_id: storyId, notes });
|
||||||
|
},
|
||||||
|
/** Launch the QA app for a story's worktree. */
|
||||||
|
launchQaApp(storyId: string) {
|
||||||
|
return callMcpTool("launch_qa_app", { story_id: storyId });
|
||||||
|
},
|
||||||
|
/** Delete a story from the pipeline, stopping any running agent and removing the worktree. */
|
||||||
|
deleteStory(storyId: string) {
|
||||||
|
return callMcpTool("delete_story", { story_id: storyId });
|
||||||
|
},
|
||||||
|
/** Fetch OAuth status from the server. */
|
||||||
|
getOAuthStatus() {
|
||||||
|
return requestJson<OAuthStatus>("/oauth/status", {}, "");
|
||||||
|
},
|
||||||
|
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
||||||
|
botCommand(command: string, args: string, baseUrl?: string) {
|
||||||
|
return requestJson<{ response: string }>(
|
||||||
|
"/bot/command",
|
||||||
|
{ method: "POST", body: JSON.stringify({ command, args }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Public API surface for the Huskies client module.
|
||||||
|
* Re-exports all types, HTTP helpers, and the WebSocket client so that
|
||||||
|
* callers importing from `api/client` continue to work without changes
|
||||||
|
* after the module was decomposed into focused submodules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** All domain types and interfaces from the client module. */
|
||||||
|
export type {
|
||||||
|
AgentAssignment,
|
||||||
|
AgentCostEntry,
|
||||||
|
AllTokenUsageResponse,
|
||||||
|
AnthropicModelInfo,
|
||||||
|
CommandOutput,
|
||||||
|
FileEntry,
|
||||||
|
Message,
|
||||||
|
OAuthStatus,
|
||||||
|
PipelineState,
|
||||||
|
PipelineStageItem,
|
||||||
|
ProviderConfig,
|
||||||
|
Role,
|
||||||
|
SearchResult,
|
||||||
|
StatusEvent,
|
||||||
|
TestCaseResult,
|
||||||
|
TestResultsResponse,
|
||||||
|
TokenCostResponse,
|
||||||
|
TokenUsageRecord,
|
||||||
|
ToolCall,
|
||||||
|
WizardStateData,
|
||||||
|
WizardStepInfo,
|
||||||
|
WorkItemContent,
|
||||||
|
WsRequest,
|
||||||
|
WsResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { api, callMcpTool, DEFAULT_API_BASE, resolveWsHost } from "./http";
|
||||||
|
|
||||||
|
export { ChatWebSocket } from "./websocket";
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Type and interface definitions for the Huskies API client.
|
||||||
|
* All shared domain types — WebSocket messages, pipeline state,
|
||||||
|
* provider configuration, and response shapes — live here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A message sent from the browser to the Huskies server over WebSocket. */
|
||||||
|
export type WsRequest =
|
||||||
|
| {
|
||||||
|
type: "chat";
|
||||||
|
messages: Message[];
|
||||||
|
config: ProviderConfig;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "cancel";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "permission_response";
|
||||||
|
request_id: string;
|
||||||
|
approved: boolean;
|
||||||
|
always_allow: boolean;
|
||||||
|
}
|
||||||
|
| { type: "ping" }
|
||||||
|
| {
|
||||||
|
type: "side_question";
|
||||||
|
question: string;
|
||||||
|
context_messages: Message[];
|
||||||
|
config: ProviderConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Metadata for a single step in the setup wizard flow. */
|
||||||
|
export interface WizardStepInfo {
|
||||||
|
step: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full state snapshot of the setup wizard, including all steps and completion flag. */
|
||||||
|
export interface WizardStateData {
|
||||||
|
steps: WizardStepInfo[];
|
||||||
|
current_step_index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Describes the agent currently assigned to a pipeline work item. */
|
||||||
|
export interface AgentAssignment {
|
||||||
|
agent_name: string;
|
||||||
|
model: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single item in any pipeline stage (backlog, current, QA, merge, or done). */
|
||||||
|
export interface PipelineStageItem {
|
||||||
|
story_id: string;
|
||||||
|
name: string | null;
|
||||||
|
error: string | null;
|
||||||
|
merge_failure: string | null;
|
||||||
|
agent: AgentAssignment | null;
|
||||||
|
review_hold: boolean | null;
|
||||||
|
qa: string | null;
|
||||||
|
depends_on: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot of all pipeline stages returned via WebSocket or REST. */
|
||||||
|
export interface PipelineState {
|
||||||
|
backlog: PipelineStageItem[];
|
||||||
|
current: PipelineStageItem[];
|
||||||
|
qa: PipelineStageItem[];
|
||||||
|
merge: PipelineStageItem[];
|
||||||
|
done: PipelineStageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message received from the Huskies server over WebSocket. */
|
||||||
|
export type WsResponse =
|
||||||
|
| { type: "token"; content: string }
|
||||||
|
| { type: "update"; messages: Message[] }
|
||||||
|
| { type: "session_id"; session_id: string }
|
||||||
|
| { type: "error"; message: string }
|
||||||
|
| {
|
||||||
|
type: "pipeline_state";
|
||||||
|
backlog: PipelineStageItem[];
|
||||||
|
current: PipelineStageItem[];
|
||||||
|
qa: PipelineStageItem[];
|
||||||
|
merge: PipelineStageItem[];
|
||||||
|
done: PipelineStageItem[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "permission_request";
|
||||||
|
request_id: string;
|
||||||
|
tool_name: string;
|
||||||
|
tool_input: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| { type: "tool_activity"; tool_name: string }
|
||||||
|
| {
|
||||||
|
type: "reconciliation_progress";
|
||||||
|
story_id: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
/** `.story_kit/project.toml` was modified; re-fetch the agent roster. */
|
||||||
|
| { type: "agent_config_changed" }
|
||||||
|
/** An agent started, stopped, or changed state; re-fetch agent list. */
|
||||||
|
| { type: "agent_state_changed" }
|
||||||
|
/** Heartbeat response confirming the connection is alive. */
|
||||||
|
| { type: "pong" }
|
||||||
|
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
||||||
|
| { type: "onboarding_status"; needs_onboarding: boolean }
|
||||||
|
/** Sent on connect when a setup wizard is active. */
|
||||||
|
| {
|
||||||
|
type: "wizard_state";
|
||||||
|
steps: WizardStepInfo[];
|
||||||
|
current_step_index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
||||||
|
| { type: "thinking_token"; content: string }
|
||||||
|
/** Streaming token from a /btw side question response. */
|
||||||
|
| { type: "side_question_token"; content: string }
|
||||||
|
/** Final signal that the /btw side question has been fully answered. */
|
||||||
|
| { type: "side_question_done"; response: string }
|
||||||
|
/** A single server log entry (bulk on connect, then live). */
|
||||||
|
| { type: "log_entry"; timestamp: string; level: string; message: string }
|
||||||
|
/** A structured pipeline status event from the status broadcaster. */
|
||||||
|
| { type: "status_update"; event: StatusEvent };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structured pipeline status event emitted by the status broadcaster.
|
||||||
|
*
|
||||||
|
* The discriminant `type` field enables per-event-type rendering without
|
||||||
|
* parsing strings. All fields from the original event are preserved so
|
||||||
|
* future UI stories can add dedicated icons, banners, or filters.
|
||||||
|
*/
|
||||||
|
export type StatusEvent =
|
||||||
|
| {
|
||||||
|
type: "stage_transition";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
from_stage: string;
|
||||||
|
to_stage: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "merge_failure";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "story_blocked";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "rate_limit_warning";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
agent_name: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "rate_limit_hard_block";
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
agent_name: string;
|
||||||
|
reset_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** LLM provider configuration used when initiating a chat request. */
|
||||||
|
export interface ProviderConfig {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
base_url?: string;
|
||||||
|
enable_tools?: boolean;
|
||||||
|
session_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valid role values for a chat message. */
|
||||||
|
export type Role = "system" | "user" | "assistant" | "tool";
|
||||||
|
|
||||||
|
/** An LLM tool call embedded in an assistant message. */
|
||||||
|
export interface ToolCall {
|
||||||
|
id?: string;
|
||||||
|
type: string;
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single chat message exchanged with the LLM. */
|
||||||
|
export interface Message {
|
||||||
|
role: Role;
|
||||||
|
content: string;
|
||||||
|
tool_calls?: ToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anthropic model metadata returned by the models endpoint. */
|
||||||
|
export interface AnthropicModelInfo {
|
||||||
|
id: string;
|
||||||
|
context_window: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content and metadata for a pipeline work item fetched from the server. */
|
||||||
|
export interface WorkItemContent {
|
||||||
|
content: string;
|
||||||
|
stage: string;
|
||||||
|
name: string | null;
|
||||||
|
agent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result for a single test case from the server's test runner. */
|
||||||
|
export interface TestCaseResult {
|
||||||
|
name: string;
|
||||||
|
status: "pass" | "fail";
|
||||||
|
details: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combined unit and integration test results for a work item. */
|
||||||
|
export interface TestResultsResponse {
|
||||||
|
unit: TestCaseResult[];
|
||||||
|
integration: TestCaseResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file-system entry (file or directory) returned by listing endpoints. */
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
kind: "file" | "dir";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single file-search match with path and match count. */
|
||||||
|
export interface SearchResult {
|
||||||
|
path: string;
|
||||||
|
matches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-agent token usage and cost breakdown within a story. */
|
||||||
|
export interface AgentCostEntry {
|
||||||
|
agent_name: string;
|
||||||
|
model: string | null;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_creation_input_tokens: number;
|
||||||
|
cache_read_input_tokens: number;
|
||||||
|
total_cost_usd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total token cost for a work item, broken down by agent. */
|
||||||
|
export interface TokenCostResponse {
|
||||||
|
total_cost_usd: number;
|
||||||
|
agents: AgentCostEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single token-usage record from the server's usage log. */
|
||||||
|
export interface TokenUsageRecord {
|
||||||
|
story_id: string;
|
||||||
|
agent_name: string;
|
||||||
|
model: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_creation_input_tokens: number;
|
||||||
|
cache_read_input_tokens: number;
|
||||||
|
total_cost_usd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All token-usage records returned by the usage endpoint. */
|
||||||
|
export interface AllTokenUsageResponse {
|
||||||
|
records: TokenUsageRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output captured from a shell command executed on the server. */
|
||||||
|
export interface CommandOutput {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exit_code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth authentication status returned by the server. */
|
||||||
|
export interface OAuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
expired: boolean;
|
||||||
|
expires_at: number;
|
||||||
|
has_refresh_token: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket client for real-time communication with the Huskies server.
|
||||||
|
* Manages a shared socket with reference counting, automatic reconnection,
|
||||||
|
* and heartbeat keepalive. All inbound message types are dispatched to
|
||||||
|
* caller-supplied handler callbacks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveWsHost } from "./http";
|
||||||
|
import type {
|
||||||
|
Message,
|
||||||
|
PipelineState,
|
||||||
|
ProviderConfig,
|
||||||
|
StatusEvent,
|
||||||
|
WizardStateData,
|
||||||
|
WsRequest,
|
||||||
|
WsResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
declare const __HUSKIES_PORT__: string;
|
||||||
|
|
||||||
|
const DEFAULT_WS_PATH = "/ws";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton-backed WebSocket client with automatic reconnection and heartbeat.
|
||||||
|
* Multiple callers share one underlying socket via reference counting; the
|
||||||
|
* socket is closed only when the last caller disconnects.
|
||||||
|
*/
|
||||||
|
export class ChatWebSocket {
|
||||||
|
private static sharedSocket: WebSocket | null = null;
|
||||||
|
private static refCount = 0;
|
||||||
|
private socket?: WebSocket;
|
||||||
|
private onToken?: (content: string) => void;
|
||||||
|
private onThinkingToken?: (content: string) => void;
|
||||||
|
private onUpdate?: (messages: Message[]) => void;
|
||||||
|
private onSessionId?: (sessionId: string) => void;
|
||||||
|
private onError?: (message: string) => void;
|
||||||
|
private onPipelineState?: (state: PipelineState) => void;
|
||||||
|
private onPermissionRequest?: (
|
||||||
|
requestId: string,
|
||||||
|
toolName: string,
|
||||||
|
toolInput: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
private onActivity?: (toolName: string) => void;
|
||||||
|
private onReconciliationProgress?: (
|
||||||
|
storyId: string,
|
||||||
|
status: string,
|
||||||
|
message: string,
|
||||||
|
) => void;
|
||||||
|
private onAgentConfigChanged?: () => void;
|
||||||
|
private onAgentStateChanged?: () => void;
|
||||||
|
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
|
private onWizardState?: (state: WizardStateData) => void;
|
||||||
|
private onSideQuestionToken?: (content: string) => void;
|
||||||
|
private onSideQuestionDone?: (response: string) => void;
|
||||||
|
private onLogEntry?: (
|
||||||
|
timestamp: string,
|
||||||
|
level: string,
|
||||||
|
message: string,
|
||||||
|
) => void;
|
||||||
|
private onStatusUpdate?: (event: StatusEvent) => void;
|
||||||
|
private onConnected?: () => void;
|
||||||
|
private connected = false;
|
||||||
|
private closeTimer?: number;
|
||||||
|
private wsPath = DEFAULT_WS_PATH;
|
||||||
|
private reconnectTimer?: number;
|
||||||
|
private reconnectDelay = 1000;
|
||||||
|
private shouldReconnect = false;
|
||||||
|
private heartbeatInterval?: number;
|
||||||
|
private heartbeatTimeout?: number;
|
||||||
|
private static readonly HEARTBEAT_INTERVAL = 30_000;
|
||||||
|
private static readonly HEARTBEAT_TIMEOUT = 5_000;
|
||||||
|
|
||||||
|
private _startHeartbeat(): void {
|
||||||
|
this._stopHeartbeat();
|
||||||
|
this.heartbeatInterval = window.setInterval(() => {
|
||||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
||||||
|
const ping: WsRequest = { type: "ping" };
|
||||||
|
this.socket.send(JSON.stringify(ping));
|
||||||
|
this.heartbeatTimeout = window.setTimeout(() => {
|
||||||
|
// No pong received within timeout; close socket to trigger reconnect.
|
||||||
|
this.socket?.close();
|
||||||
|
}, ChatWebSocket.HEARTBEAT_TIMEOUT);
|
||||||
|
}, ChatWebSocket.HEARTBEAT_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stopHeartbeat(): void {
|
||||||
|
window.clearInterval(this.heartbeatInterval);
|
||||||
|
window.clearTimeout(this.heartbeatTimeout);
|
||||||
|
this.heartbeatInterval = undefined;
|
||||||
|
this.heartbeatTimeout = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildWsUrl(): string {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const wsHost = resolveWsHost(
|
||||||
|
import.meta.env.DEV,
|
||||||
|
typeof __HUSKIES_PORT__ !== "undefined" ? __HUSKIES_PORT__ : undefined,
|
||||||
|
window.location.host,
|
||||||
|
);
|
||||||
|
return `${protocol}://${wsHost}${this.wsPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _attachHandlers(): void {
|
||||||
|
if (!this.socket) return;
|
||||||
|
this.socket.onopen = () => {
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
this._startHeartbeat();
|
||||||
|
this.onConnected?.();
|
||||||
|
};
|
||||||
|
this.socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as WsResponse;
|
||||||
|
if (data.type === "token") this.onToken?.(data.content);
|
||||||
|
if (data.type === "thinking_token")
|
||||||
|
this.onThinkingToken?.(data.content);
|
||||||
|
if (data.type === "update") this.onUpdate?.(data.messages);
|
||||||
|
if (data.type === "session_id") this.onSessionId?.(data.session_id);
|
||||||
|
if (data.type === "error") this.onError?.(data.message);
|
||||||
|
if (data.type === "pipeline_state")
|
||||||
|
this.onPipelineState?.({
|
||||||
|
backlog: data.backlog,
|
||||||
|
current: data.current,
|
||||||
|
qa: data.qa,
|
||||||
|
merge: data.merge,
|
||||||
|
done: data.done,
|
||||||
|
});
|
||||||
|
if (data.type === "permission_request")
|
||||||
|
this.onPermissionRequest?.(
|
||||||
|
data.request_id,
|
||||||
|
data.tool_name,
|
||||||
|
data.tool_input,
|
||||||
|
);
|
||||||
|
if (data.type === "tool_activity") this.onActivity?.(data.tool_name);
|
||||||
|
if (data.type === "reconciliation_progress")
|
||||||
|
this.onReconciliationProgress?.(
|
||||||
|
data.story_id,
|
||||||
|
data.status,
|
||||||
|
data.message,
|
||||||
|
);
|
||||||
|
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
|
||||||
|
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
||||||
|
if (data.type === "onboarding_status")
|
||||||
|
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||||
|
if (data.type === "wizard_state")
|
||||||
|
this.onWizardState?.({
|
||||||
|
steps: data.steps,
|
||||||
|
current_step_index: data.current_step_index,
|
||||||
|
completed: data.completed,
|
||||||
|
});
|
||||||
|
if (data.type === "side_question_token")
|
||||||
|
this.onSideQuestionToken?.(data.content);
|
||||||
|
if (data.type === "side_question_done")
|
||||||
|
this.onSideQuestionDone?.(data.response);
|
||||||
|
if (data.type === "log_entry")
|
||||||
|
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
||||||
|
if (data.type === "status_update") this.onStatusUpdate?.(data.event);
|
||||||
|
if (data.type === "pong") {
|
||||||
|
window.clearTimeout(this.heartbeatTimeout);
|
||||||
|
this.heartbeatTimeout = undefined;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.onError?.(String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.socket.onerror = () => {
|
||||||
|
this.onError?.("WebSocket error");
|
||||||
|
};
|
||||||
|
this.socket.onclose = () => {
|
||||||
|
if (this.shouldReconnect && this.connected) {
|
||||||
|
this._scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleReconnect(): void {
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
const delay = this.reconnectDelay;
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||||
|
this.reconnectTimer = window.setTimeout(() => {
|
||||||
|
this.reconnectTimer = undefined;
|
||||||
|
const wsUrl = this._buildWsUrl();
|
||||||
|
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||||
|
this.socket = ChatWebSocket.sharedSocket;
|
||||||
|
this._attachHandlers();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(
|
||||||
|
handlers: {
|
||||||
|
onToken?: (content: string) => void;
|
||||||
|
onThinkingToken?: (content: string) => void;
|
||||||
|
onUpdate?: (messages: Message[]) => void;
|
||||||
|
onSessionId?: (sessionId: string) => void;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
onPipelineState?: (state: PipelineState) => void;
|
||||||
|
onPermissionRequest?: (
|
||||||
|
requestId: string,
|
||||||
|
toolName: string,
|
||||||
|
toolInput: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
onActivity?: (toolName: string) => void;
|
||||||
|
onReconciliationProgress?: (
|
||||||
|
storyId: string,
|
||||||
|
status: string,
|
||||||
|
message: string,
|
||||||
|
) => void;
|
||||||
|
onAgentConfigChanged?: () => void;
|
||||||
|
onAgentStateChanged?: () => void;
|
||||||
|
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
|
onWizardState?: (state: WizardStateData) => void;
|
||||||
|
onSideQuestionToken?: (content: string) => void;
|
||||||
|
onSideQuestionDone?: (response: string) => void;
|
||||||
|
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||||
|
onStatusUpdate?: (event: StatusEvent) => void;
|
||||||
|
onConnected?: () => void;
|
||||||
|
},
|
||||||
|
wsPath = DEFAULT_WS_PATH,
|
||||||
|
) {
|
||||||
|
this.onToken = handlers.onToken;
|
||||||
|
this.onThinkingToken = handlers.onThinkingToken;
|
||||||
|
this.onUpdate = handlers.onUpdate;
|
||||||
|
this.onSessionId = handlers.onSessionId;
|
||||||
|
this.onError = handlers.onError;
|
||||||
|
this.onPipelineState = handlers.onPipelineState;
|
||||||
|
this.onPermissionRequest = handlers.onPermissionRequest;
|
||||||
|
this.onActivity = handlers.onActivity;
|
||||||
|
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
||||||
|
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||||
|
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
||||||
|
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||||
|
this.onWizardState = handlers.onWizardState;
|
||||||
|
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||||
|
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||||
|
this.onLogEntry = handlers.onLogEntry;
|
||||||
|
this.onStatusUpdate = handlers.onStatusUpdate;
|
||||||
|
this.onConnected = handlers.onConnected;
|
||||||
|
this.wsPath = wsPath;
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
|
||||||
|
if (this.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connected = true;
|
||||||
|
ChatWebSocket.refCount += 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!ChatWebSocket.sharedSocket ||
|
||||||
|
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
|
||||||
|
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
|
||||||
|
) {
|
||||||
|
const wsUrl = this._buildWsUrl();
|
||||||
|
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
|
||||||
|
}
|
||||||
|
this.socket = ChatWebSocket.sharedSocket;
|
||||||
|
this._attachHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendChat(messages: Message[], config: ProviderConfig) {
|
||||||
|
this.send({ type: "chat", messages, config });
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSideQuestion(
|
||||||
|
question: string,
|
||||||
|
contextMessages: Message[],
|
||||||
|
config: ProviderConfig,
|
||||||
|
) {
|
||||||
|
this.send({
|
||||||
|
type: "side_question",
|
||||||
|
question,
|
||||||
|
context_messages: contextMessages,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.send({ type: "cancel" });
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPermissionResponse(
|
||||||
|
requestId: string,
|
||||||
|
approved: boolean,
|
||||||
|
alwaysAllow = false,
|
||||||
|
) {
|
||||||
|
this.send({
|
||||||
|
type: "permission_response",
|
||||||
|
request_id: requestId,
|
||||||
|
approved,
|
||||||
|
always_allow: alwaysAllow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this._stopHeartbeat();
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = undefined;
|
||||||
|
|
||||||
|
if (!this.connected) return;
|
||||||
|
this.connected = false;
|
||||||
|
ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
if (this.closeTimer) {
|
||||||
|
window.clearTimeout(this.closeTimer);
|
||||||
|
}
|
||||||
|
this.closeTimer = window.setTimeout(() => {
|
||||||
|
if (ChatWebSocket.refCount === 0) {
|
||||||
|
ChatWebSocket.sharedSocket?.close();
|
||||||
|
ChatWebSocket.sharedSocket = null;
|
||||||
|
}
|
||||||
|
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
||||||
|
this.closeTimer = undefined;
|
||||||
|
}, 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ChatWebSocket.refCount === 0) {
|
||||||
|
ChatWebSocket.sharedSocket?.close();
|
||||||
|
ChatWebSocket.sharedSocket = null;
|
||||||
|
}
|
||||||
|
this.socket = ChatWebSocket.sharedSocket ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(payload: WsRequest) {
|
||||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
this.onError?.("WebSocket is not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.socket.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user