diff --git a/package.json b/package.json index 2889356..d8d344d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@ai-sdk/openai": "^3.0.8", "@ai-sdk/openai-compatible": "^2.0.4", "@ai-sdk/vue": "^3.0.28", - "@beekeeperstudio/plugin": "^1.6.0", + "@beekeeperstudio/plugin": "^1.7.1-beta.2", "@beekeeperstudio/ui-kit": "0.3.1", "@langchain/core": "^0.3.61", "@material-symbols/font-400": "^0.31.2", diff --git a/src/App.vue b/src/App.vue index 79e3ae8..12e4b8a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,7 +6,7 @@ - + @@ -24,7 +24,7 @@ import Configuration, { PageId as ConfigurationPageId, } from "@/components/configuration/Configuration.vue"; import OnboardingScreen from "./components/OnboardingScreen.vue"; -import { getData, log } from "@beekeeperstudio/plugin"; +import { appStorage, log } from "@beekeeperstudio/plugin"; import { Dialog } from "primevue"; type Page = "starting" | "chat-interface"; @@ -104,9 +104,6 @@ export default { this.configurationPage = "general"; this.showConfiguration = true; }, - closeConfiguration() { - this.showConfiguration = false; - }, // In Beekeeper Studio v5.3.3 and lower, the requests from plugins are // sometimes not responded due to a race condition. // See https://github.com/beekeeper-studio/beekeeper-studio/pull/3473 @@ -129,10 +126,10 @@ export default { window.location.reload(); }, reloadDelay); try { - await getData(); + await appStorage.getItem("test"); } catch (e) { } finally { - // Cancel reload if getData() succeeds or fails quickly + // Cancel reload if it succeeds or fails quickly clearTimeout(reloadTimer); } }, diff --git a/src/assets/styles/pages/_chat-interface.scss b/src/assets/styles/pages/_chat-interface.scss index bfe5754..becca3d 100644 --- a/src/assets/styles/pages/_chat-interface.scss +++ b/src/assets/styles/pages/_chat-interface.scss @@ -480,7 +480,7 @@ border: none; border-radius: 0.3rem; background-color: transparent; - color: var(--text-dark); + color: var(--text); font-weight: normal; &:hover { diff --git a/src/assets/styles/pages/configuration/_main.scss b/src/assets/styles/pages/configuration/_main.scss index fe31eb5..0bac486 100644 --- a/src/assets/styles/pages/configuration/_main.scss +++ b/src/assets/styles/pages/configuration/_main.scss @@ -51,7 +51,7 @@ font-weight: normal; font-size: 0.831rem; color: rgb(from var(--theme-base) r g b / 0.77); - padding-left: 0.75rem; + padding-inline: 0.75rem; border-radius: 6px; &:hover { diff --git a/src/components/ChatInterface.vue b/src/components/ChatInterface.vue index 8847064..76544dc 100644 --- a/src/components/ChatInterface.vue +++ b/src/components/ChatInterface.vue @@ -368,5 +368,6 @@ export default { margin-top: 0.5rem; text-align: right; color: var(--text); + font-size: 0.9rem; } diff --git a/src/components/common/BaseInput.vue b/src/components/common/BaseInput.vue index 1027e9b..e1c8bad 100644 --- a/src/components/common/BaseInput.vue +++ b/src/components/common/BaseInput.vue @@ -55,6 +55,18 @@ >

+
+ + + + info + Unsaved changes + +
@@ -85,9 +97,12 @@ export default { type: String as PropType<"before-input" | "after-input">, default: "after-input", }, + /** If `showActions`, we'll show save and discard buttons. */ + showActions: Boolean, + showUnsavedError: Boolean, }, - emits: ["update:modelValue", "input", "change", "click"], + emits: ["update:modelValue", "input", "change", "click", "save", "discard"], // FIXME: Strip this out for now cause vue-tsc isn't happy // See https://github.com/vuejs/language-tools/issues/5069 @@ -117,10 +132,30 @@ export default { [data-position="before-input"] { margin-bottom: 0.5rem; } + .form-group:not(.switch) [data-position="after-input"] { margin-top: 0.35rem; } +.actions { + display: flex; + align-items: center; + padding-top: 0.5rem; + gap: 0.5rem; + font-size: 0.831rem; +} + +.unsaved-error { + color: var(--brand-danger); + display: flex; + align-items: center; + + .material-symbols-outlined { + font-size: 1em; + margin-right: 0.5ch; + } +} + label :deep(.material-symbols-outlined) { padding-left: 0.25em; font-size: 1em; diff --git a/src/components/configuration/Configuration.vue b/src/components/configuration/Configuration.vue index ed4d3a8..84710b7 100644 --- a/src/components/configuration/Configuration.vue +++ b/src/components/configuration/Configuration.vue @@ -1,36 +1,57 @@ diff --git a/src/components/configuration/GeneralConfiguration.vue b/src/components/configuration/GeneralConfiguration.vue index e83bf4d..622536b 100644 --- a/src/components/configuration/GeneralConfiguration.vue +++ b/src/components/configuration/GeneralConfiguration.vue @@ -44,25 +44,78 @@ >.

