Skip to content

Commit

Permalink
[editor] Handle CMD/CTRL + S to Save (#735)
Browse files Browse the repository at this point in the history
# [editor] Handle CMD/CTRL + S to Save

A common action when using an editor UI is to hit CMD+s or CTRL+s to
save. In browsers, this will by default open up the browser save window
to save the web page itself. In this PR, we override this default
behaviour and instead call the /save endpoint for the config if it's
dirty



https://github.com/lastmile-ai/aiconfig/assets/5060851/a2bf656b-2209-49e0-a404-31b23b836abd



---
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with
[ReviewStack](https://reviewstack.dev/lastmile-ai/aiconfig/pull/735).
* __->__ #735
* #734
* #733
  • Loading branch information
rholinshead authored Jan 3, 2024
2 parents 650c9d6 + 455cdc5 commit 267ac4d
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ClientAIConfig } from "../shared/types";
const AIConfigContext = createContext<{
getState: () => ClientAIConfig;
}>({
getState: () => ({ prompts: [] }),
getState: () => ({ prompts: [], _ui: { isDirty: false } }),
});

export default AIConfigContext;
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import PromptContainer from "./prompt/PromptContainer";
import { Container, Button, createStyles, Stack, Flex } from "@mantine/core";
import {
Container,
Button,
createStyles,
Stack,
Flex,
Tooltip,
} from "@mantine/core";
import { Notifications, showNotification } from "@mantine/notifications";
import {
AIConfig,
Expand All @@ -8,7 +15,14 @@ import {
Prompt,
PromptInput,
} from "aiconfig";
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import aiconfigReducer, { AIConfigReducerAction } from "./aiconfigReducer";
import {
ClientPrompt,
Expand All @@ -26,8 +40,9 @@ import PromptMenuButton from "./prompt/PromptMenuButton";
import GlobalParametersContainer from "./GlobalParametersContainer";
import AIConfigContext from "./AIConfigContext";
import ConfigNameDescription from "./ConfigNameDescription";
import { DEBOUNCE_MS } from "../utils/constants";
import { AUTOSAVE_INTERVAL_MS, DEBOUNCE_MS } from "../utils/constants";
import { getPromptModelName } from "../utils/promptUtils";
import { IconDeviceFloppy } from "@tabler/icons-react";

type Props = {
aiconfig: AIConfig;
Expand Down Expand Up @@ -108,6 +123,9 @@ export default function EditorContainer({
setIsSaving(true);
try {
await saveCallback(clientConfigToAIConfig(stateRef.current));
dispatch({
type: "SAVE_CONFIG_SUCCESS",
});
} catch (err: unknown) {
const message = (err as RequestCallbackError).message ?? null;
showNotification({
Expand Down Expand Up @@ -614,14 +632,58 @@ export default function EditorContainer({
[getState]
);

const isDirty = aiconfigState._ui.isDirty !== false;
useEffect(() => {
if (!isDirty) {
return;
}

// Save every 15 seconds if there are unsaved changes
const saveInterval = setInterval(onSave, AUTOSAVE_INTERVAL_MS);

return () => clearInterval(saveInterval);
}, [isDirty, onSave]);

// Override CMD+s and CTRL+s to save
useEffect(() => {
const saveHandler = (e: KeyboardEvent) => {
// Note platform property to distinguish between CMD and CTRL for
// Mac/Windows/Linux is deprecated.
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform
// Just handle both for now.
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();

if (stateRef.current._ui.isDirty) {
onSave();
}
}
};

window.addEventListener("keydown", saveHandler, false);

return () => window.removeEventListener("keydown", saveHandler);
}, [onSave]);

return (
<AIConfigContext.Provider value={contextValue}>
<Notifications />
<Container maw="80rem">
<Flex justify="flex-end" mt="md" mb="xs">
<Button loading={isSaving} onClick={onSave}>
Save
</Button>
<Tooltip
label={isDirty ? "Save changes to config" : "No unsaved changes"}
>
<div>
<Button
leftIcon={<IconDeviceFloppy />}
loading={isSaving}
onClick={onSave}
disabled={!isDirty}
>
Save
</Button>
</div>
</Tooltip>
</Flex>
<ConfigNameDescription
name={aiconfigState.name}
Expand Down
61 changes: 45 additions & 16 deletions python/src/aiconfig/editor/client/src/components/aiconfigReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { AIConfig, JSONObject, PromptInput } from "aiconfig";
export type AIConfigReducerAction =
| MutateAIConfigAction
| ConsolidateAIConfigAction
| RunPromptErrorAction;
| RunPromptErrorAction
| SaveConfigSuccessAction;

export type MutateAIConfigAction =
| AddPromptAction
Expand Down Expand Up @@ -48,6 +49,10 @@ export type RunPromptErrorAction = {
message?: string;
};

export type SaveConfigSuccessAction = {
type: "SAVE_CONFIG_SUCCESS";
};

export type SetDescriptionAction = {
type: "SET_DESCRIPTION";
description: string;
Expand Down Expand Up @@ -192,18 +197,27 @@ export default function aiconfigReducer(
state: ClientAIConfig,
action: AIConfigReducerAction
): ClientAIConfig {
const dirtyState = {
...state,
_ui: {
...state._ui,
isDirty: true,
},
};
switch (action.type) {
case "ADD_PROMPT_AT_INDEX": {
return reduceInsertPromptAtIndex(state, action.index, action.prompt);
return reduceInsertPromptAtIndex(dirtyState, action.index, action.prompt);
}
case "DELETE_PROMPT": {
return {
...state,
prompts: state.prompts.filter((prompt) => prompt._ui.id !== action.id),
...dirtyState,
prompts: dirtyState.prompts.filter(
(prompt) => prompt._ui.id !== action.id
),
};
}
case "RUN_PROMPT": {
return reduceReplacePrompt(state, action.id, (prompt) => ({
return reduceReplacePrompt(dirtyState, action.id, (prompt) => ({
...prompt,
_ui: {
...prompt._ui,
Expand All @@ -212,46 +226,57 @@ export default function aiconfigReducer(
}));
}
case "RUN_PROMPT_ERROR": {
return reduceReplacePrompt(state, action.id, (prompt) => ({
return reduceReplacePrompt(dirtyState, action.id, (prompt) => ({
...prompt,
_ui: {
...prompt._ui,
isRunning: false,
},
}));
}
case "SET_DESCRIPTION": {
case "SAVE_CONFIG_SUCCESS": {
return {
...state,
_ui: {
...state._ui,
isDirty: false,
},
};
}
case "SET_DESCRIPTION": {
return {
...dirtyState,
description: action.description,
};
}
case "SET_NAME": {
return {
...state,
...dirtyState,
name: action.name,
};
}
case "UPDATE_PROMPT_INPUT": {
return reduceReplaceInput(state, action.id, () => action.input);
return reduceReplaceInput(dirtyState, action.id, () => action.input);
}
case "UPDATE_PROMPT_NAME": {
// Validate that no prompt has a name that conflicts with this one:
const existingPromptNames = state.prompts.map((prompt) => prompt.name);
const existingPromptNames = dirtyState.prompts.map(
(prompt) => prompt.name
);

if (
existingPromptNames.find((existingName) => action.name === existingName)
) {
// Don't allow duplicate names
return state;
}
return reduceReplacePrompt(state, action.id, (prompt) => ({
return reduceReplacePrompt(dirtyState, action.id, (prompt) => ({
...prompt,
name: action.name,
}));
}
case "UPDATE_PROMPT_MODEL": {
return reduceReplacePrompt(state, action.id, (prompt) => ({
return reduceReplacePrompt(dirtyState, action.id, (prompt) => ({
...prompt,
metadata: {
...prompt.metadata,
Expand All @@ -265,7 +290,7 @@ export default function aiconfigReducer(
}));
}
case "UPDATE_PROMPT_MODEL_SETTINGS": {
return reduceReplacePrompt(state, action.id, (prompt) => ({
return reduceReplacePrompt(dirtyState, action.id, (prompt) => ({
...prompt,
metadata: {
...prompt.metadata,
Expand All @@ -284,7 +309,7 @@ export default function aiconfigReducer(
}));
}
case "UPDATE_PROMPT_PARAMETERS": {
return reduceReplacePrompt(state, action.id, (prompt) => ({
return reduceReplacePrompt(dirtyState, action.id, (prompt) => ({
...prompt,
metadata: {
...prompt.metadata,
Expand All @@ -294,15 +319,19 @@ export default function aiconfigReducer(
}
case "UPDATE_GLOBAL_PARAMETERS": {
return {
...state,
...dirtyState,
metadata: {
...state.metadata,
parameters: action.parameters,
},
};
}
case "CONSOLIDATE_AICONFIG": {
return reduceConsolidateAIConfig(state, action.action, action.config);
return reduceConsolidateAIConfig(
dirtyState,
action.action,
action.config
);
}
}
}
19 changes: 15 additions & 4 deletions python/src/aiconfig/editor/client/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export type ClientPrompt = Prompt & {

export type ClientAIConfig = Omit<AIConfig, "prompts"> & {
prompts: ClientPrompt[];
_ui: {
isDirty?: boolean;
};
};

export function clientPromptToAIConfigPrompt(prompt: ClientPrompt): Prompt {
Expand All @@ -30,12 +33,17 @@ export function clientPromptToAIConfigPrompt(prompt: ClientPrompt): Prompt {
}

export function clientConfigToAIConfig(clientConfig: ClientAIConfig): AIConfig {
// For some reason, TS thinks ClientAIConfig is missing properties from
// AIConfig, so we have to cast it
return {
const config = {
...clientConfig,
prompts: clientConfig.prompts.map(clientPromptToAIConfigPrompt),
} as unknown as AIConfig;
_ui: undefined,
};

delete config._ui;

// For some reason, TS thinks ClientAIConfig is missing properties from
// AIConfig, so we have to cast it
return config as unknown as AIConfig;
}

export function aiConfigToClientConfig(aiconfig: AIConfig): ClientAIConfig {
Expand All @@ -47,5 +55,8 @@ export function aiConfigToClientConfig(aiconfig: AIConfig): ClientAIConfig {
id: uniqueId(),
},
})),
_ui: {
isDirty: false,
},
};
}
1 change: 1 addition & 0 deletions python/src/aiconfig/editor/client/src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const DEBOUNCE_MS = 300;
export const AUTOSAVE_INTERVAL_MS = 15 * 1000; // 15 seconds
3 changes: 3 additions & 0 deletions python/src/aiconfig/editor/travel.aiconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
{
"name": "get_activities",
"input": "Tell me 10 fun attractions to do in NYC.",
"metadata": {
"parameters": {}
},
"outputs": [
{
"output_type": "execute_result",
Expand Down

0 comments on commit 267ac4d

Please sign in to comment.