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 @@
>.
+
- All Connections
- Used in every conversation.
+ Global
+ Used in every conversation
- This Connection Only
- Used only for this connection.
+ This connection only
+ Used only for this connection
+
+
+
+ Workspace connection
+
+ lock
+
+
+ Used only for this connection and managed by the workspace
+ owner
@@ -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"