+ - - + + - - + + + + + + @@ -71,6 +124,8 @@ import { mapActions, mapGetters, mapState } from "pinia"; import BaseInput from "@/components/common/BaseInput.vue"; import { useConfigurationStore } from "@/stores/configuration"; import ExternalLink from "@/components/common/ExternalLink.vue"; +import { useChatStore } from "@/stores/chat"; +import { RootBinding } from "@/plugins/appEvent"; export default { name: "GeneralConfiguration", @@ -80,13 +135,61 @@ export default { ExternalLink, }, + emits: ["update:dirty"], + + data() { + return { + unsavedCustomInstructions: "", + unsavedConnectionInstructions: "", + unsavedWorkspaceConnectionInstructions: "", + showUnsavedError: false, + }; + }, + computed: { + ...mapState(useChatStore, ["workspaceInfo"]), ...mapState(useConfigurationStore, [ - "customInstructions", "allowExecutionOfReadOnlyQueries", "enableAutoCompact", + "customInstructions", + "workspaceConnectionInstructions", ]), ...mapGetters(useConfigurationStore, ["currentConnectionInstructions"]), + rootBindings(): RootBinding[] { + return [ + { + event: "dialogClosePrevented", + handler: () => { + this.dirtyRef?.focus(); + this.dirtyRef?.$el.scrollIntoView({ + behavior: "instant", + }); + this.showUnsavedError = true; + }, + }, + ]; + }, + dirtyRef() { + if (this.unsavedCustomInstructions !== this.customInstructions) { + return this.$refs.customInstructions as InstanceType; + } + if (this.unsavedConnectionInstructions !== this.currentConnectionInstructions) { + return this.$refs.connectionInstructions as InstanceType; + } + if (this.unsavedWorkspaceConnectionInstructions !== this.workspaceConnectionInstructions) { + return this.$refs.workspaceConnectionInstructions as InstanceType; + } + return null; + }, + }, + + watch: { + dirtyRef() { + if (!this.dirtyRef) { + this.showUnsavedError = false; + } + this.$emit("update:dirty", !!this.dirtyRef); + }, }, methods: { @@ -95,5 +198,25 @@ export default { "configureCustomConnectionInstructions", ]), }, + + mounted() { + this.unsavedCustomInstructions = this.customInstructions; + this.unsavedConnectionInstructions = this.currentConnectionInstructions; + this.unsavedWorkspaceConnectionInstructions = + this.workspaceConnectionInstructions; + }, }; + + diff --git a/src/plugins/appEvent.ts b/src/plugins/appEvent.ts index 872cdf5..8cc2078 100644 --- a/src/plugins/appEvent.ts +++ b/src/plugins/appEvent.ts @@ -6,6 +6,7 @@ export type AppEvent = keyof AppEventHandlers; export interface AppEventHandlers { showResultTable: (queryResults: QueryResult[]) => void; showedResultTable: (queryResults: QueryResult[]) => void; + dialogClosePrevented: () => void; } export interface RootBinding { diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 0f4946e..f886114 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -16,7 +16,9 @@ import { getAppVersion, getConnectionInfo, getTables, + getWorkspaceInfo, log, + WorkspaceInfo, } from "@beekeeperstudio/plugin"; import type { Entity } from "@beekeeperstudio/ui-kit"; import gt from "semver/functions/gt"; @@ -38,6 +40,7 @@ type ChatState = { defaultInstructions: string; entities: Entity[]; connectionInfo: ConnectionInfo; + workspaceInfo: WorkspaceInfo; appVersion: Awaited>; }; @@ -58,6 +61,12 @@ export const useChatStore = defineStore("chat", { readOnlyMode: true, }, appVersion: "900.0.0", + workspaceInfo: { + id: -1, + name: "", + type: "local", + isOwner: false, + }, }), getters: { models() { @@ -137,13 +146,19 @@ export const useChatStore = defineStore("chat", { }, systemPrompt(state) { const config = useConfigurationStore(); - return ( + + let prompt = state.defaultInstructions + "\n" + config.customInstructions + "\n" + - config.currentConnectionInstructions - ).trim(); + config.currentConnectionInstructions; + + if (this.workspaceInfo.type === "cloud") { + prompt += "\n" + config.workspaceConnectionInstructions; + } + + return prompt.trim(); }, // FIXME move this to UI Kit? formatterDialect(state) { @@ -276,6 +291,11 @@ export const useChatStore = defineStore("chat", { this.connectionInfo = info; }) .catch(log.error); + getWorkspaceInfo() + .then((info) => { + this.workspaceInfo = info; + }) + .catch(log.error); }, /** List the models for a provider and store them in the internal data store. */ async syncProvider(provider: AvailableProvidersWithDynamicModels) { diff --git a/src/stores/configuration.ts b/src/stores/configuration.ts index 2e3a894..6f3daf2 100644 --- a/src/stores/configuration.ts +++ b/src/stores/configuration.ts @@ -7,113 +7,42 @@ * FUTURE PLAN (probably): * * - Save configuration to .ini config files via Beekeeper Studio API - * instead of using setData? + * instead of using appStorage? */ import { defineStore } from "pinia"; import _ from "lodash"; -import { - getData, - getEncryptedData, - setData, - setEncryptedData, -} from "@beekeeperstudio/plugin"; -import { - AvailableProviders, - disabledModelsByDefault, - providerConfigs, -} from "@/config"; +import { cloudStorage, appStorage, log } from "@beekeeperstudio/plugin"; +import { AvailableProviders, providerConfigs } from "@/config"; import { useChatStore } from "./chat"; - -type Model = { - id: string; - displayName: string; -}; - -type Configurable = { - // ==== GENERAL ==== - /** Append custom instructions to the default system instructions. */ - customInstructions: string; - /** Append custom instructions to the default system instructions. - * It's applied based on the connection ID */ - customConnectionInstructions: { - workspaceId: number; - connectionId: number; - instructions: string; - }[]; - allowExecutionOfReadOnlyQueries: boolean; - enableAutoCompact: boolean; - - // ==== MODELS ==== - /** List of disabled models by id. */ - disabledModels: { providerId: AvailableProviders; modelId: string }[]; - /** Models that are removed are not shown in the UI and cannot be enabled. */ - removedModels: { providerId: AvailableProviders; modelId: string }[]; - providers_openaiCompat_baseUrl: string; - providers_openaiCompat_headers: string; - providers_ollama_baseUrl: string; - providers_ollama_headers: string; -} & { - // User defined models - [K in AvailableProviders as `providers_${K}_models`]: Model[]; -}; - -type EncryptedConfigurable = { - "providers.openai.apiKey": string; - "providers.anthropic.apiKey": string; - "providers.google.apiKey": string; - providers_openaiCompat_apiKey: string; -}; - -type ConfigurationState = Configurable & EncryptedConfigurable; - -export type ConfigurationKey = keyof ConfigurationState; - -const encryptedConfigKeys: (keyof EncryptedConfigurable)[] = [ - "providers.openai.apiKey", - "providers.anthropic.apiKey", - "providers.google.apiKey", - "providers_openaiCompat_apiKey", -]; - -const defaultConfiguration: ConfigurationState = { - // ==== GENERAL ==== - customInstructions: "", - customConnectionInstructions: [], - allowExecutionOfReadOnlyQueries: false, - enableAutoCompact: true, - - // ==== MODELS ==== - "providers.openai.apiKey": "", - "providers.anthropic.apiKey": "", - "providers.google.apiKey": "", - providers_openaiCompat_baseUrl: "", - providers_openaiCompat_apiKey: "", - providers_openaiCompat_headers: "", - providers_ollama_baseUrl: "http://localhost:11434", - providers_ollama_headers: "", - providers_openai_models: [], - providers_anthropic_models: [], - providers_google_models: [], - providers_openaiCompat_models: [], - providers_ollama_models: [], - disabledModels: disabledModelsByDefault, - removedModels: [], +import { + Configurable, + ConfigurationState, + defaultConfiguration, + encryptedConfigurableShape, + isEncryptedConfig, + isCloudConfig, + Model, +} from "./configurationSchema"; + +type State = ConfigurationState & { + storeStatus: "idle" | "loading" | "error"; + storeError: Error | null; }; -function isEncryptedConfig( - config: string, -): config is keyof EncryptedConfigurable { - return encryptedConfigKeys.includes(config as keyof EncryptedConfigurable); -} - export const useConfigurationStore = defineStore("configuration", { - state: (): ConfigurationState => { - return defaultConfiguration; + state: (): State => { + return { + ...defaultConfiguration, + storeStatus: "idle", + storeError: null, + }; }, getters: { apiKeyExists(): boolean { - return encryptedConfigKeys.some((key) => this[key].trim() !== ""); + return Object.keys(encryptedConfigurableShape).some( + (key) => this[key].trim() !== "", + ); }, getModelsByProvider: (state) => { @@ -157,20 +86,37 @@ export const useConfigurationStore = defineStore("configuration", { actions: { async sync() { - const configuration: Partial = {}; - for (const key in defaultConfiguration) { - const value = isEncryptedConfig(key) - ? await getEncryptedData(key) - : await getData(key); - - if (value === null) { - continue; + this.storeStatus = "loading"; + this.storeError = null; + + try { + const configuration: Partial = {}; + for (const key in defaultConfiguration) { + let value: unknown = null; + + if (isCloudConfig(key)) { + value = await cloudStorage.connection.getItem(key); + } else { + value = await appStorage.getItem(key, { + encrypted: isEncryptedConfig(key), + }); + } + + if (value === null) { + continue; + } + + configuration[key] = value; } - configuration[key] = value; - } + this.$patch(configuration); - this.$patch(configuration); + this.storeStatus = "idle"; + } catch (e) { + log.error("Failed to sync configuration" + e.toString()); + this.storeStatus = "error"; + this.storeError = e as Error; + } }, async configure( config: T, @@ -178,10 +124,12 @@ export const useConfigurationStore = defineStore("configuration", { ) { this.$patch({ [config]: value }); - if (isEncryptedConfig(config)) { - await setEncryptedData(config, value); + if (isCloudConfig(config)) { + await cloudStorage.connection.setItem(config, value); } else { - await setData(config, value); + await appStorage.setItem(config, value, { + encrypted: isEncryptedConfig(config), + }); } }, @@ -252,7 +200,9 @@ export const useConfigurationStore = defineStore("configuration", { const connection = useChatStore().connectionInfo; const connectionId = connection.id; const workspaceId = connection.workspaceId; - const connectionInstructions = _.cloneDeep(this.customConnectionInstructions); + const connectionInstructions = _.cloneDeep( + this.customConnectionInstructions, + ); const idx = connectionInstructions.findIndex( (i) => i.connectionId === connectionId && i.workspaceId === workspaceId, ); diff --git a/src/stores/configurationSchema.ts b/src/stores/configurationSchema.ts new file mode 100644 index 0000000..2afe459 --- /dev/null +++ b/src/stores/configurationSchema.ts @@ -0,0 +1,99 @@ +import { disabledModelsByDefault, providerConfigs } from "@/config"; +import z from "zod/v3"; + +function zodEnumFromObjKeys( + obj: Record, +): z.ZodEnum<[K, ...K[]]> { + const [firstKey, ...otherKeys] = Object.keys(obj) as K[]; + return z.enum([firstKey, ...otherKeys]); +} + +const ModelSchema = z.object({ + id: z.string(), + displayName: z.string(), +}); + +const configurableSchema = z.object({ + // ==== GENERAL ==== + /** Append custom instructions to the default system instructions. */ + customInstructions: z.string().default(""), + /** Append custom instructions to the default system instructions. + * It's applied based on the connection ID */ + customConnectionInstructions: z + .array( + z.object({ + workspaceId: z.number(), + connectionId: z.number(), + instructions: z.string(), + }), + ) + .default([]), + allowExecutionOfReadOnlyQueries: z.boolean().default(false), + enableAutoCompact: z.boolean().default(true), + + // ==== MODELS ==== + /** List of disabled models by id. */ + disabledModels: z + .array( + z.object({ + providerId: zodEnumFromObjKeys(providerConfigs), + modelId: z.string(), + }), + ) + .default(disabledModelsByDefault), + removedModels: z + .array( + z.object({ + providerId: zodEnumFromObjKeys(providerConfigs), + modelId: z.string(), + }), + ) + .default([]), + + providers_openaiCompat_baseUrl: z.string().default(""), + providers_openaiCompat_headers: z.string().default(""), + providers_ollama_baseUrl: z.string().default("http://localhost:11434"), + providers_ollama_headers: z.string().default(""), + + providers_mock_models: z.array(ModelSchema).default([]), + providers_openai_models: z.array(ModelSchema).default([]), + providers_anthropic_models: z.array(ModelSchema).default([]), + providers_google_models: z.array(ModelSchema).default([]), + providers_openaiCompat_models: z.array(ModelSchema).default([]), + providers_ollama_models: z.array(ModelSchema).default([]), +}); + +const encryptedConfigurableSchema = z.object({ + "providers.openai.apiKey": z.string().default(""), + "providers.anthropic.apiKey": z.string().default(""), + "providers.google.apiKey": z.string().default(""), + providers_openaiCompat_apiKey: z.string().default(""), +}); + +const cloudConfigurableSchema = z.object({ + cloudConnectionInstructions: z.string().default(""), +}); + +export const encryptedConfigurableShape = encryptedConfigurableSchema.shape; + +export type Model = z.infer; +export type Configurable = z.infer; +export type ConfigurationState = typeof defaultConfiguration; + +export function isEncryptedConfig( + config: string, +): config is keyof z.infer { + return config in encryptedConfigurableSchema.shape; +} + +export function isCloudConfig( + config: string, +): config is keyof z.infer { + return config in cloudConfigurableSchema.shape; +} + +export const defaultConfiguration = { + ...configurableSchema.parse({}), + ...encryptedConfigurableSchema.parse({}), + ...cloudConfigurableSchema.parse({}), +}; diff --git a/src/stores/internalData.ts b/src/stores/internalData.ts index c88e708..2ac28f9 100644 --- a/src/stores/internalData.ts +++ b/src/stores/internalData.ts @@ -11,10 +11,7 @@ import { defineStore } from "pinia"; import _ from "lodash"; -import { - getData as rawGetData, - setData as rawSetData, -} from "@beekeeperstudio/plugin"; +import { appStorage } from "@beekeeperstudio/plugin"; type InternalData = { /** FIXME use Model type */ @@ -27,14 +24,6 @@ const defaultData: InternalData = { isFirstTimeUser: true, }; -const getData: typeof rawGetData = (key, ...args) => { - return rawGetData(`internal.${key}`, ...args); -}; - -const setData: typeof rawSetData = (key, ...args) => { - return rawSetData(`internal.${key}`, ...args); -}; - export const useInternalDataStore = defineStore("pluginData", { state: (): InternalData => { return defaultData; @@ -44,7 +33,7 @@ export const useInternalDataStore = defineStore("pluginData", { async sync() { const data: Partial = {}; for (const key in defaultData) { - const value = await getData(key); + const value = await appStorage.getItem(`internal.${key}`); if (value === null) { continue; @@ -60,7 +49,7 @@ export const useInternalDataStore = defineStore("pluginData", { value: InternalData[T], ) { this.$patch({ [key]: value }); - await setData(key, value); + await appStorage.setItem(`internal.${key}`, value); }, }, }); diff --git a/yarn.lock b/yarn.lock index e26a034..1a5dedf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -370,10 +370,10 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" -"@beekeeperstudio/plugin@^1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@beekeeperstudio/plugin/-/plugin-1.6.0.tgz#7ba0cd3ca01cd1bc463434c7e75b905081fb8df6" - integrity sha512-it+Wbg5zUFfK8yDUY81Rq5c1jRzMat/eBXnu5vjpqSscniGPZXfY5YB3PWdZstWBM40sFo+aKEWOd7DvHNesvw== +"@beekeeperstudio/plugin@^1.7.1-beta.2": + version "1.7.1-beta.2" + resolved "https://registry.yarnpkg.com/@beekeeperstudio/plugin/-/plugin-1.7.1-beta.2.tgz#6f73ca4ccb1a0fef99f57df7fef7f39eb546f1ea" + integrity sha512-pEh8vWMB14ZQ/QIOBMjMZslDFNT1KHpM0/MeGcWSxMmblMV3foMc6LRoxHsbdH/TSlSUwk5Vx2iTx5L+IceUSQ== "@beekeeperstudio/ui-kit@0.3.1": version "0.3.1"