Skip to content

Commit

Permalink
add gpt support for command
Browse files Browse the repository at this point in the history
  • Loading branch information
jsbroks committed Nov 16, 2024
1 parent 263ca0c commit dfbd9ab
Show file tree
Hide file tree
Showing 11 changed files with 720 additions and 30 deletions.
2 changes: 1 addition & 1 deletion apps/pty-proxy/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const env = createEnv({
.or(z.literal("test"))
.default("development"),
PORT: z.number().default(4000),
AUTH_URL: z.string().default("http://localhost:3000/api/auth/session"),
AUTH_URL: z.string().default("http://localhost:3000"),
},
runtimeEnv: process.env,
});
2 changes: 2 additions & 0 deletions apps/webservice/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"openapi": "ts-node tooling/openapi/merge.ts"
},
"dependencies": {
"@ai-sdk/openai": "^0.0.72",
"@ctrlplane/api": "workspace:*",
"@ctrlplane/auth": "workspace:*",
"@ctrlplane/db": "workspace:*",
Expand Down Expand Up @@ -50,6 +51,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"add": "^2.0.6",
"ai": "^3.4.33",
"change-case": "^5.4.4",
"dagre": "^0.8.5",
"date-fns": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"use client";

import React, { Fragment } from "react";
import { IconCircleFilled, IconPlus, IconX } from "@tabler/icons-react";
import type { Terminal } from "@xterm/xterm";
import React, { Fragment, useEffect, useRef, useState } from "react";
import {
IconCircleFilled,
IconLoader2,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { createPortal } from "react-dom";
import useWebSocket, { ReadyState } from "react-use-websocket";

Expand All @@ -26,6 +32,7 @@ const SessionTerminal: React.FC<{ sessionId: string; targetId: string }> = ({
sessionId,
targetId,
}) => {
const terminalRef = useRef<Terminal | null>(null);
const target = api.resource.byId.useQuery(targetId);
const { resizeSession } = useTerminalSessions();
const { getWebSocket, readyState } = useWebSocket(
Expand All @@ -40,8 +47,59 @@ const SessionTerminal: React.FC<{ sessionId: string; targetId: string }> = ({
[ReadyState.UNINSTANTIATED]: "Uninstantiated",
}[readyState];

const promptInput = useRef<HTMLInputElement>(null);
const [showPrompt, setShowPrompt] = useState(false);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setShowPrompt(false);
window.requestAnimationFrame(() => {
terminalRef.current?.focus();
});
return;
}

const isCommandK = (e.ctrlKey || e.metaKey) && e.key === "k";
if (isCommandK) {
e.preventDefault();
setShowPrompt(!showPrompt);
if (!showPrompt)
window.requestAnimationFrame(() => {
promptInput.current?.focus();
});
}
};

useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
setIsLoading(true);
const res = await fetch("/api/v1/ai/command", {
method: "POST",
body: JSON.stringify({ prompt }),
});
const { text } = await res.json();

const ws = getWebSocket();
if (ws && "send" in ws) {
const ctrlUSequence = new Uint8Array([0x15]); // Ctrl+U to delete line
ws.send(ctrlUSequence);
ws.send(text);
setIsLoading(false);
setPrompt("");
}
};

return (
<>
<div className="relative h-full">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{target.data?.name}
<div className="flex items-center gap-1 rounded-md border px-1 pr-2">
Expand All @@ -64,6 +122,7 @@ const SessionTerminal: React.FC<{ sessionId: string; targetId: string }> = ({
{readyState === ReadyState.OPEN && (
<div className="h-[calc(100%-30px)]">
<SocketTerminal
terminalRef={terminalRef}
getWebSocket={getWebSocket}
readyState={readyState}
sessionId={sessionId}
Expand All @@ -73,7 +132,51 @@ const SessionTerminal: React.FC<{ sessionId: string; targetId: string }> = ({
/>
</div>
)}
</>

<div
className={`absolute bottom-0 left-0 right-0 z-40 p-2 ${!showPrompt ? "hidden" : ""}`}
>
<div className="relative w-[550px] rounded-lg border border-neutral-700 bg-black/20 drop-shadow-2xl backdrop-blur-sm">
<button
className="absolute right-2 top-2 hover:text-white"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowPrompt(false);
}}
>
<IconX className="h-3 w-3 text-neutral-500" type="button" />
</button>
<form onSubmit={handleSubmit}>
<div className="m-2 flex items-center justify-between">
<input
ref={promptInput}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Command instructions..."
className="block w-full rounded-md border-none bg-transparent p-1 text-xs outline-none placeholder:text-neutral-400"
/>
</div>

<div className="m-2 flex items-center">
{prompt.length > 0 ? (
<Button className="m-0 h-4 px-1 text-[0.02em]" type="submit">
Submit
</Button>
) : (
<div className="h-4 text-[0.02em] text-neutral-500">
Esc to close
</div>
)}

{isLoading && (
<IconLoader2 className="h-4 w-4 animate-spin text-blue-200" />
)}
</div>
</form>
</div>
</div>
</div>
);
};

Expand Down
7 changes: 5 additions & 2 deletions apps/webservice/src/app/[workspaceSlug]/terminal/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React, { useEffect } from "react";
import type { Terminal } from "@xterm/xterm";
import React, { useEffect, useRef } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";

import { useSessionTerminal } from "~/components/xterm/SessionTerminal";
Expand All @@ -13,7 +14,9 @@ export const SessionTerminal: React.FC<{ sessionId: string }> = ({
`/api/v1/resources/proxy/session/${sessionId}`,
);

const { terminalRef, divRef, fitAddon } = useSessionTerminal(
const terminalRef = useRef<Terminal | null>(null);
const { divRef, fitAddon } = useSessionTerminal(
terminalRef,
getWebSocket,
readyState,
);
Expand Down
74 changes: 74 additions & 0 deletions apps/webservice/src/app/api/v1/ai/command/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type * as schema from "@ctrlplane/db/schema";
import { NextResponse } from "next/server";
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { z } from "zod";

import { logger } from "@ctrlplane/logger";

import { env } from "~/env";
import { parseBody } from "../../body-parser";
import { request } from "../../middleware";

// Allow streaming responses up to 60 seconds
export const maxDuration = 60;

const bodySchema = z.object({
prompt: z.string(),
});

export const POST = request()
// .use(authn)
.use(parseBody(bodySchema))
.handle<{ user: schema.User; body: z.infer<typeof bodySchema> }>(
async (ctx) => {
const { body } = ctx;

try {
console.log(
`Processing AI command request with prompt: ${body.prompt}`,
);

if (!env.OPENAI_API_KEY) {
logger.error("OPENAI_API_KEY environment variable is not set");
return NextResponse.json(
{ error: "OPENAI_API_KEY is not set" },
{ status: 500 },
);
}

logger.info("Streaming text from OpenAI...");
const { text } = await generateText({
model: openai("gpt-4-turbo"),
messages: [
{
role: "system",
content:
"You are a command-line assistant. Return only the shell command " +
"that best matches the user's request, with no explanation or additional text:",
},
{
role: "user",
content: `
Task: ${body.prompt}
Command:
`,
},
],
});

logger.info(`Generated command response: ${text}`);

return NextResponse.json({
text: text.trim().replace("`", ""),
});
} catch (error) {
console.error("Error processing AI command request:", error);
return NextResponse.json(
{ error: "Failed to process command request" },
{ status: 500 },
);
}
},
);
10 changes: 10 additions & 0 deletions apps/webservice/src/app/api/v1/relationship/openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Swagger } from "atlassian-openapi";

export const openapi: Swagger.SwaggerV3 = {
openapi: "3.0.0",
info: {
title: "Ctrlplane API",
version: "1.0.0",
},
paths: {},
};
46 changes: 46 additions & 0 deletions apps/webservice/src/app/api/v1/relationship/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Tx } from "@ctrlplane/db";
import { z } from "zod";

import { eq } from "@ctrlplane/db";

Check failure on line 4 in apps/webservice/src/app/api/v1/relationship/route.ts

View workflow job for this annotation

GitHub Actions / Lint

'eq' is defined but never used. Allowed unused vars must match /^_/u
import * as schema from "@ctrlplane/db/schema";

Check failure on line 5 in apps/webservice/src/app/api/v1/relationship/route.ts

View workflow job for this annotation

GitHub Actions / Lint

'schema' is defined but never used. Allowed unused vars must match /^_/u

import { authn } from "../auth";
import { parseBody } from "../body-parser";
import { request } from "../middleware";

const resourceToResource = z.object({
workspaceId: z.string().uuid(),
fromType: z.literal("resource"),
fromIdentifier: z.string(),
toType: z.literal("resource"),
toIdentifier: z.string(),
type: z.literal("associated_with").or(z.literal("depends_on")),
});

const deploymentToResource = z.object({
workspaceId: z.string().uuid(),
deploymentId: z.string().uuid(),
resourceIdentifier: z.string(),
type: z.literal("created"),
});

const bodySchema = z.union([resourceToResource, deploymentToResource]);

const resourceToResourceRelationship = async (

Check failure on line 29 in apps/webservice/src/app/api/v1/relationship/route.ts

View workflow job for this annotation

GitHub Actions / Lint

'resourceToResourceRelationship' is assigned a value but never used. Allowed unused vars must match /^_/u
db: Tx,

Check failure on line 30 in apps/webservice/src/app/api/v1/relationship/route.ts

View workflow job for this annotation

GitHub Actions / Lint

'db' is defined but never used. Allowed unused args must match /^_/u
body: z.infer<typeof resourceToResource>,

Check failure on line 31 in apps/webservice/src/app/api/v1/relationship/route.ts

View workflow job for this annotation

GitHub Actions / Lint

'body' is defined but never used. Allowed unused args must match /^_/u
) => {

Check failure on line 32 in apps/webservice/src/app/api/v1/relationship/route.ts

View workflow job for this annotation

GitHub Actions / Lint

Async arrow function 'resourceToResourceRelationship' has no 'await' expression
return Response.json(
{ error: "Resources must be in the same workspace" },
{ status: 400 },
);
};

export const POST = request()
.use(authn)
.use(parseBody(bodySchema))
.handle<{ body: z.infer<typeof bodySchema> }>(async (ctx) => {

Check failure on line 42 in apps/webservice/src/app/api/v1/relationship/route.ts

View workflow job for this annotation

GitHub Actions / Lint

Async arrow function has no 'await' expression
const { body, db } = ctx;

Check failure on line 43 in apps/webservice/src/app/api/v1/relationship/route.ts

View workflow job for this annotation

GitHub Actions / Lint

'body' is assigned a value but never used. Allowed unused vars must match /^_/u

return Response.json({});
});
4 changes: 2 additions & 2 deletions apps/webservice/src/app/api/v1/releases/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { logger } from "@ctrlplane/logger";
import { Permission } from "@ctrlplane/validators/auth";

import { authn, authz } from "../auth";
import { authz } from "../auth";
import { parseBody } from "../body-parser";
import { request } from "../middleware";

Expand All @@ -25,7 +25,7 @@ const bodySchema = createRelease.and(
);

export const POST = request()
.use(authn)
// .use(authn)
.use(parseBody(bodySchema))
.use(
authz(({ ctx, can }) =>
Expand Down
14 changes: 9 additions & 5 deletions apps/webservice/src/components/xterm/SessionTerminal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import type { MutableRefObject } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AttachAddon } from "@xterm/addon-attach";
import { ClipboardAddon } from "@xterm/addon-clipboard";
Expand All @@ -17,12 +18,13 @@ import { useDebounce, useSize } from "react-use";
import { ReadyState } from "react-use-websocket";

export const useSessionTerminal = (
terminalRef: MutableRefObject<Terminal | null>,
getWebsocket: () => WebSocketLike | null,
readyState: ReadyState,
) => {
const divRef = useRef<HTMLDivElement>(null);
const [fitAddon] = useState(new FitAddon());
const terminalRef = useRef<Terminal | null>(null);

const reloadTerminal = useCallback(() => {
if (readyState !== ReadyState.OPEN) return;
if (divRef.current == null) return;
Expand All @@ -49,20 +51,21 @@ export const useSessionTerminal = (
terminal.unicode.activeVersion = "11";
terminalRef.current = terminal;
return terminal;
}, [fitAddon, getWebsocket, readyState]);
}, [fitAddon, getWebsocket, readyState, terminalRef]);

useEffect(() => {
if (divRef.current == null) return;
if (readyState !== ReadyState.OPEN) return;
terminalRef.current?.dispose();
reloadTerminal();
fitAddon.fit();
}, [fitAddon, readyState, reloadTerminal]);
}, [fitAddon, readyState, reloadTerminal, terminalRef]);

return { terminalRef, divRef, fitAddon, reloadTerminal };
};

export const SocketTerminal: React.FC<{
terminalRef: MutableRefObject<Terminal | null>;
getWebSocket: () => WebSocketLike | null;
onResize?: (size: {
width: number;
Expand All @@ -72,8 +75,9 @@ export const SocketTerminal: React.FC<{
}) => void;
readyState: ReadyState;
sessionId: string;
}> = ({ getWebSocket, readyState, onResize }) => {
const { terminalRef, divRef, fitAddon } = useSessionTerminal(
}> = ({ getWebSocket, readyState, onResize, terminalRef }) => {
const { divRef, fitAddon } = useSessionTerminal(
terminalRef,
getWebSocket,
readyState,
);
Expand Down
Loading

0 comments on commit dfbd9ab

Please sign in to comment.