diff --git a/bifrost/lib/clients/jawnTypes/private.ts b/bifrost/lib/clients/jawnTypes/private.ts index 28ef5d7678..1ebf02a3f3 100644 --- a/bifrost/lib/clients/jawnTypes/private.ts +++ b/bifrost/lib/clients/jawnTypes/private.ts @@ -127,6 +127,9 @@ export interface paths { "/v1/prompt/create": { post: operations["CreatePrompt"]; }; + "/v1/prompt/{promptId}/user-defined-id": { + patch: operations["UpdatePromptUserDefinedId"]; + }; "/v1/prompt/version/{promptVersionId}/edit-label": { post: operations["EditPromptVersionLabel"]; }; @@ -2800,6 +2803,28 @@ export interface operations { }; }; }; + UpdatePromptUserDefinedId: { + parameters: { + path: { + promptId: string; + }; + }; + requestBody: { + content: { + "application/json": { + userDefinedId: string; + }; + }; + }; + responses: { + /** @description Ok */ + 200: { + content: { + "application/json": components["schemas"]["Result_null.string_"]; + }; + }; + }; + }; EditPromptVersionLabel: { parameters: { path: { diff --git a/bifrost/lib/clients/jawnTypes/public.ts b/bifrost/lib/clients/jawnTypes/public.ts index 863e8fb1f1..b2cfad3d9c 100644 --- a/bifrost/lib/clients/jawnTypes/public.ts +++ b/bifrost/lib/clients/jawnTypes/public.ts @@ -88,6 +88,9 @@ export interface paths { "/v1/prompt/create": { post: operations["CreatePrompt"]; }; + "/v1/prompt/{promptId}/user-defined-id": { + patch: operations["UpdatePromptUserDefinedId"]; + }; "/v1/prompt/version/{promptVersionId}/edit-label": { post: operations["EditPromptVersionLabel"]; }; @@ -2828,6 +2831,28 @@ export interface operations { }; }; }; + UpdatePromptUserDefinedId: { + parameters: { + path: { + promptId: string; + }; + }; + requestBody: { + content: { + "application/json": { + userDefinedId: string; + }; + }; + }; + responses: { + /** @description Ok */ + 200: { + content: { + "application/json": components["schemas"]["Result_null.string_"]; + }; + }; + }; + }; EditPromptVersionLabel: { parameters: { path: { diff --git a/docs/swagger.json b/docs/swagger.json index 04c4d7ee5c..8a85c6af98 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -8690,6 +8690,59 @@ } } }, + "/v1/prompt/{promptId}/user-defined-id": { + "patch": { + "operationId": "UpdatePromptUserDefinedId", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result_null.string_" + } + } + } + } + }, + "tags": [ + "Prompt" + ], + "security": [ + { + "api_key": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "promptId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "userDefinedId": { + "type": "string" + } + }, + "required": [ + "userDefinedId" + ], + "type": "object" + } + } + } + } + } + }, "/v1/prompt/version/{promptVersionId}/edit-label": { "post": { "operationId": "EditPromptVersionLabel", diff --git a/valhalla/jawn/src/controllers/public/promptController.ts b/valhalla/jawn/src/controllers/public/promptController.ts index 94de075996..57fb22b95e 100644 --- a/valhalla/jawn/src/controllers/public/promptController.ts +++ b/valhalla/jawn/src/controllers/public/promptController.ts @@ -10,6 +10,7 @@ import { Route, Security, Tags, + Patch, } from "tsoa"; import { Result, resultMap } from "../../lib/shared/result"; import { @@ -231,6 +232,25 @@ export class PromptController extends Controller { return result; } + @Patch("{promptId}/user-defined-id") + public async updatePromptUserDefinedId( + @Request() request: JawnAuthenticatedRequest, + @Path() promptId: string, + @Body() requestBody: { userDefinedId: string } + ): Promise> { + const promptManager = new PromptManager(request.authParams); + const result = await promptManager.updatePromptUserDefinedId( + promptId, + requestBody.userDefinedId + ); + if (result.error) { + this.setStatus(500); + } else { + this.setStatus(200); + } + return result; + } + @Post("version/{promptVersionId}/edit-label") public async editPromptVersionLabel( @Body() diff --git a/valhalla/jawn/src/managers/prompt/PromptManager.ts b/valhalla/jawn/src/managers/prompt/PromptManager.ts index 202e4b44b7..4195113352 100644 --- a/valhalla/jawn/src/managers/prompt/PromptManager.ts +++ b/valhalla/jawn/src/managers/prompt/PromptManager.ts @@ -156,7 +156,7 @@ export class PromptManager extends BaseManager { LIMIT 1 ) END, - $6, + $6::jsonb, $7::uuid, ppv.id, CASE @@ -171,6 +171,7 @@ export class PromptManager extends BaseManager { helicone_template, prompt_v2, model, + metadata, experiment_id; `, [ @@ -185,7 +186,7 @@ export class PromptManager extends BaseManager { ] ); - return resultMap(result, (data) => data[0]); + return resultMap(result, data => data[0]); } async editPromptVersionTemplate( @@ -219,7 +220,7 @@ export class PromptManager extends BaseManager { ? params.heliconeTemplate : JSON.stringify(params.heliconeTemplate) ).matchAll(//g) - ).map((match) => match[1]); + ).map(match => match[1]); const existingExperimentInputKeys = await supabaseServer.client .from("experiment_v3") @@ -248,7 +249,7 @@ export class PromptManager extends BaseManager { ), ]); - if (res.some((r) => r.error)) { + if (res.some(r => r.error)) { return err("Failed to update experiment input keys"); } @@ -287,7 +288,7 @@ export class PromptManager extends BaseManager { [params.label, promptVersionId, this.authParams.organizationId] ); - return resultMap(result, (data) => data[0]); + return resultMap(result, data => data[0]); } async promotePromptVersionToProduction( @@ -677,7 +678,7 @@ export class PromptManager extends BaseManager { [this.authParams.organizationId, promptId] ); - return resultMap(result, (data) => data[0]); + return resultMap(result, data => data[0]); } async getPromptVersion(params: { @@ -807,6 +808,46 @@ export class PromptManager extends BaseManager { return ok(null); } + async updatePromptUserDefinedId( + promptId: string, + newUserDefinedId: string + ): Promise> { + // First check if the new ID already exists + const existingPrompt = await dbExecute<{ + id: string; + }>( + ` + SELECT id FROM prompt_v2 + WHERE user_defined_id = $1 + AND organization = $2 + AND soft_delete = false + `, + [newUserDefinedId, this.authParams.organizationId] + ); + + if (existingPrompt.data && existingPrompt.data.length > 0) { + return err(`Prompt with name ${newUserDefinedId} already exists`); + } + + // Update the prompt's user_defined_id + const result = await dbExecute( + ` + UPDATE prompt_v2 + SET user_defined_id = $1 + WHERE id = $2 + AND organization = $3 + AND soft_delete = false + `, + [newUserDefinedId, promptId, this.authParams.organizationId] + ); + + if (result.error) { + return err(`Failed to update prompt user_defined_id: ${result.error}`); + } + + return ok(null); + } + public getHeliconeTemplateKeys(template: string | object): string[] { try { // Convert to string if it's an object @@ -820,7 +861,7 @@ export class PromptManager extends BaseManager { const regex = //g; const matches = str.match(regex); return matches - ? matches.map((match) => + ? matches.map(match => match.replace(//g, "") ) : []; @@ -834,7 +875,7 @@ export class PromptManager extends BaseManager { if (typeof value === "string") { keys.push(...findKeys(value)); } else if (Array.isArray(value)) { - value.forEach((item) => { + value.forEach(item => { if (typeof item === "string") { keys.push(...findKeys(item)); } else if (typeof item === "object") { diff --git a/valhalla/jawn/src/tsoa-build/private/routes.ts b/valhalla/jawn/src/tsoa-build/private/routes.ts index 21c40dee01..1f6be8c40b 100644 --- a/valhalla/jawn/src/tsoa-build/private/routes.ts +++ b/valhalla/jawn/src/tsoa-build/private/routes.ts @@ -3255,6 +3255,39 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.patch('/v1/prompt/:promptId/user-defined-id', + authenticateMiddleware([{"api_key":[]}]), + ...(fetchMiddlewares(PromptController)), + ...(fetchMiddlewares(PromptController.prototype.updatePromptUserDefinedId)), + + async function PromptController_updatePromptUserDefinedId(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + promptId: {"in":"path","name":"promptId","required":true,"dataType":"string"}, + requestBody: {"in":"body","name":"requestBody","required":true,"dataType":"nestedObjectLiteral","nestedProperties":{"userDefinedId":{"dataType":"string","required":true}}}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new PromptController(); + + await templateService.apiHandler({ + methodName: 'updatePromptUserDefinedId', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post('/v1/prompt/version/:promptVersionId/edit-label', authenticateMiddleware([{"api_key":[]}]), ...(fetchMiddlewares(PromptController)), diff --git a/valhalla/jawn/src/tsoa-build/private/swagger.json b/valhalla/jawn/src/tsoa-build/private/swagger.json index 265860f5cc..17d7e6787b 100644 --- a/valhalla/jawn/src/tsoa-build/private/swagger.json +++ b/valhalla/jawn/src/tsoa-build/private/swagger.json @@ -7413,6 +7413,59 @@ } } }, + "/v1/prompt/{promptId}/user-defined-id": { + "patch": { + "operationId": "UpdatePromptUserDefinedId", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result_null.string_" + } + } + } + } + }, + "tags": [ + "Prompt" + ], + "security": [ + { + "api_key": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "promptId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "userDefinedId": { + "type": "string" + } + }, + "required": [ + "userDefinedId" + ], + "type": "object" + } + } + } + } + } + }, "/v1/prompt/version/{promptVersionId}/edit-label": { "post": { "operationId": "EditPromptVersionLabel", diff --git a/valhalla/jawn/src/tsoa-build/public/routes.ts b/valhalla/jawn/src/tsoa-build/public/routes.ts index 7ed0c35286..b81d49af67 100644 --- a/valhalla/jawn/src/tsoa-build/public/routes.ts +++ b/valhalla/jawn/src/tsoa-build/public/routes.ts @@ -3505,6 +3505,39 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.patch('/v1/prompt/:promptId/user-defined-id', + authenticateMiddleware([{"api_key":[]}]), + ...(fetchMiddlewares(PromptController)), + ...(fetchMiddlewares(PromptController.prototype.updatePromptUserDefinedId)), + + async function PromptController_updatePromptUserDefinedId(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + promptId: {"in":"path","name":"promptId","required":true,"dataType":"string"}, + requestBody: {"in":"body","name":"requestBody","required":true,"dataType":"nestedObjectLiteral","nestedProperties":{"userDefinedId":{"dataType":"string","required":true}}}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new PromptController(); + + await templateService.apiHandler({ + methodName: 'updatePromptUserDefinedId', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post('/v1/prompt/version/:promptVersionId/edit-label', authenticateMiddleware([{"api_key":[]}]), ...(fetchMiddlewares(PromptController)), diff --git a/valhalla/jawn/src/tsoa-build/public/swagger.json b/valhalla/jawn/src/tsoa-build/public/swagger.json index 04c4d7ee5c..8a85c6af98 100644 --- a/valhalla/jawn/src/tsoa-build/public/swagger.json +++ b/valhalla/jawn/src/tsoa-build/public/swagger.json @@ -8690,6 +8690,59 @@ } } }, + "/v1/prompt/{promptId}/user-defined-id": { + "patch": { + "operationId": "UpdatePromptUserDefinedId", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result_null.string_" + } + } + } + } + }, + "tags": [ + "Prompt" + ], + "security": [ + { + "api_key": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "promptId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "userDefinedId": { + "type": "string" + } + }, + "required": [ + "userDefinedId" + ], + "type": "object" + } + } + } + } + } + }, "/v1/prompt/version/{promptVersionId}/edit-label": { "post": { "operationId": "EditPromptVersionLabel", diff --git a/web/components/layout/auth/authLayout.tsx b/web/components/layout/auth/authLayout.tsx index 852ebfc6c7..727377d6a4 100644 --- a/web/components/layout/auth/authLayout.tsx +++ b/web/components/layout/auth/authLayout.tsx @@ -91,7 +91,7 @@ const AuthLayout = (props: AuthLayoutProps) => { setOpen={setOpen} /> -
+
{children} diff --git a/web/components/shared/authHeader.tsx b/web/components/shared/authHeader.tsx index c206bf8029..20656360c3 100644 --- a/web/components/shared/authHeader.tsx +++ b/web/components/shared/authHeader.tsx @@ -13,16 +13,25 @@ interface AuthHeaderProps { headerActions?: ReactNode; actions?: ReactNode; isWithinIsland?: boolean; + className?: string; } const AuthHeader = (props: AuthHeaderProps) => { - const { title, breadcrumb, headerActions, actions, isWithinIsland } = props; + const { + title, + breadcrumb, + headerActions, + actions, + isWithinIsland, + className, + } = props; return (
diff --git a/web/components/shared/prompts/ParametersPanel.tsx b/web/components/shared/prompts/ParametersPanel.tsx new file mode 100644 index 0000000000..11ba47014c --- /dev/null +++ b/web/components/shared/prompts/ParametersPanel.tsx @@ -0,0 +1,284 @@ +import { useEffect, useMemo, memo } from "react"; +import { + PiTargetBold, + PiPaintBrushBold, + PiPlugsBold, + PiCaretDownBold, +} from "react-icons/pi"; + +const PROVIDERS = [ + "anthropic", + "google", + "meta-llama", + "mistralai", + "openai", + "cohere", + "qwen", + "nousresearch", + "x-ai", + "amazon", + "microsoft", + "perplexity", + "deepseek", + "nvidia", + "sao10k", + "neversleep", + "eva-unit-01", + "gryphe", + "liquid", + "alpindale", + "aetherwiing", + "cognitivecomputations", + "infermatic", + "thedrummer", + "undi95", +] as const; + +const MODELS = { + anthropic: ["claude-3.5-haiku", "claude-3.5-sonnet", "claude-3-opus"], + google: [ + "gemini-flash-1.5", + "gemini-flash-1.5-8b", + "gemini-pro-1.5", + "gemini-pro", + "gemini-flash-1.5-8b-exp", + "gemma-2-27b-it", + "gemma-2-9b-it", + ], + "meta-llama": [ + "llama-3.1-70b-instruct", + "llama-3.1-8b-instruct", + "llama-3.1-405b-instruct", + "llama-3.2-1b-instruct", + "llama-3.2-3b-instruct", + "llama-3.2-11b-vision-instruct", + "llama-3.2-90b-vision-instruct", + "llama-3-70b-instruct", + "llama-3-8b-instruct", + "llama-3-70b-instruct:nitro", + "llama-3-8b-instruct:nitro", + "llama-3-8b-instruct:extended", + "llama-guard-2-8b", + "llama-3.1-405b", + ], + mistralai: [ + "mistral-nemo", + "codestral-2501", + "mixtral-8x7b-instruct", + "ministral-8b", + "ministral-3b", + "mistral-7b-instruct", + "mistral-large", + "mistral-small", + "codestral-mamba", + "pixtral-12b", + "pixtral-large-2411", + "mistral-7b-instruct-v0.1", + "mistral-7b-instruct-v0.3", + "mistral-medium", + "mistral-large-2411", + "mistral-large-2407", + "mixtral-8x7b-instruct:nitro", + "mixtral-8x22b-instruct", + "mistral-tiny", + ], + openai: [ + "gpt-4o-mini", + "gpt-4o", + // "o1-preview", + // "o1-mini", + // "o1-preview-2024-09-12", + // "o1-mini-2024-09-12", + "gpt-4-turbo", + "gpt-4", + "gpt-3.5-turbo", + "chatgpt-4o-latest", + ], + cohere: [ + "command-r-08-2024", + "command-r-plus-08-2024", + "command-r-plus", + "command-r", + "command-r-plus-04-2024", + "command-r7b-12-2024", + ], + qwen: [ + "qwen-2.5-coder-32b-instruct", + "qwen-2.5-72b-instruct", + "qwen-2.5-7b-instruct", + "qwen-2-vl-7b-instruct", + "qwen-2-vl-72b-instruct", + "qwq-32b-preview", + "qvq-72b-preview", + "qwen-2-72b-instruct", + ], + nousresearch: [ + "hermes-3-llama-3.1-405b", + "hermes-3-llama-3.1-70b", + "hermes-2-pro-llama-3-8b", + "nous-hermes-llama2-13b", + ], + "x-ai": ["grok-2-1212", "grok-beta", "grok-2-vision-1212"], + amazon: ["nova-lite-v1", "nova-micro-v1", "nova-pro-v1"], + microsoft: ["wizardlm-2-8x22b", "wizardlm-2-7b", "phi-4"], + perplexity: [ + "llama-3.1-sonar-large-128k-online", + "llama-3.1-sonar-large-128k-chat", + "llama-3.1-sonar-huge-128k-online", + "llama-3.1-sonar-small-128k-online", + ], + deepseek: ["deepseek-r1", "deepseek-chat"], + nvidia: ["llama-3.1-nemotron-70b-instruct"], + sao10k: [ + "l3-euryale-70b", + "l3.1-euryale-70b", + "l3-lunaris-8b", + "l3.1-70b-hanami-x1", + ], + neversleep: [ + "llama-3-lumimaid-8b", + "llama-3.1-lumimaid-8b", + "llama-3-lumimaid-70b", + "llama-3.1-lumimaid-70b", + "noromaid-20b", + ], + "eva-unit-01": ["eva-qwen-2.5-72b", "eva-llama-3.33-70b"], + gryphe: [ + "mythomax-l2-13b", + "mythomax-l2-13b:nitro", + "mythomax-l2-13b:extended", + ], + alpindale: ["goliath-120b", "magnum-72b"], +} as const; + +interface Parameters { + model: string; + provider: string; + temperature: number; + // TODO: Add other parameters +} + +interface ParametersPanelProps { + parameters: Parameters; + onParameterChange: (updates: Partial) => void; +} +export default function ParametersPanel({ + parameters, + onParameterChange, +}: ParametersPanelProps) { + // Initialize provider if not set + useEffect(() => { + if (!parameters.provider) { + const defaultProvider = PROVIDERS[0]; + onParameterChange({ + provider: defaultProvider, + model: MODELS[defaultProvider][0], + }); + } + }, [parameters.provider, onParameterChange]); + + const handleProviderChange = (provider: string) => { + const validProvider = provider as keyof typeof MODELS; + onParameterChange({ + provider: validProvider, + model: MODELS[validProvider][0], + }); + }; + + return ( +
+
+

Parameters

+
+
+
+
+ + +
+
+ + onParameterChange({ model })} + options={MODELS[parameters.provider as keyof typeof MODELS]} + variant="lg" + /> +
+
+
+
+ {parameters.temperature < 1 ? ( + + ) : ( + + )} + +
+
+ {parameters.temperature.toFixed(1)} + + onParameterChange({ temperature: parseFloat(e.target.value) }) + } + className="flex-1 accent-heliblue w-48 h-2.5 rounded-full bg-slate-200 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-heliblue [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:cursor-grab" + /> +
+
+
+
+ ); +} + +interface SelectDropdownProps { + value: string; + onChange: (value: string) => void; + options: readonly string[]; + variant?: "sm" | "lg"; +} + +const SelectDropdown = memo(function SelectDropdown({ + value, + onChange, + options, + variant = "lg", +}: SelectDropdownProps) { + const optionElements = useMemo( + () => + options.map((option) => ( + + )), + [options] + ); + + return ( +
+ + +
+ ); +}); + +SelectDropdown.displayName = "SelectDropdown"; diff --git a/web/components/shared/prompts/PromptBox.tsx b/web/components/shared/prompts/PromptBox.tsx new file mode 100644 index 0000000000..cdf0c43c52 --- /dev/null +++ b/web/components/shared/prompts/PromptBox.tsx @@ -0,0 +1,686 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; + +import { Variable } from "@/types/prompt-state"; +import { toCamelCase, toSnakeCase } from "@/utils/strings"; +import { getVariableStatus, isVariable } from "@/utils/variables"; +import { createSelectionRange } from "@/utils/selection"; + +import { generateStream } from "@/lib/api/llm/generate-stream"; +import { $assistant, $system, $user } from "@/utils/llm"; +import { readStream } from "@/lib/api/llm/read-stream"; +import autoCompletePrompt from "@/prompts/auto-complete"; +import performEditPrompt from "@/prompts/perform-edit"; +import { suggestions } from "@/prompts/perform-edit"; +import { + MIN_LENGTH_FOR_SUGGESTIONS, + SUGGESTION_DELAY, + suggestionReducer, + cleanSuggestionIfNeeded, +} from "@/utils/suggestions"; + +import LoadingDots from "@/components/shared/universal/LoadingDots"; +import Toolbar from "@/components/shared/prompts/Toolbar"; +import { PiChatDotsBold } from "react-icons/pi"; +import { MdKeyboardTab } from "react-icons/md"; + +type SelectionState = { + text: string; + selectionStart: number; + selectionEnd: number; + isVariable: boolean; +} | null; + +const sharedTextAreaStyles = { + fontFamily: "inherit", + whiteSpace: "pre-wrap", + overflowWrap: "break-word", + lineHeight: "24px", + fontSize: "16px", + margin: 0, + boxSizing: "border-box", + overflow: "hidden", + minHeight: "100%", + position: "relative", + zIndex: 2, +} as const; + +interface PromptBoxProps { + value: string; + onChange: (value: string) => void; + onVariableCreate?: (variable: Variable) => void; + contextText?: string; + variables?: Variable[]; +} + +export default function PromptBox({ + value, + onChange, + onVariableCreate, + contextText = "", + variables = [], +}: PromptBoxProps) { + const [suggestionState, dispatch] = useReducer(suggestionReducer, { + isTyping: false, + lastTypingTime: 0, + canShowSuggestions: true, + suggestion: "", + isStreaming: false, + }); + const [selection, setSelection] = useState(null); + const toolboxRef = useRef(null); + const containerRef = useRef(null); + const abortControllerRef = useRef(null); + const textareaRef = useRef(null); + const typingTimeoutRef = useRef(null); + const isUndoingRef = useRef(false); + const [toolbarPosition, setToolbarPosition] = useState({ + toolbar: { top: 0, left: 0 }, + preview: { top: 0, left: 0 }, + highlights: [] as Array<{ + left: number; + top: number; + width: number; + height: number; + }>, + }); + const [pendingEdit, setPendingEdit] = useState<{ + originalText: string; + generatedText: string; + start: number; + end: number; + isLoading: boolean; + } | null>(null); + + // AUTOCOMPLETE: CANCEL + const abortCurrentRequest = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + const cancelCurrentSuggestion = useCallback(() => { + abortCurrentRequest(); + dispatch({ type: "CANCEL_SUGGESTIONS" }); + }, [abortCurrentRequest]); + + // AUTOCOMPLETE: TYPING + useEffect(() => { + if (suggestionState.isTyping && !isUndoingRef.current) { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + typingTimeoutRef.current = setTimeout(() => { + if (!isUndoingRef.current) { + dispatch({ type: "PAUSE_TYPING" }); + } + }, SUGGESTION_DELAY); + + return () => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + }; + } + }, [suggestionState.isTyping, value]); + useEffect(() => { + console.log("Suggestion Effect:", { + isTyping: suggestionState.isTyping, + canShowSuggestions: suggestionState.canShowSuggestions, + textLength: value.trim().length, + endsWithSpace: /[\s\n]$/.test(value), + timeSinceLastType: Date.now() - suggestionState.lastTypingTime, + }); + + if ( + suggestionState.isTyping || + !suggestionState.canShowSuggestions || + value.trim().length < MIN_LENGTH_FOR_SUGGESTIONS || + !/[\s\n]$/.test(value) + ) { + console.log("Cancelling suggestions due to:", { + isTyping: suggestionState.isTyping, + canShowSuggestions: suggestionState.canShowSuggestions, + textLength: value.trim().length, + endsWithSpace: /[\s\n]$/.test(value), + }); + cancelCurrentSuggestion(); + return; + } + + const timeSinceLastType = Date.now() - suggestionState.lastTypingTime; + if (timeSinceLastType < SUGGESTION_DELAY) { + console.log("Not enough time since last type:", timeSinceLastType); + cancelCurrentSuggestion(); + return; + } + + // Only abort the previous request, don't cancel suggestions state + abortCurrentRequest(); + + // Create new controller for this request + const controller = new AbortController(); + abortControllerRef.current = controller; + + const fetchAndHandleStream = async () => { + try { + const prompt = autoCompletePrompt(value, contextText); + console.log("Fetching suggestions for:", value); + + const stream = await generateStream( + { + provider: "anthropic", + model: "claude-3-5-haiku:beta", + messages: [ + $system(prompt.system), + $user(prompt.user), + $assistant(prompt.prefill), + ], + temperature: 0.7, + }, + { headers: { "x-cancel": "0" } } + ); + + let accumulatedText = ""; + await readStream( + stream, + (chunk: string) => { + accumulatedText += chunk; + console.log("Received suggestion:", accumulatedText); + dispatch({ + type: "SET_SUGGESTION", + payload: cleanSuggestionIfNeeded(value, accumulatedText), + }); + }, + controller.signal + ); + + console.log("Stopped streaming"); + if (abortControllerRef.current === controller) { + dispatch({ type: "STOP_STREAMING" }); + abortControllerRef.current = null; + } + } catch (error) { + if (error instanceof Error && error.name !== "AbortError") { + console.error("Error fetching suggestion:", error); + } + dispatch({ type: "STOP_STREAMING" }); + } + }; + fetchAndHandleStream(); + + return () => { + controller.abort(); + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + } + }; + }, [ + value, + suggestionState.isTyping, + suggestionState.canShowSuggestions, + suggestionState.lastTypingTime, + contextText, + cancelCurrentSuggestion, + abortCurrentRequest, + ]); + const handleKeyDown = (e: React.KeyboardEvent) => { + console.log("KeyDown Event:", { + key: e.key, + hasSuggestion: !!suggestionState.suggestion, + }); + + // Track undo operation + if ((e.metaKey || e.ctrlKey) && e.key === "z") { + isUndoingRef.current = true; + // Clear any pending typing timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + // Cancel any ongoing suggestions + cancelCurrentSuggestion(); + // Reset after a short delay + setTimeout(() => { + isUndoingRef.current = false; + }, 100); + } + + if (e.key === "Tab" && suggestionState.suggestion) { + e.preventDefault(); + const textarea = textareaRef.current; + if (textarea) { + textarea.focus(); + document.execCommand( + "insertText", + false, + cleanSuggestionIfNeeded(value, suggestionState.suggestion) + ); + onChange(textarea.value); + } + dispatch({ type: "ACCEPT_SUGGESTION" }); + return; + } + + if (!["Shift", "Control", "Alt", "Meta"].includes(e.key)) { + // Only cancel if we're not at a word boundary + if (!/[\s\n]$/.test(value)) { + console.log("Cancelling request - not at word boundary"); + abortCurrentRequest(); + } + if (!isUndoingRef.current) { + dispatch({ type: "TYPE" }); + } + } + }; + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + + // Don't trigger typing actions if this is an undo operation + if (!isUndoingRef.current) { + // Only cancel if we're not at a word boundary + if (!/[\s\n]$/.test(newValue)) { + console.log("Cancelling request - not at word boundary"); + abortCurrentRequest(); + } + dispatch({ type: "TYPE" }); + } + }; + + // VARIABLES: COLORING + const getColoredText = () => { + const parts = value.split(/({{[^}]*}})/g); + return parts.map((part, i) => { + if (isVariable(part)) { + const varContent = part.slice(2, -2); + const varName = varContent.trim(); + const { isValid, hasValue, value } = getVariableStatus( + varName, + variables + ); + + return ( + + {part} + + ); + } + return {part}; + }); + }; + + // TOOLBAR: POSITION + const updateToolboxPosition = useCallback(() => { + if (!selection || !textareaRef.current) return; + const textarea = textareaRef.current; + const pre = textarea.nextElementSibling as HTMLPreElement; + if (!pre) return; + + const selectionRange = createSelectionRange(pre, selection); + if (!selectionRange) return; + + const { range, preRect } = selectionRange; + + // Get all client rects for the range to handle multi-line selections + const rects = Array.from(range.getClientRects()); + const highlights = rects.map((rect) => ({ + left: rect.left - preRect.left, + top: rect.top - preRect.top - textarea.scrollTop - 2, + width: rect.width, + height: 24, + })); + + // Get the width of the toolbox + const toolbox = toolboxRef.current; + if (!toolbox) return; + const toolboxWidth = toolbox.getBoundingClientRect().width; + + // Use the first line's rect for toolbar positioning + const firstRect = rects[0]; + const lastRect = rects[rects.length - 1]; + + // Calculate toolbar position (centered above first line) + const toolbarLeft = + firstRect.left - preRect.left + firstRect.width / 2 - toolboxWidth / 2; + const toolbarTop = firstRect.top - preRect.top - textarea.scrollTop; + + // Calculate preview position (underneath first line) + const previewLeft = firstRect.left - preRect.left; + const previewTop = lastRect.bottom - preRect.top - textarea.scrollTop; + + setToolbarPosition({ + toolbar: { left: toolbarLeft, top: toolbarTop }, + preview: { left: previewLeft, top: previewTop }, + highlights, + }); + }, [selection]); + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const resizeObserver = new ResizeObserver(() => { + if (selection) requestAnimationFrame(updateToolboxPosition); + }); + + const intersectionObserver = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && selection) { + requestAnimationFrame(updateToolboxPosition); + } + }, + { threshold: [0, 1] } + ); + + // Add scroll listener to parent elements + const handleScroll = () => { + if (selection) requestAnimationFrame(updateToolboxPosition); + }; + + // Add listener for toolbar mode changes + const handleToolbarModeChange = () => { + if (selection) requestAnimationFrame(updateToolboxPosition); + }; + + resizeObserver.observe(textarea); + intersectionObserver.observe(textarea); + window.addEventListener("scroll", handleScroll, true); + window.addEventListener("toolbarModeChange", handleToolbarModeChange); + + return () => { + resizeObserver.disconnect(); + intersectionObserver.disconnect(); + window.removeEventListener("scroll", handleScroll, true); + window.removeEventListener("toolbarModeChange", handleToolbarModeChange); + }; + }, [selection, updateToolboxPosition]); + + // CLICK OUTSIDE HANDLING + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + // Don't hide if clicking inside toolbox or if clicking inside container + if ( + toolboxRef.current?.contains(e.target as Node) || + containerRef.current?.contains(e.target as Node) + ) { + return; + } + + // Clear selection if clicking outside + setSelection(null); + }; + + window.addEventListener("mousedown", handleClickOutside); + return () => window.removeEventListener("mousedown", handleClickOutside); + }, []); + const handleSelection = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + // Clear selection if there's no actual selection + if (start === end) { + setSelection(null); + return; + } + + const selectedText = value.slice(start, end).trim(); + if (!selectedText) { + setSelection(null); + return; + } + + setSelection({ + text: selectedText, + selectionStart: start, + selectionEnd: end, + isVariable: isVariable(selectedText), + }); + + // Update position immediately after selection + requestAnimationFrame(updateToolboxPosition); + }, [value, updateToolboxPosition]); + const handleBlur = (e: React.FocusEvent) => { + // Don't hide if focus is moving to toolbox + if (toolboxRef.current?.contains(e.relatedTarget as Node)) { + return; + } + + // Only clear selection if focus is moving outside container + if (!containerRef.current?.contains(e.relatedTarget as Node)) { + setSelection(null); + } + }; + const handleTextEdit = useCallback( + (newValue: string, newStart: number, newEnd: number) => { + onChange(newValue); + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(newStart, newEnd); + handleSelection(); + } + }); + }, + [onChange, handleSelection] + ); + + // TOOLBAR: EDIT - HANDLERS + const handleGeneratedEdit = async (instruction: string) => { + if (!selection) return; + + const prompt = performEditPrompt( + instruction, + selection.text, + value.slice(0, selection.selectionStart), + value.slice(selection.selectionEnd) + ); + + try { + let generatedText = ""; + setPendingEdit({ + originalText: selection.text, + generatedText: "", + start: selection.selectionStart, + end: selection.selectionEnd, + isLoading: true, + }); + + const stream = await generateStream( + { + provider: "anthropic", + model: "claude-3-5-haiku:beta", + messages: [ + $system(prompt.system), + $user(prompt.user), + $assistant(prompt.prefill), + ], + temperature: 1, + stop: [""], + }, + { headers: { "x-cancel": "0" } } + ); + + await readStream(stream, (chunk: string) => { + generatedText += chunk; + setPendingEdit((prev) => + prev ? { ...prev, generatedText: generatedText.trim() } : null + ); + }); + } catch (error) { + console.error("Error generating edit:", error); + setPendingEdit(null); + } finally { + setPendingEdit((prev) => (prev ? { ...prev, isLoading: false } : null)); + } + }; + const handleAcceptEdit = () => { + if (!pendingEdit) return; + + const newValue = + value.slice(0, pendingEdit.start) + + pendingEdit.generatedText + + value.slice(pendingEdit.end); + + // Update the text and selection in one go + onChange(newValue); + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.setSelectionRange( + pendingEdit.start, + pendingEdit.start + pendingEdit.generatedText.length + ); + handleSelection(); + } + }); + + setPendingEdit(null); + }; + const handleDenyEdit = () => { + setPendingEdit(null); + // TODO: Also cancel the generation if ongoing + }; + + // TOOLBAR: TOOLS + const tools = [ + { + icon:

{"{{}}"}

, + label: "Make Into Variable", + hotkey: "e", + onSubmit: (varName: string) => { + if (!selection || !textareaRef.current) return; + const cleanedVarName = toCamelCase(varName); + const newText = `{{${cleanedVarName}}}`; + const newValue = + value.slice(0, selection.selectionStart) + + newText + + value.slice(selection.selectionEnd); + + // Create new variable and notify parent + onVariableCreate?.({ + name: cleanedVarName, + value: selection.text, + isValid: true, + }); + + // Update content + onChange(newValue); + + const newStart = selection.selectionStart; + const newEnd = selection.selectionStart + newText.length; + + handleTextEdit(newValue, newStart, newEnd); + }, + placeholder: "Variable name...", + }, + { + icon:

{""}

, + label: "Wrap In Delimiters", + hotkey: "j", + onSubmit: (tagName: string) => { + if (!selection || !textareaRef.current) return; + + const selectedText = selection.text; + const cleanedTagName = toSnakeCase(tagName); + const newText = `<${cleanedTagName}>\n${selectedText}\n`; + + const newValue = + value.slice(0, selection.selectionStart) + + newText + + value.slice(selection.selectionEnd); + + const newStart = selection.selectionStart; + const newEnd = selection.selectionStart + newText.length; + + handleTextEdit(newValue, newStart, newEnd); + }, + placeholder: "Delimiter name...", + }, + { + icon: , + label: "Perform an Edit", + hotkey: "k", + multiline: true, + showConfirmation: true, + onSubmit: handleGeneratedEdit, + onAccept: handleAcceptEdit, + onDeny: handleDenyEdit, + placeholder: "Describe your edit...", + }, + ]; + + return ( +
+