feat: autocomplete v2 + AI chat (#5323)

* feat: autocomplete v2

* wip chat

* wip

* feat: chat ai review and apply

* feat: multiple providers

* update cli gen

* fixes

* improvements

* fix build

* fix issues

* fix build

* final nits

* nits

* nits
This commit is contained in:
HugoCasa
2025-03-25 19:53:48 +01:00
committed by GitHub
parent 3d05f1d7df
commit 47e526ad04
74 changed files with 4185 additions and 1525 deletions
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ai_resource FROM workspace_settings WHERE workspace_id = $1",
"query": "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "ai_resource",
"name": "ai_config",
"type_info": "Jsonb"
}
],
@@ -18,5 +18,5 @@
true
]
},
"hash": "ceda377fa534656ac12a7f41db77db1f054ec751855c8db7352b0e9c20b63ef8"
"hash": "0689cdc6c7676f5e1984792a0e0b172ea9a70835bfba6cef56142556197e9767"
}
@@ -65,7 +65,7 @@
},
{
"ordinal": 12,
"name": "ai_resource",
"name": "ai_config",
"type_info": "Jsonb"
},
{
@@ -130,26 +130,16 @@
},
{
"ordinal": 25,
"name": "ai_models",
"type_info": "VarcharArray"
},
{
"ordinal": 26,
"name": "code_completion_model",
"type_info": "Varchar"
},
{
"ordinal": 27,
"name": "teams_command_script",
"type_info": "Text"
},
{
"ordinal": 28,
"ordinal": 26,
"name": "teams_team_id",
"type_info": "Text"
},
{
"ordinal": 29,
"ordinal": 27,
"name": "teams_team_name",
"type_info": "Text"
}
@@ -185,8 +175,6 @@
true,
true,
true,
false,
true,
true,
true,
true
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, resource_type FROM resource WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "resource_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
false
]
},
"hash": "103ef3cf5cf4d25d780e4aefd5b290d810a5e8ea6458d9f9fd484ced549ea82e"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ai_config as \"ai_config: sqlx::types::Json<AIConfig>\" FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "ai_config: sqlx::types::Json<AIConfig>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "197590e7ab74f64bbf374f23128850bed8f435ea5de16ba796f346fba51d9437"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET ai_resource = NULL, code_completion_model = $1, ai_models = '{}' WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "1bbef6baa5b8e2522d685df2979bb1e4b9022f5e841afd9eeb08a81688f6c0c8"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS (SELECT 1\n FROM workspace_settings\n WHERE workspace_id <> $1\n AND slack_command_script IS NOT NULL\n AND slack_team_id IS NOT NULL\n AND slack_team_id = (SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1))\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "3734117167a1269f78b3949eed005db96faeec6c18500ad96087cd06c2c85a8b"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET ai_resource = $1, code_completion_model = $2, ai_models = $3 WHERE workspace_id = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Varchar",
"VarcharArray",
"Text"
]
},
"nullable": []
},
"hash": "4edf05cbf35325d444de0e74ee070aafd27ef61c940daba186e7e66f668c31ed"
}
@@ -65,7 +65,7 @@
},
{
"ordinal": 12,
"name": "ai_resource",
"name": "ai_config",
"type_info": "Jsonb"
},
{
@@ -130,26 +130,16 @@
},
{
"ordinal": 25,
"name": "ai_models",
"type_info": "VarcharArray"
},
{
"ordinal": 26,
"name": "code_completion_model",
"type_info": "Varchar"
},
{
"ordinal": 27,
"name": "teams_command_script",
"type_info": "Text"
},
{
"ordinal": 28,
"ordinal": 26,
"name": "teams_team_id",
"type_info": "Text"
},
{
"ordinal": 29,
"ordinal": 27,
"name": "teams_team_name",
"type_info": "Text"
}
@@ -185,8 +175,6 @@
true,
true,
true,
false,
true,
true,
true,
true
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "63c16a4277983aaed0aed54972923919cee3cc444725ac6b7906922554bae800"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n \n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\", \n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\"\n ",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\"\n ",
"describe": {
"columns": [
{
@@ -54,5 +54,5 @@
null
]
},
"hash": "31b6fccad46b22bcbba6bbce22209ccb1825116004ed72682854ce3352f454a5"
"hash": "9ae20f2f29406cfa5337736ad710c496f1757d950cdec63c93c77fe79d5212cc"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, slack_team_id, teams_team_id, teams_team_name, slack_name, slack_command_script, teams_command_script, slack_email, auto_invite_domain, auto_invite_operator, auto_add, customer_id, plan, webhook, deploy_to, ai_resource, ai_models, code_completion_model, error_handler, error_handler_extra_args, error_handler_muted_on_cancel, large_file_storage, git_sync, deploy_ui, default_app, automatic_billing, default_scripts, mute_critical_alerts, color, operator_settings FROM workspace_settings WHERE workspace_id = $1",
"query": "SELECT workspace_id, slack_team_id, teams_team_id, teams_team_name, slack_name, slack_command_script, teams_command_script, slack_email, auto_invite_domain, auto_invite_operator, auto_add, customer_id, plan, webhook, deploy_to, ai_config, error_handler, error_handler_extra_args, error_handler_muted_on_cancel, large_file_storage, git_sync, deploy_ui, default_app, automatic_billing, default_scripts, mute_critical_alerts, color, operator_settings FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -80,76 +80,66 @@
},
{
"ordinal": 15,
"name": "ai_resource",
"name": "ai_config",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"name": "ai_models",
"type_info": "VarcharArray"
},
{
"ordinal": 17,
"name": "code_completion_model",
"type_info": "Varchar"
},
{
"ordinal": 18,
"name": "error_handler",
"type_info": "Varchar"
},
{
"ordinal": 19,
"ordinal": 17,
"name": "error_handler_extra_args",
"type_info": "Json"
},
{
"ordinal": 20,
"ordinal": 18,
"name": "error_handler_muted_on_cancel",
"type_info": "Bool"
},
{
"ordinal": 21,
"ordinal": 19,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"ordinal": 20,
"name": "git_sync",
"type_info": "Jsonb"
},
{
"ordinal": 23,
"ordinal": 21,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 24,
"ordinal": 22,
"name": "default_app",
"type_info": "Varchar"
},
{
"ordinal": 25,
"ordinal": 23,
"name": "automatic_billing",
"type_info": "Bool"
},
{
"ordinal": 26,
"ordinal": 24,
"name": "default_scripts",
"type_info": "Jsonb"
},
{
"ordinal": 27,
"ordinal": 25,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 28,
"ordinal": 26,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 29,
"ordinal": 27,
"name": "operator_settings",
"type_info": "Jsonb"
}
@@ -176,8 +166,6 @@
true,
true,
true,
false,
true,
true,
true,
false,
@@ -192,5 +180,5 @@
true
]
},
"hash": "4e9c2e0690eaca280ccb5e5160438f207d930d29e3000b9845eef89e87a35ad1"
"hash": "aff243a11d41085b0b2f52b1b49f122fd3eae99a382ca3e66606d3183de51843"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ai_resource, code_completion_model, ai_models FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "ai_resource",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "code_completion_model",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "ai_models",
"type_info": "VarcharArray"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true,
false
]
},
"hash": "b186efe51e7bb1924efbd7e9b36085502a1e77e30e59c7310c78976f77a810a3"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "ba117e89b5da7ef46a9e702a2f80c624cd1b00bcab9855edb9284d8afec46ff0"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n -- slack_team_id, \n -- slack_name, \n -- slack_command_script, \n -- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,\n auto_invite_domain IS NOT NULL AS \"auto_invite_enabled!\",\n CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS \"auto_invite_as!\", \n CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS \"auto_invite_mode!\", \n webhook, \n deploy_to, \n error_handler, \n ai_resource, \n ai_models,\n code_completion_model,\n error_handler_extra_args, \n error_handler_muted_on_cancel, \n large_file_storage, \n git_sync,\n default_app,\n default_scripts,\n workspace.name,\n mute_critical_alerts,\n color,\n operator_settings\n FROM workspace_settings\n LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id\n WHERE workspace_id = $1",
"query": "SELECT\n -- slack_team_id,\n -- slack_name,\n -- slack_command_script,\n -- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,\n auto_invite_domain IS NOT NULL AS \"auto_invite_enabled!\",\n CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS \"auto_invite_as!\",\n CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS \"auto_invite_mode!\",\n webhook,\n deploy_to,\n error_handler,\n ai_config,\n error_handler_extra_args,\n error_handler_muted_on_cancel,\n large_file_storage,\n git_sync,\n default_app,\n default_scripts,\n workspace.name,\n mute_critical_alerts,\n color,\n operator_settings\n FROM workspace_settings\n LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -35,66 +35,56 @@
},
{
"ordinal": 6,
"name": "ai_resource",
"name": "ai_config",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "ai_models",
"type_info": "VarcharArray"
},
{
"ordinal": 8,
"name": "code_completion_model",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "error_handler_extra_args",
"type_info": "Json"
},
{
"ordinal": 10,
"ordinal": 8,
"name": "error_handler_muted_on_cancel",
"type_info": "Bool"
},
{
"ordinal": 11,
"ordinal": 9,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 12,
"ordinal": 10,
"name": "git_sync",
"type_info": "Jsonb"
},
{
"ordinal": 13,
"ordinal": 11,
"name": "default_app",
"type_info": "Varchar"
},
{
"ordinal": 14,
"ordinal": 12,
"name": "default_scripts",
"type_info": "Jsonb"
},
{
"ordinal": 15,
"ordinal": 13,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 16,
"ordinal": 14,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 17,
"ordinal": 15,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 18,
"ordinal": 16,
"name": "operator_settings",
"type_info": "Jsonb"
}
@@ -112,8 +102,6 @@
true,
true,
true,
false,
true,
true,
false,
true,
@@ -126,5 +114,5 @@
true
]
},
"hash": "dc165e2d3e6cfc52d92b48500b5ca7dd94b46263c58163071c0ded1c54535727"
"hash": "c12a0b0d423577afbb2772f971bffe2635785b53d31d0ceb3d72226a0c316e8a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members \n FROM usr u\n JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id\n RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_\n WHERE g_.workspace_id = $1 AND g_.name != 'all'\n GROUP BY g_.workspace_id, name, summary, extra_perms",
"query": "SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members\n FROM usr u\n JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id\n RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_\n WHERE g_.workspace_id = $1 AND g_.name != 'all'\n GROUP BY g_.workspace_id, name, summary, extra_perms",
"describe": {
"columns": [
{
@@ -42,5 +42,5 @@
null
]
},
"hash": "cc6e21bf16d7b92764aa2b261cee94f6daf6a1eedd8a68742a2f510f0452cc32"
"hash": "e3c8219420bb859de3f1c322978c5859c924cffa61074562ac6d33106d02d7d6"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT usage.usage FROM usage \n WHERE is_workspace = true \n AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)\n AND id = $1",
"query": "\n SELECT usage.usage FROM usage\n WHERE is_workspace = true\n AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)\n AND id = $1",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
false
]
},
"hash": "82b16e771b6e21c4587b5ebf059e312f43b3e5a48f7599133831dbd65886f5d8"
"hash": "e5f1e1e74daeabf410991e2484c1fb565f04539fc7eb141ebfaa957456016841"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS (SELECT 1 \n FROM workspace_settings \n WHERE workspace_id <> $1 \n AND slack_command_script IS NOT NULL\n AND slack_team_id IS NOT NULL \n AND slack_team_id = (SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1))\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "f632ca2e17a3952fc45bd40a055a9442c35453dff95140d2f252c4fe6a14c6a4"
}
@@ -0,0 +1,24 @@
ALTER TABLE workspace_settings RENAME COLUMN ai_config TO ai_resource;
ALTER TABLE workspace_settings
ADD COLUMN ai_models VARCHAR(255)[] NOT NULL DEFAULT '{}',
ADD COLUMN code_completion_model VARCHAR(255);
UPDATE workspace_settings
SET ai_resource = CASE
WHEN ai_resource IS NULL THEN NULL
ELSE jsonb_build_object(
'provider',
COALESCE((SELECT jsonb_object_keys(COALESCE(ai_resource->>'providers', '{}')::jsonb) LIMIT 1), 'openai'), -- Get the first provider key
'path',
ai_resource->'providers'->(SELECT jsonb_object_keys(COALESCE(ai_resource->>'providers', '{}')::jsonb) LIMIT 1)->>'resource_path'
)
END,
ai_models = COALESCE((
SELECT array_agg(model)
FROM jsonb_array_elements_text(
COALESCE(ai_resource->'providers'->(SELECT jsonb_object_keys(COALESCE(ai_resource->>'providers', '{}')::jsonb) LIMIT 1)->>'models', '[]')::jsonb
) model
WHERE model IS NOT NULL
), '{}'),
code_completion_model = ai_resource->'code_completion_model'->>'model';
@@ -0,0 +1,38 @@
UPDATE workspace_settings
SET ai_resource = CASE
WHEN ai_resource IS NULL OR ai_resource->>'path' IS NULL OR ai_resource->>'provider' IS NULL THEN NULL
ELSE jsonb_build_object(
'providers', jsonb_build_object(
ai_resource->>'provider',
jsonb_build_object(
'resource_path', ai_resource->>'path',
'models', to_jsonb(ai_models)
)
),
'default_model',
CASE
WHEN array_length(ai_models, 1) > 0 THEN jsonb_build_object(
'model', ai_models[1],
'provider', ai_resource->>'provider'
)
ELSE NULL
END,
'code_completion_model',
CASE
WHEN code_completion_model IS NULL THEN NULL
ELSE jsonb_build_object(
'model', code_completion_model,
'provider', ai_resource->>'provider'
)
END
)
END;
ALTER TABLE workspace_settings
DROP COLUMN code_completion_model,
DROP COLUMN ai_models;
ALTER TABLE workspace_settings RENAME COLUMN ai_resource TO ai_config;
-- { providers: { [provider]: { resource_path: resource_path, models: ai_models}, default_model: ai_models[0], code_completion_model: code_completion_model}
+34 -40
View File
@@ -1755,14 +1755,8 @@ paths:
type: string
deploy_to:
type: string
ai_resource:
$ref: "#/components/schemas/AIResource"
code_completion_model:
type: string
ai_models:
type: array
items:
type: string
ai_config:
$ref: "#/components/schemas/AIConfig"
error_handler:
type: string
error_handler_extra_args:
@@ -1786,7 +1780,6 @@ paths:
operator_settings:
$ref: "#/components/schemas/OperatorSettings"
required:
- ai_models
- automatic_billing
- error_handler_muted_on_cancel
@@ -2235,18 +2228,7 @@ paths:
content:
application/json:
schema:
type: object
required:
- ai_models
properties:
ai_resource:
$ref: "#/components/schemas/AIResource"
code_completion_model:
type: string
ai_models:
type: array
items:
type: string
$ref: "#/components/schemas/AIConfig"
responses:
"200":
description: status
@@ -2268,23 +2250,10 @@ paths:
"200":
description: status
content:
text/plain:
application/json:
schema:
type: object
properties:
ai_provider:
$ref: "#/components/schemas/AIProvider"
exists_ai_resource:
type: boolean
code_completion_model:
type: string
ai_models:
type: array
items:
type: string
required:
- exists_ai_resource
- ai_models
$ref: "#/components/schemas/AIConfig"
/w/{workspace}/workspaces/edit_error_handler:
post:
@@ -12415,17 +12384,42 @@ components:
type: string
enum: [openai, anthropic, mistral, deepseek, googleai, groq, openrouter, customai]
AIResource:
AIProviderModel:
type: object
properties:
path:
model:
type: string
provider:
$ref: "#/components/schemas/AIProvider"
required:
- path
- model
- provider
AIProviderConfig:
type: object
properties:
resource_path:
type: string
models:
type: array
items:
type: string
required:
- resource_path
- models
AIConfig:
type: object
properties:
providers:
type: object
additionalProperties:
$ref: "#/components/schemas/AIProviderConfig"
default_model:
$ref: "#/components/schemas/AIProviderModel"
code_completion_model:
$ref: "#/components/schemas/AIProviderModel"
Script:
type: object
properties:
+218 -196
View File
@@ -3,11 +3,12 @@ use crate::{
variables::get_variable_or_self,
};
use anthropic::AnthropicCache;
use anyhow::Context;
use axum::{body::Bytes, extract::Path, response::IntoResponse, routing::post, Extension, Router};
use http::HeaderMap;
use lazy_static::lazy_static;
use openai::OpenaiCache;
use openai_api_compatible::OpenaiApiCompatibleCache;
use quick_cache::sync::Cache;
use reqwest::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
@@ -16,10 +17,6 @@ use std::collections::HashMap;
use windmill_audit::{audit_ee::audit_log, ActionKind};
use windmill_common::error::{to_anyhow, Error, Result};
use mistral::MistralCache;
use openai::OpenaiCache;
use openai_api_compatible::OpenaiApiCompatibleCache;
lazy_static::lazy_static! {
static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(60 * 5))
@@ -64,7 +61,12 @@ mod openai_api_compatible {
Value::Object(mut obj) => obj
.remove("api_key")
.map(|v| serde_json::from_value::<String>(v.clone()).ok())
.flatten(),
.flatten()
.or_else(|| {
obj.remove("apiKey")
.map(|v| serde_json::from_value::<String>(v.clone()).ok())
.flatten()
}),
_ => None,
};
OpenaiApiCompatibleCache { base_url, api_key }
@@ -134,7 +136,7 @@ mod openai {
}
}
const BASE_URL: &str = "https://api.openai.com/v1";
pub const BASE_URL: &str = "https://api.openai.com/v1";
impl OpenaiCache {
pub fn prepare_request(self, openai_path: &str, mut body: Bytes) -> Result<RequestBuilder> {
let OpenaiCache { api_key, azure_base_path, organization_id, user } = self;
@@ -274,92 +276,21 @@ mod openai {
}
}
mod anthropic {
use super::*;
#[derive(Clone, Deserialize, Debug)]
pub struct AnthropicCache {
#[serde(rename = "apiKey")]
pub api_key: String,
}
const API_VERSION: &str = "2023-06-01";
const BASE_URL: &str = "https://api.anthropic.com";
impl AnthropicCache {
pub fn prepare_request(self, anthropic_path: &str, body: Bytes) -> Result<RequestBuilder> {
let AnthropicCache { api_key } = self;
let url = format!("{}/{}", BASE_URL, anthropic_path);
let request = HTTP_CLIENT
.post(url)
.header("x-api-key", api_key)
.header("anthropic-version", API_VERSION)
.header("content-type", "application/json")
.body(body);
Ok(request)
}
}
pub async fn get_cached_value(db: &DB, w_id: &str, resource: Value) -> Result<KeyCache> {
let mut resource: AnthropicCache = serde_json::from_value(resource)
.map_err(|e| Error::internal_err(format!("validating anthropic resource {e:#}")))?;
resource.api_key = get_variable_or_self(resource.api_key, db, w_id).await?;
Ok(KeyCache::Anthropic(resource))
}
}
mod mistral {
use super::*;
#[derive(Deserialize, Clone, Debug)]
pub struct MistralCache {
#[serde(rename = "apiKey")]
pub api_key: String,
}
const BASE_URL: &str = "https://api.mistral.ai";
impl MistralCache {
pub fn prepare_request(self, mistral_path: &str, body: Bytes) -> Result<RequestBuilder> {
let MistralCache { api_key } = self;
let url = format!("{}/{}", BASE_URL, mistral_path);
let request = HTTP_CLIENT
.post(url)
.header("content-type", "application/json")
.header("Accept", "application/json")
.header("authorization", format!("Bearer {}", api_key))
.body(body);
Ok(request)
}
}
pub async fn get_cached_value(db: &DB, w_id: &str, resource: Value) -> Result<KeyCache> {
let mut resource: MistralCache = serde_json::from_value(resource)
.map_err(|e| Error::internal_err(format!("validating mistral resource {e:#}")))?;
resource.api_key = get_variable_or_self(resource.api_key, db, w_id).await?;
Ok(KeyCache::Mistral(resource))
}
}
#[derive(Clone, Debug)]
pub enum KeyCache {
Openai(OpenaiCache),
Anthropic(AnthropicCache),
Mistral(MistralCache),
OpenaiApiCompatible(OpenaiApiCompatibleCache),
}
#[derive(Clone, Debug)]
pub struct AICache {
pub path: String,
pub cached_key: KeyCache,
pub expires_at: std::time::Instant,
}
impl AICache {
pub fn new(path: String, cached_key: KeyCache) -> Self {
pub fn new(cached_key: KeyCache) -> Self {
Self {
path,
cached_key,
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
}
@@ -370,10 +301,10 @@ impl AICache {
}
lazy_static! {
pub static ref AI_KEY_CACHE: Cache<String, AICache> = Cache::new(500);
pub static ref AI_KEY_CACHE: Cache<(String, AIProvider), AICache> = Cache::new(500);
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Hash, Clone)]
#[serde(rename_all = "lowercase")]
pub enum AIProvider {
OpenAI,
@@ -387,7 +318,7 @@ pub enum AIProvider {
}
impl AIProvider {
pub fn get_openai_compatible_base_url(&self) -> Result<Option<String>> {
pub fn get_base_url(&self) -> Result<Option<String>> {
match self {
AIProvider::DeepSeek => Ok(Some("https://api.deepseek.com/v1".to_string())),
AIProvider::GoogleAI => Ok(Some(
@@ -395,10 +326,10 @@ impl AIProvider {
)),
AIProvider::Groq => Ok(Some("https://api.groq.com/openai/v1".to_string())),
AIProvider::OpenRouter => Ok(Some("https://openrouter.ai/api/v1".to_string())),
AIProvider::Anthropic => Ok(Some("https://api.anthropic.com/v1".to_string())),
AIProvider::Mistral => Ok(Some("https://api.mistral.ai/v1".to_string())),
AIProvider::CustomAI => Ok(None),
_ => Err(Error::BadRequest(
"Please use the specific provider instead of the OpenAI compatible one".to_string(),
)),
AIProvider::OpenAI => Ok(Some(openai::BASE_URL.to_string())),
}
}
}
@@ -420,141 +351,232 @@ impl TryFrom<&str> for AIProvider {
}
}
#[derive(Deserialize, Debug)]
pub struct AIResource {
pub path: Option<String>,
#[derive(Serialize, Deserialize, Debug)]
pub struct ProviderConfig {
pub resource_path: String,
pub models: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ProviderModel {
pub model: String,
pub provider: AIProvider,
}
pub fn workspaced_service() -> Router {
let router = Router::new().route("/proxy/*ai", post(proxy));
router
#[derive(Serialize, Deserialize, Debug)]
pub struct AIConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<AIProvider, ProviderConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<ProviderModel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_completion_model: Option<ProviderModel>,
}
async fn proxy(
pub fn global_service() -> Router {
Router::new().route("/proxy/*ai", post(global_proxy))
}
pub fn workspaced_service() -> Router {
Router::new().route("/proxy/*ai", post(proxy))
}
async fn global_proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, ai_path)): Path<(String, String)>,
Path(ai_path): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let workspace_cache = AI_KEY_CACHE.get(&w_id);
let forced_resource_path = headers
.get("X-Resource-Path")
let provider = headers
.get("X-Provider")
.map(|v| v.to_str().unwrap_or("").to_string());
let api_key = headers
.get("X-API-Key")
.map(|v| v.to_str().unwrap_or("").to_string());
let ai_cache = match workspace_cache {
Some(cache) if !cache.is_expired() && forced_resource_path.is_none() => cache.cached_key,
_ => {
let (resource, resource_path, ai_provider) = if let Some(resource_path) =
forced_resource_path
{
// guess the provider from the resource type
let record = sqlx::query!(
"SELECT value, resource_type FROM resource WHERE path = $1 AND workspace_id = $2",
&resource_path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"Could not find the resource {}, update the resource path in the workspace settings", resource_path
))
})?;
(
record.value,
resource_path,
AIProvider::try_from(record.resource_type.as_str())?,
)
} else {
let ai_resource = sqlx::query_scalar!(
"SELECT ai_resource FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if ai_resource.is_none() {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
let ai_resource = serde_json::from_value::<AIResource>(ai_resource.unwrap())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let path = ai_resource.path.unwrap_or("".to_string());
if path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
let resource = sqlx::query_scalar!(
"SELECT value
FROM resource
WHERE path = $1 AND workspace_id = $2",
&path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"Could not find the {:?} resource at path {}, update the resource path in the workspace settings", ai_resource.provider, path
))
})?;
(resource, path, ai_resource.provider)
};
if resource.is_none() {
return Err(Error::internal_err(format!(
"{:?} resource missing value",
ai_provider
)));
}
let resource = resource.unwrap();
let ai_cache = match ai_provider {
AIProvider::OpenAI => openai::get_cached_value(&db, &w_id, resource).await,
AIProvider::Anthropic => anthropic::get_cached_value(&db, &w_id, resource).await,
AIProvider::Mistral => mistral::get_cached_value(&db, &w_id, resource).await,
_ => {
openai_api_compatible::get_cached_value(
&db,
&w_id,
resource,
ai_provider.get_openai_compatible_base_url()?,
)
.await
}
};
let ai_cache = ai_cache?;
AI_KEY_CACHE.insert(w_id.clone(), AICache::new(resource_path, ai_cache.clone()));
ai_cache
}
let provider = match provider {
Some(provider) => AIProvider::try_from(provider.as_str())?,
None => return Err(Error::BadRequest("Provider is required".to_string())),
};
let request = match ai_cache {
KeyCache::Openai(cached) => cached.prepare_request(&ai_path, body),
KeyCache::Anthropic(cached) => cached.prepare_request(&ai_path, body),
KeyCache::Mistral(cached) => cached.prepare_request(&ai_path, body),
KeyCache::OpenaiApiCompatible(cached) => cached.prepare_request(&ai_path, body),
let Some(api_key) = api_key else {
return Err(Error::BadRequest("API key is required".to_string()));
};
let response = request?.send().await.map_err(to_anyhow)?;
let base_url = provider.get_base_url()?;
let Some(base_url) = base_url else {
return Err(Error::BadRequest("Provider is not supported".to_string()));
};
let url = format!("{}/{}", base_url, ai_path);
let request = HTTP_CLIENT
.post(url)
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
.body(body);
let response = request.send().await.map_err(to_anyhow)?;
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&authed,
"ai.request",
"ai.global_request",
ActionKind::Execute,
&w_id,
"global",
Some(&authed.email),
Some([("ai_resource_path", &format!("{:?}", ai_path)[..])].into()),
None,
)
.await?;
tx.commit().await?;
if response.error_for_status_ref().is_err() {
let err_msg = response.text().await.unwrap_or("".to_string());
return Err(Error::AiError(err_msg));
}
let status_code = response.status();
let headers = response.headers().clone();
let stream = response.bytes_stream();
Ok((status_code, headers, axum::body::Body::from_stream(stream)))
}
async fn proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, ai_path)): Path<(String, String)>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let provider = headers
.get("X-Provider")
.map(|v| v.to_str().unwrap_or("").to_string());
let provider = match provider {
Some(provider) => AIProvider::try_from(provider.as_str())?,
None => return Err(Error::BadRequest("Provider is required".to_string())),
};
let workspace_cache = AI_KEY_CACHE.get(&(w_id.clone(), provider.clone()));
let forced_resource_path = headers
.get("X-Resource-Path")
.map(|v| v.to_str().unwrap_or("").to_string());
let ai_cache = match workspace_cache {
Some(cache) if !cache.is_expired() && forced_resource_path.is_none() => cache.cached_key,
_ => {
let (resource, ai_provider, save_to_cache) = if let Some(resource_path) =
forced_resource_path
{
// forced resource path, get the resource directly
let resource = sqlx::query_scalar!(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
&resource_path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"Could not find the resource {}, update the resource path in the workspace settings", resource_path
))
})?;
(resource, provider, false)
} else {
let ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if ai_config.is_none() {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
let ai_config = serde_json::from_value::<AIConfig>(ai_config.unwrap())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let provider_config = ai_config
.providers
.as_ref()
.map(|providers| providers.get(&provider))
.flatten()
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
let resource = sqlx::query_scalar!(
"SELECT value
FROM resource
WHERE path = $1 AND workspace_id = $2",
&provider_config.resource_path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"Could not find the {:?} resource at path {}, update the resource path in the workspace settings", provider, provider_config.resource_path
))
})?;
(resource, provider, true)
};
let Some(resource) = resource else {
return Err(Error::internal_err(format!(
"{:?} resource missing value",
ai_provider
)));
};
let ai_cache = match ai_provider {
AIProvider::OpenAI => openai::get_cached_value(&db, &w_id, resource).await?,
_ => {
openai_api_compatible::get_cached_value(
&db,
&w_id,
resource,
ai_provider.get_base_url()?,
)
.await?
}
};
if save_to_cache {
AI_KEY_CACHE.insert((w_id.clone(), ai_provider), AICache::new(ai_cache.clone()));
}
ai_cache
}
};
let request = match ai_cache {
KeyCache::Openai(cached) => cached.prepare_request(&ai_path, body),
KeyCache::OpenaiApiCompatible(cached) => cached.prepare_request(&ai_path, body),
};
let response = request?.send().await.map_err(to_anyhow)?;
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&authed,
"ai.request",
ActionKind::Execute,
&w_id,
Some(&authed.email),
Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()),
)
.await?;
tx.commit().await?;
+1
View File
@@ -487,6 +487,7 @@ pub async fn run_server(
.nest("/apps", apps::global_service().layer(cors.clone()))
.nest("/schedules", schedule::global_service())
.nest("/embeddings", embeddings::global_service())
.nest("/ai", ai::global_service())
.route_layer(from_extractor::<ApiAuthed>())
.route_layer(from_extractor::<users::Tokened>())
.nest("/jobs", jobs::global_root_service())
+50 -92
View File
@@ -8,7 +8,7 @@
use std::collections::HashMap;
use crate::ai::{AIProvider, AIResource, AI_KEY_CACHE};
use crate::ai::{AIConfig, AI_KEY_CACHE};
use crate::db::ApiAuthed;
use crate::users_ee::send_email_if_possible;
use crate::utils::get_instance_username_or_create_pending;
@@ -52,6 +52,9 @@ use windmill_git_sync::handle_deployment_metadata;
#[cfg(feature = "enterprise")]
use windmill_common::utils::require_admin_or_devops;
#[cfg(not(feature = "enterprise"))]
use crate::ai::AIProvider;
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Postgres, Transaction};
@@ -211,10 +214,7 @@ pub struct WorkspaceSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub deploy_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_resource: Option<serde_json::Value>,
pub ai_models: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_completion_model: Option<String>,
pub ai_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_handler: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -291,13 +291,6 @@ struct EditWebhook {
webhook: Option<String>,
}
#[derive(Deserialize)]
struct EditCopilotConfig {
ai_resource: Option<serde_json::Value>,
code_completion_model: Option<String>,
ai_models: Vec<String>,
}
#[derive(Deserialize, Serialize, Debug)]
struct LargeFileStorageWithSecondary {
#[serde(flatten)]
@@ -449,7 +442,7 @@ async fn get_settings(
let mut tx = user_db.begin(&authed).await?;
let settings = sqlx::query_as!(
WorkspaceSettings,
"SELECT workspace_id, slack_team_id, teams_team_id, teams_team_name, slack_name, slack_command_script, teams_command_script, slack_email, auto_invite_domain, auto_invite_operator, auto_add, customer_id, plan, webhook, deploy_to, ai_resource, ai_models, code_completion_model, error_handler, error_handler_extra_args, error_handler_muted_on_cancel, large_file_storage, git_sync, deploy_ui, default_app, automatic_billing, default_scripts, mute_critical_alerts, color, operator_settings FROM workspace_settings WHERE workspace_id = $1",
"SELECT workspace_id, slack_team_id, teams_team_id, teams_team_name, slack_name, slack_command_script, teams_command_script, slack_email, auto_invite_domain, auto_invite_operator, auto_add, customer_id, plan, webhook, deploy_to, ai_config, error_handler, error_handler_extra_args, error_handler_muted_on_cancel, large_file_storage, git_sync, deploy_ui, default_app, automatic_billing, default_scripts, mute_critical_alerts, color, operator_settings FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&mut *tx)
@@ -495,11 +488,11 @@ async fn edit_slack_command(
if es.slack_command_script.is_some() {
let exists_slack_command_with_team_id = sqlx::query_scalar!(
r#"
SELECT EXISTS (SELECT 1
FROM workspace_settings
WHERE workspace_id <> $1
SELECT EXISTS (SELECT 1
FROM workspace_settings
WHERE workspace_id <> $1
AND slack_command_script IS NOT NULL
AND slack_team_id IS NOT NULL
AND slack_team_id IS NOT NULL
AND slack_team_id = (SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1))
"#,
&w_id
@@ -697,51 +690,35 @@ async fn edit_copilot_config(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(eo): Json<EditCopilotConfig>,
Json(ai_config): Json<AIConfig>,
) -> Result<String> {
require_admin(is_admin, &username)?;
let mut tx = db.begin().await?;
if let Some(ai_resource) = &eo.ai_resource {
let parsed_ai_resource = serde_json::from_value::<AIResource>(ai_resource.clone())
.map_err(|e| Error::BadRequest(e.to_string()))?;
sqlx::query!(
"UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2",
sqlx::types::Json(&ai_config) as sqlx::types::Json<&AIConfig>,
&w_id
)
.execute(&mut *tx)
.await?;
#[cfg(not(feature = "enterprise"))]
{
if matches!(parsed_ai_resource.provider, AIProvider::CustomAI) {
return Err(Error::BadRequest(
"Custom AI is only available on EE".to_string(),
));
}
}
sqlx::query!(
"UPDATE workspace_settings SET ai_resource = $1, code_completion_model = $2, ai_models = $3 WHERE workspace_id = $4",
ai_resource,
eo.code_completion_model,
eo.ai_models.as_slice(),
&w_id
)
.execute(&mut *tx)
.await?;
if let Some(cached) = AI_KEY_CACHE.get(&w_id) {
if parsed_ai_resource.path.is_none() || parsed_ai_resource.path.unwrap() != cached.path
if let Some(ref providers) = ai_config.providers {
for provider in providers.keys() {
#[cfg(not(feature = "enterprise"))]
{
AI_KEY_CACHE.remove(&w_id);
if matches!(provider, &AIProvider::CustomAI) {
return Err(Error::BadRequest(
"Custom AI is only available on EE".to_string(),
));
}
}
AI_KEY_CACHE.remove(&(w_id.clone(), provider.clone()));
}
} else {
sqlx::query!(
"UPDATE workspace_settings SET ai_resource = NULL, code_completion_model = $1, ai_models = '{}' WHERE workspace_id = $2",
eo.code_completion_model,
&w_id,
)
.execute(&mut *tx)
.await?;
AI_KEY_CACHE.remove(&w_id);
}
audit_log(
&mut *tx,
&authed,
@@ -749,16 +726,7 @@ async fn edit_copilot_config(
ActionKind::Update,
&w_id,
Some(&authed.email),
Some(
[
("ai_resource", &format!("{:?}", eo.ai_resource)[..]),
(
"code_completion_model",
&format!("{:?}", eo.code_completion_model)[..],
),
]
.into(),
),
Some([("ai_config", &format!("{:?}", ai_config)[..])].into()),
)
.await?;
tx.commit().await?;
@@ -766,42 +734,33 @@ async fn edit_copilot_config(
Ok(format!("Edit copilot config for workspace {}", &w_id))
}
#[derive(Serialize)]
struct CopilotInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_provider: Option<AIProvider>,
pub exists_ai_resource: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_completion_model: Option<String>,
pub ai_models: Vec<String>,
}
async fn get_copilot_info(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<CopilotInfo> {
) -> JsonResult<AIConfig> {
let mut tx = db.begin().await?;
let record = sqlx::query!(
"SELECT ai_resource, code_completion_model, ai_models FROM workspace_settings WHERE workspace_id = $1",
let copilot_info = sqlx::query_scalar!(
"SELECT ai_config as \"ai_config: sqlx::types::Json<AIConfig>\" FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting ai_resource and code_completion_model: {e:#}")))?;
.map_err(|e| {
Error::internal_err(format!(
"getting ai config: {e:#}"
))
})?;
tx.commit().await?;
let (ai_provider, exists_ai_resource) = if let Some(ai_resource) = record.ai_resource {
let ai_resource = serde_json::from_value::<AIResource>(ai_resource)?;
(Some(ai_resource.provider), ai_resource.path.is_some())
if let Some(sqlx::types::Json(copilot_info)) = copilot_info {
Ok(Json(copilot_info))
} else {
(None, false)
};
Ok(Json(CopilotInfo {
ai_provider,
exists_ai_resource,
code_completion_model: record.code_completion_model,
ai_models: record.ai_models,
}))
Ok(Json(AIConfig {
providers: None,
default_model: None,
code_completion_model: None,
}))
}
}
async fn edit_large_file_storage_config(
@@ -1370,9 +1329,8 @@ async fn get_used_triggers(
let websocket_used = sqlx::query_as!(
UsedTriggers,
r#"
SELECT
EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS "websocket_used!",
SELECT
EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS "websocket_used!",
EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS "http_routes_used!",
EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as "kafka_used!",
EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as "nats_used!",
@@ -2100,8 +2058,8 @@ async fn change_workspace_color(
async fn get_usage(Extension(db): Extension<DB>, Path(w_id): Path<String>) -> Result<String> {
let usage = sqlx::query_scalar!(
"
SELECT usage.usage FROM usage
WHERE is_workspace = true
SELECT usage.usage FROM usage
WHERE is_workspace = true
AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)
AND id = $1",
w_id
+16 -21
View File
@@ -250,10 +250,7 @@ struct SimplifiedSettings {
error_handler_extra_args: Option<Value>,
error_handler_muted_on_cancel: bool,
#[serde(skip_serializing_if = "Option::is_none")]
ai_resource: Option<serde_json::Value>,
ai_models: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
code_completion_model: Option<String>,
ai_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
large_file_storage: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -500,8 +497,8 @@ pub(crate) async fn tarball_workspace(
{
let apps = sqlx::query_as::<_, AppWithLastVersion>(
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
app.extra_perms, app_version.value,
app_version.created_at, app_version.created_by from app, app_version
app.extra_perms, app_version.value,
app_version.created_at, app_version.created_by from app, app_version
WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
)
.bind(&w_id)
@@ -711,7 +708,7 @@ pub(crate) async fn tarball_workspace(
if include_groups.unwrap_or(false) {
let groups = sqlx::query!(
r#"SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members
r#"SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members
FROM usr u
JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id
RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_
@@ -773,22 +770,20 @@ pub(crate) async fn tarball_workspace(
let settings = sqlx::query_as!(
SimplifiedSettings,
r#"SELECT
-- slack_team_id,
-- slack_name,
-- slack_command_script,
-- slack_team_id,
-- slack_name,
-- slack_command_script,
-- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,
auto_invite_domain IS NOT NULL AS "auto_invite_enabled!",
CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS "auto_invite_as!",
CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS "auto_invite_mode!",
webhook,
deploy_to,
error_handler,
ai_resource,
ai_models,
code_completion_model,
error_handler_extra_args,
error_handler_muted_on_cancel,
large_file_storage,
CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS "auto_invite_as!",
CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS "auto_invite_mode!",
webhook,
deploy_to,
error_handler,
ai_config,
error_handler_extra_args,
error_handler_muted_on_cancel,
large_file_storage,
git_sync,
default_app,
default_scripts,
+1 -1
View File
@@ -54,7 +54,7 @@ export const OpenAPI: OpenAPIConfig = {
PASSWORD: undefined,
TOKEN: getEnv("WM_TOKEN"),
USERNAME: undefined,
VERSION: '1.477.1',
VERSION: '1.478.1',
WITH_CREDENTIALS: true,
interceptors: {
request: new Interceptors(),
+1 -1
View File
@@ -1374,7 +1374,7 @@ export const editCopilotConfig = (data: EditCopilotConfigData): CancelablePromis
* get copilot info
* @param data The data for the request.
* @param data.workspace
* @returns unknown status
* @returns AIConfig status
* @throws ApiError
*/
export const getCopilotInfo = (data: GetCopilotInfoData): CancelablePromise<GetCopilotInfoResponse> => { return __request(OpenAPI, {
+18 -16
View File
@@ -2,11 +2,24 @@
export type AIProvider = 'openai' | 'anthropic' | 'mistral' | 'deepseek' | 'googleai' | 'groq' | 'openrouter' | 'customai';
export type AIResource = {
path: string;
export type AIProviderModel = {
model: string;
provider: AIProvider;
};
export type AIProviderConfig = {
resource_path: string;
models: Array<(string)>;
};
export type AIConfig = {
providers?: {
[key: string]: AIProviderConfig;
};
default_model?: AIProviderModel;
code_completion_model?: AIProviderModel;
};
export type Script = {
workspace_id?: string;
hash: string;
@@ -2538,9 +2551,7 @@ export type GetSettingsResponse = ({
customer_id?: string;
webhook?: string;
deploy_to?: string;
ai_resource?: AIResource;
code_completion_model?: string;
ai_models: Array<(string)>;
ai_config?: AIConfig;
error_handler?: string;
error_handler_extra_args?: ScriptArgs;
error_handler_muted_on_cancel: boolean;
@@ -2742,11 +2753,7 @@ export type EditCopilotConfigData = {
/**
* WorkspaceCopilotConfig
*/
requestBody: {
ai_resource?: AIResource;
code_completion_model?: string;
ai_models: Array<(string)>;
};
requestBody: AIConfig;
workspace: string;
};
@@ -2756,12 +2763,7 @@ export type GetCopilotInfoData = {
workspace: string;
};
export type GetCopilotInfoResponse = ({
ai_provider?: AIProvider;
exists_ai_resource: boolean;
code_completion_model?: string;
ai_models: Array<(string)>;
});
export type GetCopilotInfoResponse = (AIConfig);
export type EditErrorHandlerData = {
/**
+4 -14
View File
@@ -1,7 +1,7 @@
import process from "node:process";
import { colors, Confirm, log, yamlParseFile, yamlStringify } from "./deps.ts";
import * as wmill from "./gen/services.gen.ts";
import { AIResource, Config, GlobalSetting } from "./gen/types.gen.ts";
import { AIConfig, Config, GlobalSetting } from "./gen/types.gen.ts";
import { compareInstanceObjects, InstanceSyncOptions } from "./instance.ts";
import { isSuperset } from "./types.ts";
import { deepEqual } from "./utils.ts";
@@ -20,9 +20,7 @@ export interface SimplifiedSettings {
error_handler?: string;
error_handler_extra_args?: any;
error_handler_muted_on_cancel?: boolean;
ai_resource?: AIResource;
code_completion_model?: string;
ai_models: string[];
ai_config?: AIConfig;
large_file_storage?: any;
git_sync?: any;
default_app?: string;
@@ -156,19 +154,11 @@ export async function pushWorkspaceSettings(
});
}
}
if (
localSettings.ai_resource != settings.ai_resource ||
localSettings.code_completion_model != settings.code_completion_model ||
!deepEqual(localSettings.ai_models, settings.ai_models)
) {
if (!deepEqual(localSettings.ai_config, settings.ai_config)) {
log.debug(`Updating copilot settings...`);
await wmill.editCopilotConfig({
workspace,
requestBody: {
ai_resource: localSettings.ai_resource,
code_completion_model: localSettings.code_completion_model,
ai_models: localSettings.ai_models,
},
requestBody: localSettings.ai_config ?? {},
});
}
if (
+61 -97
View File
@@ -9,7 +9,6 @@
"version": "1.478.1",
"license": "AGPL-3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@aws-crypto/sha256-js": "^4.0.0",
"@codingame/monaco-vscode-configuration-service-override": "~11.1.2",
"@codingame/monaco-vscode-standalone-css-language-features": "~11.1.2",
@@ -18,7 +17,6 @@
"@codingame/monaco-vscode-standalone-typescript-language-features": "~11.1.2",
"@json2csv/plainjs": "^7.0.6",
"@leeoniya/ufuzzy": "^1.0.8",
"@mistralai/mistralai": "^1.3.0",
"@popperjs/core": "^2.11.6",
"@redocly/json-to-json-schema": "^0.0.1",
"@tanstack/svelte-table": "^8.9.9",
@@ -34,13 +32,14 @@
"chartjs-plugin-zoom": "^2.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^2.30.0",
"diff": "^5.1.0",
"diff": "^7.0.0",
"driver.js": "^1.3.0",
"esm-env": "^1.0.0",
"fast-equals": "^5.0.1",
"graphql": "^16.7.1",
"hash-sum": "^2.0.0",
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"lucide-svelte": "^0.399.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~11.1.2",
@@ -49,7 +48,7 @@
"monaco-languageclient": "9.1.1",
"monaco-vim": "^0.4.1",
"ol": "^7.4.0",
"openai": "^4.57.2",
"openai": "^4.87.1",
"p-limit": "^6.1.0",
"panzoom": "^9.4.3",
"pdfjs-dist": "4.8.69",
@@ -97,6 +96,7 @@
"@tailwindcss/typography": "^0.5.8",
"@types/d3": "^7.4.0",
"@types/d3-zoom": "^3.0.3",
"@types/diff": "^7.0.1",
"@types/lodash": "^4.14.195",
"@types/node": "^20.3.3",
"@types/vscode": "^1.83.5",
@@ -185,30 +185,6 @@
"node": ">=6.0.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.64",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.64.tgz",
"integrity": "sha512-955mDqvO2vFf/oL7V3WiUtiz+BugyX8uVbaT2H8oj3+8dRyH2FLiNdowe7eNqRM7IOIZvzDH76EoAT+gwm6aIQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.6.1.tgz",
@@ -1758,14 +1734,6 @@
"node": "^18 || >=20"
}
},
"node_modules/@mistralai/mistralai": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.3.0.tgz",
"integrity": "sha512-G5DPCSC8sEhG3LUEZDYLD7qEZWDuZXgaX3IcoC3a/ydm9jFuh2pRZtknsgMx2sU8d7kxRuxblY3fPH5C38wnhQ==",
"peerDependencies": {
"zod": ">= 3"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2746,6 +2714,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/diff": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz",
"integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@@ -2829,11 +2804,6 @@
"integrity": "sha512-Yg4LkgFYvn1faISbDNWmcAC1XoDT8IoMUFspp5mnagKk+UvD2N0IWt5A7GRdMubsNWqgCLmrkf8rXkzNqb4szA==",
"dev": true
},
"node_modules/@types/qs": {
"version": "6.9.15",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz",
"integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg=="
},
"node_modules/@types/semver": {
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz",
@@ -3334,6 +3304,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
"license": "MIT",
"optional": true
},
"node_modules/asynckit": {
@@ -4573,9 +4544,10 @@
"dev": true
},
"node_modules/diff": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
"integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
@@ -6130,6 +6102,12 @@
"ms": "^2.0.0"
}
},
"node_modules/idb": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.2.tgz",
"integrity": "sha512-CX70rYhx7GDDQzwwQMDwF6kDRQi5vVs6khHUumDrMecBylKkwvZ8HWvKV08AGb7VbpoGCWUQ4aHzNDgoUiOIUg==",
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -8913,17 +8891,6 @@
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
"integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
@@ -9004,27 +8971,30 @@
}
},
"node_modules/openai": {
"version": "4.57.2",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.57.2.tgz",
"integrity": "sha512-IgIxNjo9tfgnfx6gmwNMg3tdF9giK/2lbwG5DY7zs4TP9Gz+h6h2hBOMoalLPFUVOO5HLOgMI/PFV5VDAUvvMg==",
"version": "4.87.1",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.87.1.tgz",
"integrity": "sha512-mwZ4N4KKGUE5JSTR4IdQZzuxFyStJFqSR62JuWob7ka286tbFacYkOf+Ypk/2IPAUCmhzft5C3UqzcFYaliVMA==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"@types/qs": "^6.9.7",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7",
"qs": "^6.10.3"
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
@@ -10166,20 +10136,6 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quadprog": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/quadprog/-/quadprog-1.6.1.tgz",
@@ -10801,23 +10757,6 @@
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
"integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
"dependencies": {
"call-bind": "^1.0.7",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.4",
"object-inspect": "^1.13.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -12671,12 +12610,26 @@
}
},
"node_modules/ws": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz",
"integrity": "sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT",
"optional": true,
"dependencies": {
"async-limiter": "~1.0.0"
"peer": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-utils": {
@@ -12775,6 +12728,16 @@
"yjs": "^13.5.6"
}
},
"node_modules/y-websocket/node_modules/ws": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz",
"integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==",
"license": "MIT",
"optional": true,
"dependencies": {
"async-limiter": "~1.0.0"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -12838,6 +12801,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+4 -4
View File
@@ -31,6 +31,7 @@
"@tailwindcss/typography": "^0.5.8",
"@types/d3": "^7.4.0",
"@types/d3-zoom": "^3.0.3",
"@types/diff": "^7.0.1",
"@types/lodash": "^4.14.195",
"@types/node": "^20.3.3",
"@types/vscode": "^1.83.5",
@@ -84,7 +85,6 @@
},
"type": "module",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@aws-crypto/sha256-js": "^4.0.0",
"@codingame/monaco-vscode-configuration-service-override": "~11.1.2",
"@codingame/monaco-vscode-standalone-css-language-features": "~11.1.2",
@@ -93,7 +93,6 @@
"@codingame/monaco-vscode-standalone-typescript-language-features": "~11.1.2",
"@json2csv/plainjs": "^7.0.6",
"@leeoniya/ufuzzy": "^1.0.8",
"@mistralai/mistralai": "^1.3.0",
"@popperjs/core": "^2.11.6",
"@redocly/json-to-json-schema": "^0.0.1",
"@tanstack/svelte-table": "^8.9.9",
@@ -109,13 +108,14 @@
"chartjs-plugin-zoom": "^2.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^2.30.0",
"diff": "^5.1.0",
"diff": "^7.0.0",
"driver.js": "^1.3.0",
"esm-env": "^1.0.0",
"fast-equals": "^5.0.1",
"graphql": "^16.7.1",
"hash-sum": "^2.0.0",
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"lucide-svelte": "^0.399.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~11.1.2",
@@ -124,7 +124,7 @@
"monaco-languageclient": "9.1.1",
"monaco-vim": "^0.4.1",
"ol": "^7.4.0",
"openai": "^4.57.2",
"openai": "^4.87.1",
"p-limit": "^6.1.0",
"panzoom": "^9.4.3",
"pdfjs-dist": "4.8.69",
+6 -15
View File
@@ -19,7 +19,7 @@
type TriggersCount
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { copilotInfo, userStore, workspaceStore } from '$lib/stores'
import { setCopilotInfo, userStore, workspaceStore } from '$lib/stores'
import { emptySchema, sendUserToast } from '$lib/utils'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { onDestroy, onMount, setContext } from 'svelte'
@@ -108,30 +108,21 @@
setContext('FlowCopilotContext', flowCopilotContext)
async function setCopilotInfo() {
async function setupCopilotInfo() {
if (workspace) {
workspaceAIClients.init(workspace)
try {
const info = await WorkspaceService.getCopilotInfo({ workspace })
copilotInfo.set({
...info,
ai_provider: info.ai_provider ?? 'openai'
})
setCopilotInfo(info)
} catch (err) {
copilotInfo.set({
ai_provider: 'openai',
exists_ai_resource: false,
code_completion_model: undefined,
ai_models: []
})
console.error('Could not get copilot info')
console.error('Could not get copilot info', err)
setCopilotInfo({})
}
}
}
$: if (workspace) {
$workspaceStore = workspace
setCopilotInfo()
setupCopilotInfo()
}
$: if (workspace && token) {
+126 -76
View File
@@ -140,13 +140,11 @@
import type { Disposable } from 'vscode'
import type { DocumentUri, MessageTransports } from 'vscode-languageclient'
import { workspaceStore } from '$lib/stores'
import { type Preview, UserService } from '$lib/gen'
import { type Preview, ResourceService, UserService } from '$lib/gen'
import type { Text } from 'yjs'
import { initializeVscode } from '$lib/components/vscode'
import { initializeMode } from 'monaco-graphql/esm/initializeMode.js'
import { sleep } from '$lib/utils'
import { editorCodeCompletion } from '$lib/components/copilot/completion'
import {
editor as meditor,
languages,
@@ -172,7 +170,11 @@
import { initVim } from './monaco_keybindings'
import { buildWorkerDefinition } from '$lib/monaco_workers/build_workers'
import { parseTypescriptDeps } from '$lib/relative_imports'
import { Autocompletor } from './copilot/autocomplete/monaco-adapter'
import { AIChatEditorHandler } from './copilot/chat/monaco-adapter'
import GlobalReviewButtons from './copilot/chat/GlobalReviewButtons.svelte'
import { writable } from 'svelte/store'
import { formatResourceTypes } from './copilot/chat/core'
// import EditorTheme from './EditorTheme.svelte'
let divEl: HTMLDivElement | null = null
@@ -202,7 +204,7 @@
ruff: false,
deno: false,
go: false,
shellcheck: false,
shellcheck: false
}
export let shouldBindKey: boolean = true
export let fixedOverflowWidgets = true
@@ -346,7 +348,7 @@
}
}
export function append(code): void {
export function append(code: string): void {
if (editor) {
const lineCount = editor.getModel()?.getLineCount() || 0
const lastLineLength = editor.getModel()?.getLineLength(lineCount) || 0
@@ -601,79 +603,86 @@
}
}
let copilotCompletor: Disposable | undefined = undefined
let copilotTs = Date.now()
let abortController: AbortController | undefined = undefined
function addCopilotSuggestions() {
if (copilotCompletor) {
copilotCompletor.dispose()
}
copilotCompletor = vscode.languages.registerInlineCompletionItemProvider(
{ pattern: '**' },
{
async provideInlineCompletionItems(model, position, context, token) {
abortController?.abort()
const textUntilPosition = model.getText(
new vscode.Range(0, 0, position.line, position.character)
)
let items: vscode.InlineCompletionItem[] = []
const lastChar = textUntilPosition[textUntilPosition.length - 1]
if (textUntilPosition.trim().length > 5 && lastChar.match(/[\(\{\s:=]/)) {
const textAfterPosition = model.getText(
new vscode.Range(position.line, position.character, model.lineCount + 1, 1)
)
const thisTs = Date.now()
copilotTs = thisTs
await sleep(200)
if (copilotTs === thisTs) {
abortController?.abort()
abortController = new AbortController()
token.onCancellationRequested(() => {
abortController?.abort()
})
const aiProvider = $copilotInfo.ai_provider
const insertText = await editorCodeCompletion(
textUntilPosition,
textAfterPosition,
lang,
abortController,
aiProvider
)
if (insertText) {
items = [
{
insertText,
range: new vscode.Range(
position.line,
position.character,
position.line,
position.character
)
}
]
}
}
}
return {
items,
commands: []
}
}
}
)
let reviewingChanges = writable(false)
let aiChatEditorHandler: AIChatEditorHandler | undefined = undefined
export function reviewAndApplyCode(code: string) {
aiChatEditorHandler?.reviewAndApply(code)
}
$: $copilotInfo.exists_ai_resource &&
$copilotInfo.code_completion_model &&
function addChatHandler(editor: meditor.IStandaloneCodeEditor) {
aiChatEditorHandler = new AIChatEditorHandler(editor)
reviewingChanges = aiChatEditorHandler.reviewingChanges
}
$: $reviewingChanges && autocompletor?.reject()
let completorDisposable: Disposable | undefined = undefined
let autocompletor: Autocompletor | undefined = undefined
function addSuperCompletor(editor: meditor.IStandaloneCodeEditor) {
if (completorDisposable) {
completorDisposable.dispose()
}
autocompletor = new Autocompletor(editor, lang)
// last user events (currently disabled):
// let lastTs = Date.now()
// editor.onDidChangeModelContent((e) => {
// const thisTs = Date.now()
// lastTs = thisTs
// setTimeout(() => {
// if (thisTs === lastTs) {
// autocompletor?.savePatch()
// }
// }, 150)
// })
completorDisposable = editor.onDidChangeCursorPosition((e) => {
autocompletor?.reject()
if ($reviewingChanges) {
return
}
const position = editor.getPosition()
if (!position) {
return
}
const upToText = editor.getModel()?.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 0,
endLineNumber: position.lineNumber,
endColumn: position.column
})
const lastChar = upToText ? upToText[upToText.length - 1] : ''
if (lastChar && lastChar.match(/[\(\{\s:="',]/)) {
autocompletor?.predict()
}
})
editor.addCommand(KeyCode.Tab, () => {
if (autocompletor?.hasChanges()) {
autocompletor?.accept()
autocompletor?.predict()
} else {
editor.trigger('keyboard', 'tab', {})
}
})
editor.onKeyDown((e) => {
if (e.keyCode === KeyCode.Escape) {
autocompletor?.reject()
}
})
}
$: $copilotInfo.enabled &&
$copilotInfo.codeCompletionModel &&
$codeCompletionSessionEnabled &&
initialized &&
addCopilotSuggestions()
editor &&
addSuperCompletor(editor)
$: !$codeCompletionSessionEnabled && copilotCompletor && copilotCompletor.dispose()
$: $copilotInfo.enabled && initialized && editor && addChatHandler(editor)
$: !$codeCompletionSessionEnabled && completorDisposable && completorDisposable.dispose()
const outputChannel = {
name: 'Language Server Client',
@@ -1121,7 +1130,7 @@
initialized = true
try {
model = meditor.createModel(code, (lang == 'nu') ? 'python' : lang, mUri.parse(uri))
model = meditor.createModel(code, lang == 'nu' ? 'python' : lang, mUri.parse(uri))
} catch (err) {
console.log('model already existed', err)
const nmodel = meditor.getModel(mUri.parse(uri))
@@ -1182,6 +1191,14 @@
editor?.trigger('keyboard', 'editor.action.commentLine', {})
})
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyL, function () {
dispatch('toggleAiPanel')
})
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyU, function () {
dispatch('toggleTestPanel')
})
if (
!websocketAlive.deno &&
!websocketAlive.pyright &&
@@ -1199,6 +1216,7 @@
reloadWebsocket()
setTypescriptExtraLibs()
setTypescriptRTNamespace()
return () => {
console.log('disposing editor')
ata = undefined
@@ -1214,6 +1232,27 @@
}
}
async function setTypescriptRTNamespace() {
if (
scriptLang &&
(scriptLang === 'bun' ||
scriptLang === 'deno' ||
scriptLang === 'bunnative' ||
scriptLang === 'nativets')
) {
const resourceTypes = await ResourceService.listResourceType({
workspace: $workspaceStore ?? ''
})
const namespace = formatResourceTypes(
resourceTypes,
scriptLang === 'bunnative' ? 'bun' : scriptLang
)
languages.typescript.typescriptDefaults.addExtraLib(namespace, 'rt.d.ts')
}
}
async function setTypescriptExtraLibs() {
if (lang === 'typescript' && scriptLang != 'deno') {
const hostname = getHostname()
@@ -1314,7 +1353,7 @@
disposeMethod && disposeMethod()
websocketInterval && clearInterval(websocketInterval)
sqlSchemaCompletor && sqlSchemaCompletor.dispose()
copilotCompletor && copilotCompletor.dispose()
completorDisposable && completorDisposable.dispose()
sqlTypeCompletor && sqlTypeCompletor.dispose()
timeoutModel && clearTimeout(timeoutModel)
})
@@ -1341,6 +1380,17 @@
<div class="fixed bottom-0 z-30" bind:this={statusDiv} />
{/if}
{#if $reviewingChanges}
<GlobalReviewButtons
on:acceptAll={() => {
aiChatEditorHandler?.acceptAll()
}}
on:rejectAll={() => {
aiChatEditorHandler?.rejectAll()
}}
/>
{/if}
<style global lang="postcss">
.editor {
@apply p-0;
@@ -857,7 +857,6 @@
try {
push(history, $flowStore)
let module = stepOnly ? $copilotModulesStore[0] : $copilotModulesStore[idx]
const aiProvider = $copilotInfo.ai_provider
copilotLoading = true
copilotStatus = "Generating code for step '" + module.id + "'..."
@@ -987,8 +986,7 @@
})
: undefined,
isFirstInLoop,
abortController,
aiProvider
abortController
)
unsubscribe()
}
@@ -1004,7 +1002,7 @@
pastModule.value.type === 'script')
) {
const stepSchema: Schema = JSON.parse(JSON.stringify($flowStateStore[module.id].schema)) // deep copy
if (isHubStep && pastModule !== undefined && $copilotInfo.exists_ai_resource) {
if (isHubStep && pastModule !== undefined && $copilotInfo.enabled) {
// ask AI to set step inputs
abortController = new AbortController()
const { inputs, allExprs } = await glueCopilot(
@@ -1014,8 +1012,7 @@
value: RawScript | PathScript
},
isFirstInLoop,
abortController,
aiProvider
abortController
)
// create flow inputs used by AI for autocompletion
@@ -1063,7 +1060,7 @@
$shouldUpdatePropertyType[key] = 'javascript'
})
} else {
if (isHubStep && pastModule !== undefined && !$copilotInfo.exists_ai_resource) {
if (isHubStep && pastModule !== undefined && !$copilotInfo.enabled) {
sendUserToast(
'For better input generation, enable Windmill AI in the workspace settings',
true
@@ -17,9 +17,11 @@
import { copyToClipboard } from '$lib/utils'
import { ClipboardCopy } from 'lucide-svelte'
import HighlightTheme from './HighlightTheme.svelte'
import type { LanguageType } from 'svelte-highlight/languages'
export let code: string = ''
export let language: Script['language'] | 'bunnative' | 'frontend' | undefined
export let highlightLanguage: LanguageType<string> | undefined = undefined
export let lines = false
function getLang(lang: Script['language'] | 'bunnative' | 'frontend' | undefined) {
@@ -63,18 +65,18 @@
case 'nu':
return python
case 'ansible':
return yaml;
return yaml
default:
return typescript
}
}
$: lang = getLang(language)
$: lang = highlightLanguage ?? getLang(language)
</script>
<HighlightTheme />
<div class="relative overflow-x-auto">
<div class="relative">
<Button
wrapperClasses="absolute top-2 right-2 z-20"
on:click={() => copyToClipboard(code)}
@@ -85,17 +87,19 @@
}}
iconOnly
/>
{#if code?.length < 10000}
{#if !lines}
<Highlight class="nowrap {$$props.class}" language={lang} {code} />
<div class="overflow-x-auto">
{#if code?.length < 10000}
{#if !lines}
<Highlight class="nowrap {$$props.class}" language={lang} {code} />
{:else}
<Highlight class="nowrap {$$props.class}" language={lang} {code} let:highlighted>
<LineNumbers {highlighted} />
</Highlight>
{/if}
{:else}
<Highlight class="nowrap {$$props.class}" language={lang} {code} let:highlighted>
<LineNumbers {highlighted} />
</Highlight>
<pre class="overflow-auto max-h-screen text-xs {$$props.class}"
><code class="language-{language}">{code}</code></pre
>
{/if}
{:else}
<pre class="overflow-auto max-h-screen text-xs {$$props.class}"
><code class="language-{language}">{code}</code></pre
>
{/if}
</div>
</div>
+189 -7
View File
@@ -3,7 +3,7 @@
import type { Schema, SupportedLanguage } from '$lib/common'
import { type CompletedJob, type Job, JobService, type Preview } from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { copilotInfo, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { copyToClipboard, emptySchema, sendUserToast } from '$lib/utils'
import Editor from './Editor.svelte'
import { inferArgs } from '$lib/infer'
@@ -22,7 +22,15 @@
import { WebsocketProvider } from 'y-websocket'
import Modal from './common/modal/Modal.svelte'
import DiffEditor from './DiffEditor.svelte'
import { Clipboard, CornerDownLeft, Github, Play } from 'lucide-svelte'
import {
Clipboard,
CornerDownLeft,
ExternalLink,
Github,
Play,
PlayIcon,
WandSparkles
} from 'lucide-svelte'
import { setLicense } from '$lib/enterpriseUtils'
import type { ScriptEditorWhitelabelCustomUi } from './custom_ui'
import Tabs from './common/tabs/Tabs.svelte'
@@ -30,7 +38,11 @@
import { slide } from 'svelte/transition'
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import CaptureButton from './triggers/CaptureButton.svelte'
import AIChat from './copilot/chat/AIChat.svelte'
import { setContext } from 'svelte'
import HideButton from './apps/editor/settingsPanel/HideButton.svelte'
import { base } from '$lib/base'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './copilot/chat/core'
// Exported
export let schema: Schema | any = emptySchema()
@@ -107,6 +119,12 @@
if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') {
event.preventDefault()
runTest()
} else if ((event.ctrlKey || event.metaKey) && event.key == 'l') {
event.preventDefault()
toggleAiPanel()
} else if ((event.ctrlKey || event.metaKey) && event.key == 'u') {
event.preventDefault()
toggleTestPanel()
}
}
@@ -268,6 +286,60 @@
let setFocusToLogs = () => {}
setContext('disableTooltips', customUi?.disableTooltips === true)
let aiPanelSize =
!$copilotInfo.enabled ||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '') ||
localStorage.getItem('aiPanelOpen') === 'false'
? 0
: 30
let codePanelSize = 40 + (30 - aiPanelSize)
let storedAiPanelSize = aiPanelSize > 0 ? aiPanelSize : 30
let testPanelSize = 30
let storedTestPanelSize = testPanelSize
function toggleAiPanel() {
if (!$copilotInfo.enabled) return
if (aiPanelSize > 0) {
storedAiPanelSize = aiPanelSize
codePanelSize += aiPanelSize
aiPanelSize = 0
localStorage.setItem('aiPanelOpen', 'false')
} else {
codePanelSize -= storedAiPanelSize
aiPanelSize = storedAiPanelSize
localStorage.setItem('aiPanelOpen', 'true')
}
}
$: !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '') && aiPanelSize > 0 && toggleAiPanel()
function toggleTestPanel() {
if (testPanelSize > 0) {
storedTestPanelSize = testPanelSize
codePanelSize += testPanelSize
testPanelSize = 0
} else {
codePanelSize -= storedTestPanelSize
testPanelSize = storedTestPanelSize
}
}
let aiChat: AIChat | undefined = undefined
function getStringError(job: Job | undefined) {
if (
job != undefined &&
job.type === 'CompletedJob' &&
typeof job.result == 'object' &&
job.result != null &&
`error` in job.result &&
job.result.error
) {
return JSON.stringify(job.result.error, null, 2)
}
return undefined
}
$: error = getStringError(testJob)
</script>
<TestJobLoader
@@ -304,7 +376,7 @@
setCollaborationMode()
}
}}
customUi={customUi?.editorBar}
customUi={{ ...customUi?.editorBar, aiGen: false }}
collabLive={wsProvider?.shouldConnect}
{collabMode}
{validCode}
@@ -344,8 +416,54 @@
</div>
<SplitPanesWrapper>
<Splitpanes class="!overflow-visible">
<Pane size={60} minSize={10} class="!overflow-visible">
<div class="h-full !overflow-visible bg-gray-50 dark:bg-[#272D38]">
<Pane bind:size={codePanelSize} minSize={10} class="!overflow-visible">
<div class="h-full !overflow-visible bg-gray-50 dark:bg-[#272D38] relative">
<div class="absolute top-2 right-4 z-10 flex flex-row gap-2">
{#if aiPanelSize === 0}
{#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')}
<HideButton
hidden={true}
direction="right"
panelName="AI"
shortcut="L"
size="md"
usePopoverOverride={!$copilotInfo.enabled}
customHiddenIcon={WandSparkles}
btnClasses="!text-violet-800 dark:!text-violet-400 border border-gray-200 dark:border-gray-600 bg-surface"
on:click={() => {
toggleAiPanel()
}}
>
<svelte:fragment slot="popoverOverride">
<div class="text-sm">
Enable Windmill AI in the <a
href="{base}/workspace_settings?tab=ai"
target="_blank"
class="inline-flex flex-row items-center gap-1"
>
workspace settings <ExternalLink size={16} />
</a>
</div>
</svelte:fragment>
</HideButton>
{/if}
{#if testPanelSize === 0}
<HideButton
hidden={true}
direction="right"
size="md"
panelName="Test"
shortcut="U"
customHiddenIcon={PlayIcon}
on:click={() => {
toggleTestPanel()
}}
btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70"
color="marine"
/>
{/if}
{/if}
</div>
{#key lang}
<Editor
lineNumbersMinChars={4}
@@ -360,6 +478,8 @@
inferSchema(e.detail)
}}
on:saveDraft
on:toggleAiPanel={toggleAiPanel}
on:toggleTestPanel={toggleTestPanel}
cmdEnterAction={async () => {
await inferSchema(code)
runTest()
@@ -390,7 +510,53 @@
{/key}
</div>
</Pane>
<Pane size={40} minSize={10}>
{#if lang && $copilotInfo.enabled}
<Pane bind:size={aiPanelSize} minSize={0}>
<AIChat
bind:this={aiChat}
{code}
{lang}
{error}
{args}
{path}
on:applyCode={(e) => {
editor?.reviewAndApplyCode(e.detail.code)
}}
>
<svelte:fragment slot="header-left">
<HideButton
hidden={false}
direction="right"
panelName="AI"
shortcut="L"
size="md"
on:click={() => {
toggleAiPanel()
}}
/>
</svelte:fragment>
<svelte:fragment slot="header-right">
{#if testPanelSize === 0}
<div class="bg-gray-200 h-6 w-[1px] rounded-full dark:bg-gray-600" />
<HideButton
hidden={true}
direction="right"
panelName="Test"
shortcut="U"
size="md"
customHiddenIcon={PlayIcon}
on:click={() => {
toggleTestPanel()
}}
btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70"
color="marine"
/>
{/if}
</svelte:fragment>
</AIChat>
</Pane>
{/if}
<Pane bind:size={testPanelSize} minSize={0}>
<div class="flex flex-col h-full">
{#if showTabs}
<div transition:slide={{ duration: 200 }}>
@@ -405,7 +571,19 @@
</div>
{/if}
<div class="flex justify-center pt-1">
<div class="flex justify-center pt-1 relative">
<div class="absolute top-2 left-2">
<HideButton
hidden={false}
direction="right"
panelName="Test"
shortcut="U"
size="md"
on:click={() => {
toggleTestPanel()
}}
/>
</div>
{#if testIsLoading}
<Button on:click={testJobLoader?.cancelJob} btnClasses="w-full" color="red" size="xs">
<WindmillIcon
@@ -490,6 +668,10 @@
<Pane size={67} class="relative">
<LogPanel
bind:setFocusToLogs
on:fix={() => {
aiChat?.fix()
}}
fixChatMode
{lang}
previewJob={testJob}
{pastPreviews}
+1 -1
View File
@@ -23,7 +23,7 @@
export let size: 'sm' | 'xs' | '2xs' = 'sm'
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher<{ change: boolean }>()
const bothOptions = Boolean(options.left) && Boolean(options.right)
export let textDisabled = false
@@ -3,16 +3,36 @@
import { ButtonType } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import { getModifierKey } from '$lib/utils'
import { PanelBottomClose, PanelLeftClose, PanelRightClose } from 'lucide-svelte'
import {
type Icon,
PanelBottomClose,
PanelBottomOpen,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen
} from 'lucide-svelte'
import type { ComponentType } from 'svelte'
import { twMerge } from 'tailwind-merge'
export let btnClasses: string | undefined = undefined
export let size: ButtonType.Size = 'xs'
export let variant: ButtonType.Variant = 'contained'
export let color: ButtonType.Color = 'light'
export let direction: 'left' | 'right' | 'bottom' = 'right'
export let hidden: boolean = false
export let shortcut: string | undefined = undefined
export let panelName: string | undefined = undefined
export let customHiddenIcon: ComponentType<Icon> | undefined = undefined
export let usePopoverOverride: boolean = false
const IconMap = {
const OpenIconMap = {
left: PanelLeftOpen,
right: PanelRightOpen,
bottom: PanelBottomOpen
}
const CloseIconMap = {
left: PanelLeftClose,
right: PanelRightClose,
bottom: PanelBottomClose
@@ -27,18 +47,22 @@
<Popover>
<svelte:fragment slot="text">
<div class="flex flex-row gap-1">
{hidden ? 'Show' : 'Hide '} the {direction} panel.
{#if usePopoverOverride && $$slots.popoverOverride}
<slot name="popoverOverride" />
{:else}
<div class="flex flex-row gap-1">
{hidden ? 'Show' : 'Hide '} the {panelName ?? direction} panel.
<div class="flex flex-row items-center !text-md opacity-60 gap-0 font-normal">
{getModifierKey()}{shortcuts[direction]}
<div class="flex flex-row items-center !text-md opacity-60 gap-0 font-normal">
{getModifierKey()}{shortcut ?? shortcuts[direction]}
</div>
</div>
</div>
{/if}
</svelte:fragment>
<Button
iconOnly
startIcon={{
icon: IconMap[direction]
icon: hidden ? customHiddenIcon ?? OpenIconMap[direction] : CloseIconMap[direction]
}}
{size}
btnClasses={twMerge(
@@ -47,6 +71,7 @@
btnClasses
)}
on:click
color="light"
{variant}
{color}
/>
</Popover>
@@ -2,17 +2,17 @@
import {
codeCompletionSessionEnabled,
copilotInfo,
FORMAT_ON_SAVE_SETTING_NAME
CODE_COMPLETION_SETTING_NAME
} from '$lib/stores'
import { storeLocalSetting } from '$lib/utils'
import Toggle from '../Toggle.svelte'
function storeSetting() {
storeLocalSetting(FORMAT_ON_SAVE_SETTING_NAME, $codeCompletionSessionEnabled.toString())
storeLocalSetting(CODE_COMPLETION_SETTING_NAME, $codeCompletionSessionEnabled.toString())
}
</script>
{#if $copilotInfo.exists_ai_resource && $copilotInfo.code_completion_model}
{#if $copilotInfo.enabled && $copilotInfo.codeCompletionModel}
<Toggle
size="xs"
bind:checked={$codeCompletionSessionEnabled}
@@ -8,7 +8,6 @@
import { base } from '$lib/base'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import type { AIProvider } from '$lib/gen'
export let schedule: string
export let cronVersion: string
@@ -41,7 +40,6 @@
async function generateCron() {
genLoading = true
abortController = new AbortController()
const aiProvider = $copilotInfo.ai_provider
try {
const messages: ChatCompletionMessageParam[] = [
{
@@ -54,11 +52,7 @@
}
]
const response = await getNonStreamingCompletion(
messages,
abortController,
aiProvider as AIProvider
)
const response = await getNonStreamingCompletion(messages, abortController)
if (response.startsWith('ERROR:')) {
throw response.replace('ERROR:', '').trim()
@@ -91,7 +85,7 @@
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="border rounded-lg shadow-lg p-4 bg-surface">
{#if $copilotInfo.exists_ai_resource}
{#if $copilotInfo.enabled}
<div class="flex w-96">
<input
bind:this={instructionsField}
@@ -31,7 +31,7 @@
$: copilotStatus && copilotPopover?.refresh()
</script>
{#if $copilotInfo.exists_ai_resource}
{#if $copilotInfo.enabled}
<ManualPopover bind:this={copilotPopover}>
<Button
size="xs"
@@ -62,7 +62,6 @@ ${YAML.stringify(availableData)}</available>
Reply with the most probable answer, do not explain or discuss.
Use javascript object dot notation to access the properties.
Only output the expression, do not explain or discuss.`
const aiProvider = $copilotInfo.ai_provider
generatedContent = await getNonStreamingCompletion(
[
{
@@ -70,8 +69,7 @@ Only output the expression, do not explain or discuss.`
content: user
}
],
abortController,
aiProvider
abortController
)
} catch (err) {
if (!abortController.signal.aborted) {
@@ -83,7 +81,7 @@ Only output the expression, do not explain or discuss.`
}
export function onKeyUp(event: KeyboardEvent) {
if (!$copilotInfo.exists_ai_resource || !$stepInputCompletionEnabled) {
if (!$copilotInfo.enabled || !$stepInputCompletionEnabled) {
return
}
if (event.key === 'Tab') {
@@ -118,7 +116,7 @@ Only output the expression, do not explain or discuss.`
cancelOnOutOfFocus()
}
$: if ($copilotInfo.exists_ai_resource && $stepInputCompletionEnabled && focused) {
$: if ($copilotInfo.enabled && $stepInputCompletionEnabled && focused) {
automaticGeneration()
}
@@ -132,7 +130,7 @@ Only output the expression, do not explain or discuss.`
let out = true // hack to prevent regenerating answer when accepting the answer due to mouseenter on new icon
</script>
{#if $copilotInfo.exists_ai_resource && $stepInputCompletionEnabled}
{#if $copilotInfo.enabled && $stepInputCompletionEnabled}
<ManualPopover showTooltip={!empty && generatedContent.length > 0} placement="bottom" class="p-2">
<Button
size="xs"
@@ -118,11 +118,10 @@ Generate a description for the flow below:
content: config.user.replace(`{${config.placeholderName}}`, placeholderContent)
}
]
const aiProvider = $copilotInfo.ai_provider
const response = await getCompletion(messages, abortController, aiProvider)
const response = await getCompletion(messages, abortController)
generatedContent = ''
for await (const chunk of response) {
generatedContent += getResponseFromEvent(chunk, aiProvider)
generatedContent += getResponseFromEvent(chunk)
}
} catch (err) {
if (!abortController.signal.aborted) {
@@ -138,7 +137,7 @@ Generate a description for the flow below:
}
if (
$copilotInfo.exists_ai_resource &&
$copilotInfo.enabled &&
$metadataCompletionEnabled &&
generateOnAppear &&
!content &&
@@ -158,7 +157,7 @@ Generate a description for the flow below:
}
$: active =
$copilotInfo.exists_ai_resource &&
$copilotInfo.enabled &&
$metadataCompletionEnabled &&
!content &&
(loading || focused || !!generatedContent) &&
@@ -192,7 +191,7 @@ Generate a description for the flow below:
class={twMerge('relative', $$props.class)}
bind:clientWidth={width}
on:keydown={(event) => {
if (!$copilotInfo.exists_ai_resource || !$metadataCompletionEnabled) {
if (!$copilotInfo.enabled || !$metadataCompletionEnabled) {
return
}
if (event.key === 'Tab') {
@@ -59,7 +59,6 @@ Here's a summary of the available data:
${YAML.stringify(availableData)}</available>
If the branching is made inside a for-loop, the iterator value is accessible as flow_input.iter.value
Only return the expression without any wrapper. Do not explain or discuss.`
const aiProvider = $copilotInfo.ai_provider
const result = await getNonStreamingCompletion(
[
{
@@ -67,8 +66,7 @@ Only return the expression without any wrapper. Do not explain or discuss.`
content: user
}
],
abortController,
aiProvider
abortController
)
dispatch('setExpr', result)
@@ -83,7 +81,7 @@ Only return the expression without any wrapper. Do not explain or discuss.`
}
</script>
{#if $copilotInfo.exists_ai_resource && $stepInputCompletionEnabled}
{#if $copilotInfo.enabled && $stepInputCompletionEnabled}
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
contentClasses="p-4 flex w-96"
@@ -25,7 +25,6 @@
savePrompt()
genLoading = true
abortController = new AbortController()
const aiProvider = $copilotInfo.ai_provider
try {
const res = await getNonStreamingCompletion(
[
@@ -39,8 +38,7 @@
content: funcDesc
}
],
abortController,
aiProvider
abortController
)
dispatch('gen', { res: res, prompt: funcDesc })
funcDesc = ''
@@ -116,7 +114,7 @@
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="block text-primary p-4">
{#if $copilotInfo.exists_ai_resource}
{#if $copilotInfo.enabled}
<div class="flex flex-col gap-4">
<div class="flex w-96">
<input
@@ -16,6 +16,7 @@
import LoadingIcon from '../apps/svelte-select/lib/LoadingIcon.svelte'
import { autoPlacement } from '@floating-ui/core'
import { Check, Wand2, X, RotateCw } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
// props
export let lang: SupportedLanguage
@@ -23,6 +24,7 @@
export let diffEditor: DiffEditor | undefined
export let error: string
export let args: Record<string, any>
export let chatMode: boolean = false
// state
let genLoading: boolean = false
@@ -31,6 +33,10 @@
let dbSchema: DBSchema | undefined = undefined
let abortController: AbortController | undefined = undefined
const dispatch = createEventDispatcher<{
fix: null
}>()
async function onFix() {
if (!error) {
return
@@ -38,7 +44,6 @@
try {
genLoading = true
abortController = new AbortController()
const aiProvider = $copilotInfo.ai_provider
await copilot(
{
language: lang,
@@ -50,7 +55,6 @@
},
generatedCode,
abortController,
aiProvider,
generatedExplanation
)
setupDiff()
@@ -183,8 +187,10 @@
color={genLoading ? 'red' : 'light'}
spacingSize="xs2"
startIcon={genLoading ? undefined : { icon: Wand2 }}
propagateEvent={true}
on:click={genLoading
propagateEvent={!chatMode}
on:click={chatMode
? () => dispatch('fix')
: genLoading
? () => abortController?.abort()
: $generatedCode.length > 0
? undefined
@@ -212,7 +218,7 @@
</svelte:fragment>
<svelte:fragment slot="content">
<div class="p-4">
{#if $copilotInfo.exists_ai_resource}
{#if $copilotInfo.enabled}
<div class="w-[42rem] min-h-[3rem] max-h-[34rem] overflow-y-auto">
{#if $generatedCode.length > 0 && genLoading}
<div class="overflow-x-scroll">
@@ -65,7 +65,6 @@
return
}
savePrompt()
const aiProvider = $copilotInfo.ai_provider
try {
genLoading = true
blockPopupOpen = true
@@ -82,8 +81,7 @@
workspace: $workspaceStore!
},
generatedCode,
abortController,
aiProvider
abortController
)
} else {
await copilot(
@@ -96,8 +94,7 @@
workspace: $workspaceStore!
},
generatedCode,
abortController,
aiProvider
abortController
)
}
setupDiff()
@@ -239,16 +236,6 @@
}
}
function checkForInvalidModel() {
if (
!$copilotSessionModel ||
($copilotSessionModel && !$copilotInfo.ai_models.includes($copilotSessionModel))
) {
$copilotSessionModel = $copilotInfo.ai_models[0]
}
}
$: $copilotInfo && checkForInvalidModel()
function handlePublicOnlySelected({ detail }: { detail: string }) {
if (!dbSchema) return
;(dbSchema as any).publicOnly = detail === 'true'
@@ -403,7 +390,7 @@
<LoadingIcon />
{/if}
</div>
{:else if $copilotInfo.exists_ai_resource}
{:else if $copilotInfo.enabled}
<div class="flex flex-col gap-4">
<div class="flex flex-row justify-between items-center w-96 gap-2">
<ToggleButtonGroup class="w-auto shrink-0 h-auto" bind:selected={mode} let:item>
@@ -419,25 +406,27 @@
<div class="min-w-0">
<TooltipV2>
{#if $copilotInfo.ai_models.length > 1}
{#if $copilotInfo.aiModels.length > 1}
<select
bind:value={$copilotSessionModel}
class="!text-xs !pr-5 !bg-[right_center] overflow-ellipsis text-right !border-none !shadow-none"
>
{#each $copilotInfo.ai_models as model}
<option value={model} class="pr-4">{model}</option>
{#each $copilotInfo.aiModels as providerModel}
<option value={providerModel.model} class="pr-4"
>{providerModel.model}</option
>
{/each}
</select>
{:else if $copilotInfo.ai_models.length === 1}
{:else if $copilotInfo.aiModels.length === 1}
<div class="text-xs whitespace-nowrap overflow-hidden overflow-ellipsis">
{$copilotInfo.ai_models[0]}
{$copilotInfo.aiModels[0].model}
</div>
{/if}
<svelte:fragment slot="text">
<span class="text-xs"
>{$copilotInfo.ai_models.length > 1
>{$copilotInfo.aiModels.length > 1
? $copilotSessionModel
: $copilotInfo.ai_models[0]}</span
: $copilotInfo.aiModels[0].model}</span
>
</svelte:fragment>
</TooltipV2>
@@ -70,7 +70,7 @@
}
async function onGenerate() {
if (!selectedCompletion && !$copilotInfo.exists_ai_resource) {
if (!selectedCompletion && !$copilotInfo.enabled) {
sendUserToast(
'Windmill AI is not enabled, you can activate it in the workspace settings',
true
@@ -107,7 +107,6 @@ If none of the available results are appropriate, are already used or are more a
Reply with the most probable answer, do not explain or discuss.
Use javascript object dot notation to access the properties.
Only return the expression without any wrapper.`
const aiProvider = $copilotInfo.ai_provider
generatedContent = await getNonStreamingCompletion(
[
{
@@ -115,8 +114,7 @@ Only return the expression without any wrapper.`
content: user
}
],
abortController,
aiProvider
abortController
)
if (
@@ -139,7 +137,7 @@ Only return the expression without any wrapper.`
}
export function onKeyUp(event: KeyboardEvent) {
if (!$copilotInfo.exists_ai_resource || !$stepInputCompletionEnabled) {
if (!$copilotInfo.enabled || !$stepInputCompletionEnabled) {
return
}
if (event.key === 'Tab') {
@@ -182,7 +180,7 @@ Only return the expression without any wrapper.`
cancelOnOutOfFocus()
}
$: if ($copilotInfo.exists_ai_resource && $stepInputCompletionEnabled && focused) {
$: if ($copilotInfo.enabled && $stepInputCompletionEnabled && focused) {
automaticGeneration()
}
@@ -194,7 +192,7 @@ Only return the expression without any wrapper.`
let openInputsModal = false
</script>
{#if $copilotInfo.exists_ai_resource && $stepInputCompletionEnabled}
{#if $copilotInfo.enabled && $stepInputCompletionEnabled}
<FlowCopilotInputsModal
on:confirmed={async () => {
createFlowInput()
@@ -83,7 +83,6 @@ Your answer has to be in the following format (one line per input):
input_name1: expression1
input_name2: expression2
...`
const aiProvider = $copilotInfo.ai_provider
generatedContent = await getNonStreamingCompletion(
[
{
@@ -91,8 +90,7 @@ input_name2: expression2
content: user
}
],
abortController,
aiProvider
abortController
)
parsedInputs = generatedContent.split('\n').map((x) => x.split(': '))
@@ -169,7 +167,7 @@ input_name2: expression2
</script>
<div class="flex flex-row justify-end">
{#if $copilotInfo.exists_ai_resource && $stepInputCompletionEnabled}
{#if $copilotInfo.enabled && $stepInputCompletionEnabled}
<FlowCopilotInputsModal
on:confirmed={async () => {
createFlowInputs()
@@ -238,7 +236,7 @@ input_name2: expression2
<svelte:fragment slot="content" let:close>
<div class="p-4">
<p class="text-sm">
{#if !$copilotInfo.exists_ai_resource}
{#if !$copilotInfo.enabled}
Enable Windmill AI in the{' '}
<a
href="{base}/workspace_settings?tab=ai"
@@ -0,0 +1,567 @@
import { type Change, createTwoFilesPatch, diffLines, diffWordsWithSpace } from 'diff'
import { type editor as meditor } from 'monaco-editor'
import { autocompleteRequest } from './request'
import { sleep } from '$lib/utils'
import { displayVisualChanges, getLines, setGlobalCSS, type VisualChange } from '../shared'
function lineChangesToVisualChanges(changes: Change[], startLineNumber: number) {
let originalLineNumber = startLineNumber
let visualChanges: VisualChange[] = []
let removedLines: string[] = []
for (const c of changes) {
if (c.removed) {
const lines = getLines(c.value)
originalLineNumber += lines.length
removedLines.push(...lines)
} else if (c.added) {
const newLines = getLines(c.value)
const removedStartLineNumber = originalLineNumber - removedLines.length
let afterLines: string[] = []
for (const [idx, newLine] of newLines.entries()) {
const originalLine = removedLines[idx]
if (originalLine !== undefined) {
const lineDiff = diffWordsWithSpace(originalLine, newLine)
const firstRemovedChangeIdx = lineDiff.findIndex((c) => c.removed)
if (firstRemovedChangeIdx !== -1 && lineDiff.length > 3) {
let startColumn = 1
let newLineContent = newLine
const firstChange = lineDiff[0]
if (
!firstChange.added &&
!firstChange.removed &&
firstChange.value.trim().length === 0
) {
startColumn += firstChange.value.length
newLineContent = newLineContent.slice(firstChange.value.length)
}
visualChanges.push({
type: 'deleted',
range: {
startLine: removedStartLineNumber + idx,
startColumn,
endLine: removedStartLineNumber + idx,
endColumn: 10000
}
})
visualChanges.push({
type: 'added_inline',
position: {
line: removedStartLineNumber + idx,
column: 10000
},
value: newLineContent,
options: {
greenHighlight: true
}
})
} else {
let col = 1
let removedChars = 0
for (const charChange of lineDiff) {
if (charChange.added) {
visualChanges.push({
type: 'added_inline',
position: {
line: removedStartLineNumber + idx,
column: col
},
value: charChange.value,
options: {
greenHighlight: removedChars > 0
}
})
removedChars = Math.max(0, removedChars - charChange.value.length)
} else if (charChange.removed) {
visualChanges.push({
type: 'deleted',
range: {
startLine: removedStartLineNumber + idx,
startColumn: col,
endLine: removedStartLineNumber + idx,
endColumn: col + charChange.value.length
}
})
removedChars += charChange.value.length
col += charChange.value.length
} else {
col += charChange.value.length
removedChars = 0
}
}
}
} else {
afterLines.push(newLine)
}
}
if (afterLines.length > 0) {
visualChanges.push({
type: 'added_block',
position: {
afterLineNumber: originalLineNumber - 1
},
value: afterLines.join('\n')
})
}
if (removedLines.length > newLines.length) {
for (let i = 0; i < removedLines.length - newLines.length; i++) {
visualChanges.push({
type: 'deleted',
range: {
startLine: removedStartLineNumber + newLines.length + i,
startColumn: 0,
endLine: removedStartLineNumber + newLines.length + i,
endColumn: 100000
}
})
}
}
removedLines = []
} else {
if (removedLines.length > 0) {
visualChanges.push({
type: 'deleted',
range: {
startLine: originalLineNumber - removedLines.length,
startColumn: 0,
endLine: originalLineNumber - 1,
endColumn: 10000
}
})
}
originalLineNumber += c.count!
removedLines = []
}
}
if (removedLines.length > 0) {
visualChanges.push({
type: 'deleted',
range: {
startLine: originalLineNumber - removedLines.length,
startColumn: 0,
endLine: originalLineNumber - 1,
endColumn: 10000
}
})
}
return visualChanges
}
const MAX_PATCHES = 4
export class Autocompletor {
editor: meditor.IStandaloneCodeEditor
language: string
viewZoneIds: string[] = []
decorationsCollection: meditor.IEditorDecorationsCollection | undefined = undefined
visualChanges: VisualChange[] = []
modifiedCode: string = ''
applyZone:
| {
startLineNumber: number
endLineNumber: number
}
| undefined = undefined
lastChangePosition:
| {
lineNumber: number
column: number
}
| undefined = undefined
abortController: AbortController | undefined = undefined
lastTs = Date.now()
lastCodeValue: string
patches: string[] = []
predictedChange:
| {
position: {
lineNumber: number
column: number
}
distance: number
}
| undefined = undefined
tabWidget: meditor.IContentWidget | undefined = undefined
constructor(editor: meditor.IStandaloneCodeEditor, language: string) {
this.editor = editor
this.language = language
this.lastCodeValue = editor.getModel()?.getValue() || ''
}
savePatch() {
const currentCode = this.editor.getModel()?.getValue() || ''
const patch = createTwoFilesPatch(
'',
'',
this.lastCodeValue,
currentCode,
undefined,
undefined,
{
context: 1
}
)
.split('\n')
.slice(4)
.join('\n')
this.patches.push(patch)
this.lastCodeValue = currentCode
if (this.patches.length > MAX_PATCHES) {
this.patches.shift()
}
}
async predict() {
this.reject()
await this.autocomplete()
this.computeNextPosition()
this.displayPrediction()
}
computeNextPosition() {
if (this.visualChanges.length > 0) {
const position = this.editor.getPosition()
if (!position) {
return
}
let closestPosition:
| {
lineNumber: number
column: number
}
| undefined = undefined
let closestDistance = Infinity
for (const change of this.visualChanges) {
if (change.type === 'deleted') {
const distance = Math.min(
Math.abs(change.range.startLine - position.lineNumber) +
Math.abs(change.range.startColumn - position.column) / 10000,
Math.abs(change.range.endLine - position.lineNumber) +
Math.abs(change.range.endColumn - position.column) / 10000
)
if (distance < closestDistance) {
closestDistance = distance
closestPosition = {
lineNumber: change.range.startLine,
column: change.range.startColumn
}
}
} else if (change.type === 'added_block') {
const distance = Math.abs(change.position.afterLineNumber - position.lineNumber) + 1
if (distance < closestDistance) {
closestDistance = distance
closestPosition = {
lineNumber: change.position.afterLineNumber,
column: 10000
}
}
} else if (change.type === 'added_inline') {
const distance =
Math.abs(change.position.line - position.lineNumber) +
Math.abs(change.position.column - position.column) / 10000
if (distance < closestDistance) {
closestDistance = distance
closestPosition = {
lineNumber: change.position.line,
column: change.position.column
}
}
}
}
this.predictedChange = closestPosition
? { position: closestPosition, distance: closestDistance }
: undefined
console.log('predictedChange', this.predictedChange, this.visualChanges)
}
}
displayPrediction() {
if (this.predictedChange) {
if (this.predictedChange.distance < 4) {
this.predictedChange = undefined
this.displayVisualChanges()
} else {
// display tab icon
const el = document.createElement('div')
el.textContent = 'TAB'
Object.assign(el.style, {
position: 'relative',
background: '#e7e5e4',
color: 'black',
padding: '4px',
fontSize: '10px',
borderRadius: '4px',
textAlign: 'center',
transform: 'translateX(-50%)',
zIndex: 1000,
opacity: 0.8
})
// Create the arrow (pseudo-element trick doesn't work directly via JS,
// so we create a separate element to act like the arrow)
const arrow = document.createElement('div')
Object.assign(arrow.style, {
content: '""',
position: 'absolute',
top: '-6px',
left: '50%',
transform: 'translateX(-50%)',
width: '0',
height: '0',
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderBottom: '6px solid #e7e5e4'
})
// Add arrow to box
el.appendChild(arrow)
this.tabWidget = {
getId: () => 'tab-widget',
getDomNode: () => el,
getPosition: () => {
if (!this.predictedChange) {
return null
}
return {
position: {
lineNumber: this.predictedChange.position.lineNumber,
column: this.predictedChange.position.column
},
preference: [2] // below
}
},
allowEditorOverflow: true
}
this.editor.addContentWidget(this.tabWidget)
}
}
}
async autocomplete() {
const position = this.editor.getPosition()
if (!position) {
return
}
const model = this.editor.getModel()
if (!model) {
return
}
const thisTs = Date.now()
this.lastTs = thisTs
await sleep(200)
if (model.isDisposed()) {
return
}
if (thisTs !== this.lastTs) {
return
}
this.abortController?.abort()
this.abortController = new AbortController()
let modifiableEnd = Math.min(model.getLineCount(), position.lineNumber + 7)
while (true) {
if (modifiableEnd <= position.lineNumber) {
break
}
const line = model.getLineContent(modifiableEnd)
if (line.trim().length > 0) {
break
}
modifiableEnd--
}
let modifiableStart = Math.max(1, position.lineNumber - 3)
while (true) {
if (modifiableStart >= modifiableEnd) {
break
}
const line = model.getLineContent(modifiableStart)
if (line.trim().length > 0) {
break
}
modifiableStart++
}
const newCursorLineNumber = Math.max(position.lineNumber, modifiableStart)
const newPos = {
lineNumber: newCursorLineNumber,
column: newCursorLineNumber === position.lineNumber ? position.column : 0
}
this.applyZone = {
startLineNumber: modifiableStart,
endLineNumber: modifiableEnd
}
const prefix = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: modifiableStart,
endColumn: 1
})
const suffix = model.getValueInRange({
startLineNumber: modifiableEnd + 1,
startColumn: 0,
endLineNumber: model.getLineCount(),
endColumn: 10000
})
const modifiablePrefix = model.getValueInRange({
startLineNumber: modifiableStart,
startColumn: 1,
endLineNumber: newPos.lineNumber,
endColumn: newPos.column
})
const modifiableSuffix = model.getValueInRange({
startLineNumber: newPos.lineNumber,
startColumn: newPos.column,
endLineNumber: modifiableEnd,
endColumn: 10000
})
const returnedCode = await autocompleteRequest(
{
prefix,
modifiablePrefix,
modifiableSuffix,
suffix,
language: this.language,
events: this.patches
},
this.abortController
)
if (!returnedCode) {
return
}
const editableCode = model.getValueInRange({
startLineNumber: modifiableStart,
startColumn: 1,
endLineNumber: modifiableEnd,
endColumn: 10000
})
const numberOfLines = modifiableEnd - modifiableStart + 1
let completionLines = getLines(returnedCode)
let finalCompletionLines: string[] = []
if (completionLines.length > numberOfLines) {
const nextFirstNonEmptyLine = suffix.split('\n').find((line) => line.trim().length > 8)
if (nextFirstNonEmptyLine) {
for (const line of completionLines) {
if (line === nextFirstNonEmptyLine) {
break
} else {
finalCompletionLines.push(line)
}
}
} else {
finalCompletionLines = completionLines
}
} else {
finalCompletionLines = completionLines
}
this.modifiedCode = finalCompletionLines.join('\n')
const changedLines = diffLines(editableCode, this.modifiedCode)
this.visualChanges = lineChangesToVisualChanges(changedLines, modifiableStart)
}
async displayVisualChanges() {
if (this.visualChanges.length > 0) {
const { collection, ids } = await displayVisualChanges(
'editor-windmill-autocomplete-style',
this.editor,
this.visualChanges
)
this.decorationsCollection = collection
this.viewZoneIds = ids
const lastAddChange = this.visualChanges
.reverse()
.find((c) => c.type === 'added_inline' || c.type === 'added_block')
if (lastAddChange) {
if (lastAddChange.type === 'added_inline') {
this.lastChangePosition = {
lineNumber: lastAddChange.position.line,
column: lastAddChange.position.column + lastAddChange.value.length
}
} else if (lastAddChange.type === 'added_block') {
this.lastChangePosition = {
lineNumber:
lastAddChange.position.afterLineNumber + lastAddChange.value.split('\n').length,
column: 10000
}
}
}
}
}
hasChanges() {
return this.modifiedCode.length > 0
}
accept() {
if (this.predictedChange) {
this.editor.setPosition(this.predictedChange.position)
}
if (!this.modifiedCode || !this.applyZone) {
return
}
this.editor.executeEdits('completion', [
{
range: {
startLineNumber: this.applyZone.startLineNumber,
startColumn: 1,
endLineNumber: this.applyZone.endLineNumber,
endColumn: 10000
},
text: this.modifiedCode
}
])
if (this.lastChangePosition) {
this.editor.setPosition(this.lastChangePosition)
}
this.reject()
}
reject() {
this.abortController?.abort()
this.editor.changeViewZones((acc) => {
for (const id of this.viewZoneIds) {
acc.removeZone(id)
}
this.viewZoneIds = []
})
this.decorationsCollection?.clear()
this.modifiedCode = ''
setGlobalCSS('editor-windmill-autocomplete-style', '')
this.predictedChange = undefined
this.tabWidget && this.editor.removeContentWidget(this.tabWidget)
this.tabWidget = undefined
this.visualChanges = []
}
}
@@ -0,0 +1,103 @@
import { codeCompletionLoading, copilotInfo } from '$lib/stores'
import { get } from 'svelte/store'
import { getNonStreamingCompletion } from '../lib'
const AUTOCOMPLETE_SYSTEM_PROMPT = `You're a code assistant. Your task is to help the user write code by suggesting the next edit for the user.
As an intelligent code assistant, your role is to analyze what the user has been doing and then to suggest the most likely next modification.
## Task
Your task is to rewrite the <EDITABLE_CODE> section of the code I send you to include an edit the user should make.
The <CURSOR> tag marks the position of the user's cursor.
Follow the following criteria.
### High-level Guidelines
- Consider the overall intent and direction of the changes
- Take into account what the user has been doing
- Maintain the code style and formatting conventions of the language used in the file
- Your edit suggestions **must** be small and self-contained. Example: if there are two statements that logically need to be added together, suggest them together instead of one by one.
### Constraints
- Preserve indentation and braces/parentheses/brackets balance.
- Prefer suggesting actual implementations over suggesting placeholders
- Dont explain the code, only return the complete <EDITABLE_CODE> section with your edits. DO NOT return any code after the <EDITABLE_CODE> tag.
- If there are no useful edits to make, return the the <EDITABLE_CODE> section unmodified, without the <CURSOR> tag.
- Never include the <CURSOR> tag in the response.
- Never remove line breaks inside the <EDITABLE_CODE> section.`
const AUTOCOMPLETE_USER_PROMPT = `
<CODE>
{prefix}<EDITABLE_CODE>
{modifiablePrefix}<CURSOR>{modifiableSuffix}
</EDITABLE_CODE>
{suffix}</CODE>
Return the EDITABLE_CODE section in the form \`\`\`{language}
<EDITABLE_CODE>
...complete editable code section with your modifications
</EDITABLE_CODE>
\`\`\``
function postProcessing(response: string) {
const code = response.match(/<EDITABLE_CODE>\n?(.*?)\n?<\/EDITABLE_CODE>/s)?.[1]
if (!code) {
throw new Error('No code found in response')
}
return code
}
export async function autocompleteRequest(
context: {
prefix: string
modifiablePrefix: string
modifiableSuffix: string
suffix: string
language: string
events: string[]
},
abortController: AbortController
) {
codeCompletionLoading.set(true)
const systemPrompt = AUTOCOMPLETE_SYSTEM_PROMPT
const userPrompt = AUTOCOMPLETE_USER_PROMPT.replace('{prefix}', context.prefix)
.replace('{modifiablePrefix}', context.modifiablePrefix)
.replace('{modifiableSuffix}', context.modifiableSuffix)
.replace('{suffix}', context.suffix)
.replace('{language}', context.language)
.replace('{events}', context.events.join('\n\n'))
const info = get(copilotInfo)
const providerModel = info.codeCompletionModel
if (!providerModel) {
throw new Error('No code completion model selected')
}
try {
const completion = await getNonStreamingCompletion(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
abortController,
{
forceModelProvider: providerModel
}
)
return postProcessing(completion)
} catch (err) {
if (!abortController.signal.aborted) {
console.log('Could not generate autocomplete', err.message)
}
} finally {
codeCompletionLoading.set(false)
}
}
@@ -0,0 +1,126 @@
import { editor as meditor } from 'monaco-editor'
/**
* Unused for now but might be useful for alternative completion diff
*/
export class DiffEditorWidget {
editor: any
domNode: HTMLElement
diffContainer: HTMLElement
diffEditor: meditor.IStandaloneDiffEditor
constructor(editor: meditor.IStandaloneCodeEditor, modified: string, lang: string) {
this.editor = editor
this.domNode = document.createElement('div')
this.domNode.style.backgroundColor = 'var(--vscode-editor-background)'
this.domNode.style.border = '1px solid #ccc'
this.domNode.style.zIndex = '1000' // Make sure it's above other elements
this.diffContainer = document.createElement('div')
this.diffContainer.style.width = '100%'
this.diffContainer.style.height = '100%'
this.diffContainer.style.padding = '0'
this.domNode.appendChild(this.diffContainer)
// Create a diff editor inside the widget
this.diffEditor = meditor.createDiffEditor(this.diffContainer, {
readOnly: true,
automaticLayout: true,
lineNumbers: 'off',
renderSideBySide: false,
minimap: {
enabled: false
},
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden'
},
scrollBeyondLastLine: false,
folding: false,
glyphMargin: false,
renderOverviewRuler: false,
overviewRulerLanes: 0,
renderIndicators: false,
lineDecorationsWidth: 5,
lightbulb: {
enabled: meditor.ShowLightbulbIconMode.Off
},
lineNumbersMinChars: 0,
renderMarginRevertIcon: false
})
const originalModel = meditor.createModel(editor.getValue() || '', lang)
const modifiedModel = meditor.createModel(modified, lang)
this.diffEditor.setModel({
original: originalModel,
modified: modifiedModel
})
function getMaxColumn(model: meditor.ITextModel) {
if (!model) return 0
let maxColumn = 0
const totalLines = model.getLineCount()
for (let line = 1; line <= totalLines; line++) {
maxColumn = Math.max(maxColumn, model.getLineMaxColumn(line))
}
return maxColumn
}
const maxOriginal = getMaxColumn(originalModel)
const maxModified = getMaxColumn(modifiedModel)
const max = Math.max(maxOriginal, maxModified)
const width = Math.min(max * 8, 600)
this.domNode.style.width = `${width}px`
this.diffEditor.onDidUpdateDiff(() => {
const originalLineCount = originalModel.getLineCount()
const changes = this.diffEditor.getLineChanges() || []
console.log('changes', changes)
let extraLines = 0
console.log('original line count', originalLineCount)
changes.forEach((change) => {
if (change.modifiedEndLineNumber) {
extraLines += change.modifiedEndLineNumber - change.modifiedStartLineNumber + 1
}
})
const lines = originalLineCount + extraLines
console.log('lines', lines)
this.domNode.style.height = `${lines * 20}px`
})
// console.log(changes)
// console.log('lineCount1', lineCount1)
// console.log('lineCount2', lineCount2)
}
layout() {
this.diffEditor.layout()
}
getId() {
return 'diffEditorWidget'
}
getDomNode() {
return this.domNode
}
getPosition() {
return {
position: {
lineNumber: 1,
column: 10000
},
preference: [meditor.ContentWidgetPositionPreference.EXACT]
}
}
}
@@ -0,0 +1,372 @@
<script lang="ts">
import { copilotSessionModel, dbSchemas, type DBSchema, type DBSchemas } from '$lib/stores'
import { writable, type Writable } from 'svelte/store'
import AIChatDisplay from './AIChatDisplay.svelte'
import {
chatRequest,
prepareSystemMessage,
prepareUserMessage,
type AIChatContext,
type ContextElement,
type DisplayMessage,
type SelectedContext
} from './core'
import { createEventDispatcher, onDestroy, setContext } from 'svelte'
import type { AIProviderModel, ScriptLang } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { openDB, type DBSchema as IDBSchema, type IDBPDatabase } from 'idb'
import { isInitialCode } from '$lib/script_helpers'
import { langToExt } from '$lib/editorUtils'
import { scriptLangToEditorLang } from '$lib/scripts'
export let lang: ScriptLang | 'bunnative'
export let code: string
export let error: string | undefined
export let args: Record<string, any>
export let path: string | undefined
$: contextCodePath =
(path?.split('/').pop() ?? 'script') + '.' + langToExt(scriptLangToEditorLang(lang))
let initializedWithInitCode: boolean | null = null
$: lang && (initializedWithInitCode = null)
function onCodeChange() {
if (initializedWithInitCode === null && code) {
if (isInitialCode(code)) {
initializedWithInitCode = true
} else {
initializedWithInitCode = false
selectedContext = [
{
type: 'code',
title: contextCodePath
}
]
}
} else if (initializedWithInitCode) {
// if the code was initial and was changed, add code context, then prevent it from being added again
selectedContext = [
{
type: 'code',
title: contextCodePath
}
]
initializedWithInitCode = false
}
}
$: code && onCodeChange()
let db: { schema: DBSchema; resource: string } | undefined = undefined
function updateSchema(
lang: ScriptLang | 'bunnative',
args: Record<string, any>,
dbSchemas: DBSchemas
) {
const schemaRes = lang === 'graphql' ? args.api : args.database
if (typeof schemaRes === 'string') {
const schemaPath = schemaRes.replace('$res:', '')
const schema = dbSchemas[schemaPath]
if (schema && schema.lang === lang) {
db = { schema, resource: schemaPath }
} else {
db = undefined
}
} else {
db = undefined
}
}
$: updateSchema(lang, args, $dbSchemas)
let selectedContext: SelectedContext[] = []
let availableContext: ContextElement[] = []
function updateAvailableContext(
contextCodePath: string,
code: string,
lang: ScriptLang | 'bunnative',
error: string | undefined,
db: { schema: DBSchema; resource: string } | undefined,
providerModel: AIProviderModel | undefined
) {
availableContext = [
{
type: 'code',
title: contextCodePath,
content: code,
lang
}
]
if (error) {
availableContext = [
...availableContext,
{
type: 'error',
title: 'error',
content: error
}
]
}
if (db && !providerModel?.model.endsWith('/thinking')) {
availableContext = [
...availableContext,
{
type: 'db',
title: db.resource,
schema: db.schema
}
]
}
}
$: updateAvailableContext(contextCodePath, code, lang, error, db, $copilotSessionModel)
let instructions = ''
let loading = writable(false)
let currentReply: Writable<string> = writable('')
const dispatch = createEventDispatcher<{
applyCode: { code: string }
}>()
setContext<AIChatContext>('AIChatContext', {
loading,
currentReply,
applyCode: (code: string) => {
dispatch('applyCode', { code })
}
})
let currentChatId: string = crypto.randomUUID()
let savedChats: Record<
string,
{
actualMessages: { role: 'user' | 'assistant' | 'system'; content: string }[]
displayMessages: DisplayMessage[]
title: string
id: string
lastModified: number
}
> = {}
$: pastChats = Object.values(savedChats)
.filter((c) => c.id !== currentChatId)
.sort((a, b) => b.lastModified - a.lastModified)
let messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [
prepareSystemMessage()
]
let displayMessages: DisplayMessage[] = []
let abortController: AbortController | undefined = undefined
function updateSelectedContextElements() {
const contextElements: ContextElement[] = []
for (const selected of selectedContext) {
const el = availableContext.find(
(c) => c.type === selected.type && c.title === selected.title
)
if (el) {
contextElements.push(el)
}
}
return contextElements
}
let selectedContextElements: ContextElement[] = []
async function sendRequest() {
if (!instructions.trim()) {
return
}
try {
loading.set(true)
aiChatDisplay?.enableAutomaticScroll()
abortController = new AbortController()
selectedContextElements = updateSelectedContextElements()
displayMessages = [
...displayMessages,
{
role: 'user',
content: instructions,
contextElements: selectedContextElements
}
]
const oldInstructions = instructions
instructions = ''
const userMessage = await prepareUserMessage(oldInstructions, lang, selectedContextElements)
messages.push({ role: 'user', content: userMessage })
await saveChat()
$currentReply = ''
await chatRequest(
messages,
abortController,
lang,
(
selectedContextElements.find((c) => c.type === 'db') as
| Extract<ContextElement, { type: 'db' }>
| undefined
)?.schema,
(token) => {
currentReply.update((prev) => prev + token)
}
)
messages.push({ role: 'assistant', content: $currentReply })
displayMessages = [
...displayMessages,
{
role: 'assistant',
content: $currentReply,
contextElements: selectedContextElements
}
]
currentReply.set('')
await saveChat()
} catch (err) {
console.error(err)
if (err instanceof Error) {
sendUserToast('Failed to send request: ' + err.message, true)
} else {
sendUserToast('Failed to send request', true)
}
} finally {
loading.set(false)
}
}
function cancel() {
currentReply.set('')
abortController?.abort()
}
async function saveChat() {
if (displayMessages.length > 0) {
const updatedChat = {
actualMessages: messages,
displayMessages: displayMessages,
title: displayMessages[0].content.slice(0, 50),
id: currentChatId,
lastModified: Date.now()
}
savedChats = {
...savedChats,
[updatedChat.id]: updatedChat
}
if (indexDB) {
await indexDB.put('chats', updatedChat)
}
}
}
async function saveAndClear() {
await saveChat()
currentChatId = crypto.randomUUID()
displayMessages = []
messages = [prepareSystemMessage()]
}
function deletePastChat(id: string) {
savedChats = Object.fromEntries(Object.entries(savedChats).filter(([key]) => key !== id))
indexDB?.delete('chats', id)
}
function loadPastChat(id: string) {
const chat = savedChats[id]
if (chat) {
messages = chat.actualMessages
displayMessages = chat.displayMessages
currentChatId = id
aiChatDisplay?.enableAutomaticScroll()
}
}
export function fix() {
instructions = 'Fix the error'
selectedContext = [
{
type: 'code',
title: contextCodePath
},
{
type: 'error',
title: 'error'
}
]
sendRequest()
}
interface ChatSchema extends IDBSchema {
chats: {
key: string
value: {
id: string
actualMessages: { role: 'user' | 'assistant' | 'system'; content: string }[]
displayMessages: DisplayMessage[]
title: string
lastModified: number
}
}
}
let indexDB: IDBPDatabase<ChatSchema> | undefined = undefined
async function initIndexDB() {
indexDB = await openDB<ChatSchema>('copilot-chat-history', 1, {
upgrade(indexDB) {
if (!indexDB.objectStoreNames.contains('chats')) {
indexDB.createObjectStore('chats', { keyPath: 'id' })
}
}
})
const chats = await indexDB.getAll('chats')
savedChats = chats.reduce((acc, chat) => {
acc[chat.id] = chat
return acc
}, {} as typeof savedChats)
}
initIndexDB()
onDestroy(() => {
cancel()
indexDB?.close()
})
let aiChatDisplay: AIChatDisplay | undefined = undefined
</script>
<AIChatDisplay
bind:this={aiChatDisplay}
{pastChats}
bind:selectedContext
{availableContext}
messages={$currentReply
? [
...displayMessages,
{
role: 'assistant',
content: $currentReply,
contextElements: selectedContextElements
}
]
: displayMessages}
bind:instructions
on:sendRequest={sendRequest}
on:cancel={cancel}
on:saveAndClear={saveAndClear}
on:deletePastChat={(e) => deletePastChat(e.detail.id)}
on:loadPastChat={(e) => loadPastChat(e.detail.id)}
>
<slot name="header-left" slot="header-left" />
<slot name="header-right" slot="header-right" />
</AIChatDisplay>
@@ -0,0 +1,283 @@
<script lang="ts">
import autosize from '$lib/autosize'
import { twMerge } from 'tailwind-merge'
import AssistantMessage from './AssistantMessage.svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { ChevronDown, HistoryIcon, Loader2, Plus, X } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import {
ContextIconMap,
type AIChatContext,
type DisplayMessage,
type ContextElement,
type SelectedContext
} from './core'
import {
COPILOT_SESSION_MODEL_SETTING_NAME,
COPILOT_SESSION_PROVIDER_SETTING_NAME,
copilotInfo,
copilotSessionModel
} from '$lib/stores'
import ContextElementBadge from './ContextElementBadge.svelte'
import { storeLocalSetting } from '$lib/utils'
export let pastChats: { id: string; title: string }[]
export let messages: DisplayMessage[]
export let instructions: string
export let selectedContext: SelectedContext[]
export let availableContext: ContextElement[]
const dispatch = createEventDispatcher<{
sendRequest: null
saveAndClear: null
deletePastChat: { id: string }
loadPastChat: { id: string }
}>()
const { loading, currentReply } = getContext<AIChatContext>('AIChatContext')
export function enableAutomaticScroll() {
automaticScroll = true
}
let automaticScroll = true
let scrollEl: HTMLDivElement
async function scrollDown() {
scrollEl?.scrollTo({
top: scrollEl.scrollHeight,
behavior: 'smooth'
})
}
let height = 0
$: automaticScroll && height && scrollDown()
$: providerModel = $copilotSessionModel ??
$copilotInfo.defaultModel ??
$copilotInfo.aiModels[0] ?? {
model: 'No model',
provider: 'No provider'
}
</script>
<div class="flex flex-col h-full">
<div
class="flex flex-row items-center justify-between gap-2 p-2 border-b border-gray-200 dark:border-gray-600"
>
<div class="flex flex-row items-center gap-2">
<slot name="header-left" />
<p class="text-sm font-semibold">Chat</p>
</div>
<div class="flex flex-row items-center gap-2">
<Popover>
<svelte:fragment slot="trigger">
<Button
on:click={() => {}}
title="History"
size="md"
btnClasses="!p-1"
startIcon={{ icon: HistoryIcon }}
iconOnly
variant="border"
color="light"
propagateEvent
/>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="p-1 overflow-y-auto max-h-[300px]">
{#if pastChats.length === 0}
<div class="text-center text-tertiary text-xs">No history</div>
{:else}
<div class="flex flex-col">
{#each pastChats as chat}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1"
on:click={() => {
dispatch('loadPastChat', { id: chat.id })
close()
}}
>
<div
class="text-xs font-medium w-48 text-ellipsis overflow-hidden whitespace-nowrap flex-1"
title={chat.title}
>
{chat.title}
</div>
<Button
iconOnly
size="xs2"
btnClasses="!p-1"
color="light"
variant="border"
startIcon={{ icon: X }}
on:click={() => {
dispatch('deletePastChat', { id: chat.id })
}}
/>
</button>
{/each}
</div>
{/if}
</div>
</svelte:fragment>
</Popover>
<Button
title="New chat"
on:click={() => {
dispatch('saveAndClear')
}}
size="md"
btnClasses="!p-1"
startIcon={{ icon: Plus }}
iconOnly
variant="border"
color="light"
/>
<slot name="header-right" />
</div>
</div>
{#if messages.length > 0}
<div
class="h-full overflow-y-scroll pt-2"
bind:this={scrollEl}
on:wheel={(e) => {
automaticScroll = false
}}
>
<div class="flex flex-col" bind:clientHeight={height}>
{#each messages as message}
{#if message.role === 'user' && message.contextElements}
<div class="flex flex-row gap-1 mb-1 overflow-scroll no-scrollbar px-2">
{#each message.contextElements as element}
<ContextElementBadge contextElement={element} />
{/each}
</div>
{/if}
<div
class={twMerge(
'text-sm py-1 mx-2',
message.role === 'user' &&
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg mb-2',
message.role === 'assistant' && 'px-[1px] mb-6'
)}
>
{#if message.role === 'assistant'}
<AssistantMessage {message} />
{:else}
{message.content}
{/if}
</div>
{/each}
{#if $loading && !$currentReply}
<div class="mb-6 py-1 px-2">
<Loader2 class="animate-spin" />
</div>
{/if}
</div>
</div>
{/if}
<div class:border-t={messages.length > 0}>
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 px-2 no-scrollbar">
<Popover>
<svelte:fragment slot="trigger">
<div
class="border rounded-md px-1 py-0.5 font-normal text-tertiary text-xs hover:bg-surface-hover"
>@</div
>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-1 text-tertiary text-xs p-1 min-w-24">
{#if availableContext.filter((c) => !selectedContext.find((sc) => sc.type === c.type)).length === 0}
<div class="text-center text-tertiary text-xs">No available context</div>
{:else}
{#each availableContext as element}
{#if !selectedContext.find((c) => c.type === element.type)}
<button
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal"
on:click={() => {
selectedContext = [
...selectedContext,
{
type: element.type,
title: element.title
}
]
close()
}}
>
<svelte:component this={ContextIconMap[element.type]} size={16} />
{element.title}
</button>
{/if}
{/each}
{/if}
</div>
</svelte:fragment>
</Popover>
{#each selectedContext as element}
{@const contextElement = availableContext.find((c) => c.type === element.type)}
{#if contextElement}
<ContextElementBadge
{contextElement}
deletable
on:delete={() => {
selectedContext = selectedContext.filter((c) => c.type !== element.type)
}}
/>
{/if}
{/each}
</div>
<div class="px-2 scroll-pb-2">
<textarea
on:keypress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
dispatch('sendRequest')
}
}}
bind:value={instructions}
use:autosize
rows={3}
placeholder={messages.length > 0 ? 'Ask followup' : 'Ask anything'}
class="resize-none"
/>
<div class="flex flex-row justify-end items-center gap-2 px-0.5">
<div class="min-w-0">
<Popover disablePopup={$copilotInfo.aiModels.length <= 1}>
<svelte:fragment slot="trigger">
<div class="text-tertiary text-xs flex flex-row items-center gap-0.5 font-normal">
{providerModel.model}
{#if $copilotInfo.aiModels.length > 1}
<ChevronDown size={16} />
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-1 p-1 min-w-24">
{#each $copilotInfo.aiModels.filter((m) => m.model !== providerModel.model) as providerModel}
<button
class="text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal"
on:click={() => {
$copilotSessionModel = providerModel
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, providerModel.model)
storeLocalSetting(
COPILOT_SESSION_PROVIDER_SETTING_NAME,
providerModel.provider
)
close()
}}
>
{providerModel.model}
</button>
{/each}
</div>
</svelte:fragment>
</Popover>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,29 @@
<script lang="ts">
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import type { DisplayMessage } from './core'
import CodeDisplay from './CodeDisplay.svelte'
import { setContext } from 'svelte'
export let message: DisplayMessage
setContext('AssistantMessageContext', {
message
})
</script>
<div
class="prose prose-sm dark:prose-invert w-full max-w-full leading-snug space-y-2 prose-ul:!pl-6"
>
<Markdown
md={message.content}
plugins={[
gfmPlugin(),
{
renderer: {
pre: CodeDisplay
}
}
]}
/>
</div>
@@ -0,0 +1,206 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { getAstNode, type HastNode } from 'svelte-exmarkdown'
import { editor as meditor } from 'monaco-editor'
import { getContext } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { initializeVscode } from '$lib/components/vscode'
import type { AIChatContext, ContextElement, DisplayMessage } from './core'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import {
csharp,
go,
graphql,
javascript,
php,
python,
rust,
shell,
sql,
typescript,
yaml
} from 'svelte-highlight/languages'
import { scriptLangToEditorLang } from '$lib/scripts'
const astNode = getAstNode()
const {
loading: loadingContext,
currentReply,
applyCode
} = getContext<AIChatContext>('AIChatContext')
const { message } = getContext<{ message: DisplayMessage }>('AssistantMessageContext')
$: codeContext = message.contextElements?.find((e) => e.type === 'code') as
| Extract<ContextElement, { type: 'code' }>
| undefined
function getSmartLang(lang: string) {
switch (lang) {
case 'python':
case 'python3':
return 'python'
case 'deno':
case 'nativets':
case 'bun':
case 'bunnative':
case 'typescript':
return 'typescript'
case 'go':
return 'go'
case 'shell':
case 'bash':
return 'shell'
case 'frontend':
case 'javascript':
return 'javascript'
case 'graphql':
return 'graphql'
case 'mysql':
case 'snowflake':
case 'bigquery':
case 'oracledb':
case 'powershell':
case 'postgresql':
case 'sql':
return 'sql'
case 'php':
return 'php'
case 'rust':
return 'rust'
case 'csharp':
return 'csharp'
case 'ansible':
case 'yaml':
return 'yaml'
default:
return 'typescript'
}
}
const SMART_LANG_TO_HIGHLIGHT_LANG = {
python: python,
typescript: typescript,
go: go,
shell: shell,
javascript: javascript,
graphql: graphql,
sql: sql,
php: php,
rust: rust,
csharp: csharp,
yaml: yaml
}
$: code = $astNode.children?.[0]?.children?.[0]?.value
$: language = ($astNode.children?.[0]?.properties?.class as string | undefined)?.split('-')[1]
let loading = true
function shouldStopLoading(astNode: HastNode, replying: boolean) {
if (!replying || $currentReply.length > (astNode.position?.end.offset ?? 0)) {
loading = false
}
}
$: shouldStopLoading($astNode, $loadingContext)
let diffEl: HTMLDivElement | undefined
let diffEditor: meditor.IStandaloneDiffEditor | undefined
async function setDiffEditor(diffEl: HTMLDivElement) {
if (!codeContext) {
return
}
await initializeVscode()
diffEditor = meditor.createDiffEditor(diffEl, {
automaticLayout: true,
renderSideBySide: false,
hideUnchangedRegions: {
enabled: true
},
originalEditable: false,
readOnly: true,
renderGutterMenu: false,
renderOverviewRuler: false,
scrollBeyondLastLine: false,
overviewRulerLanes: 0,
lineNumbersMinChars: 0,
lightbulb: {
enabled: meditor.ShowLightbulbIconMode.Off
},
scrollbar: {
alwaysConsumeMouseWheel: false
}
})
diffEditor.setModel({
original: meditor.createModel(codeContext.content, scriptLangToEditorLang(codeContext.lang)),
modified: meditor.createModel(code ?? '', language ? getSmartLang(language) : undefined)
})
const originalEditor = diffEditor.getOriginalEditor()
const modifiedEditor = diffEditor.getModifiedEditor()
originalEditor.onDidContentSizeChange((e) => {
diffEl.style.height = `${e.contentHeight}px`
})
modifiedEditor.onDidContentSizeChange((e) => {
diffEl.style.height = `${e.contentHeight}px`
})
updateModifiedModel(code ?? '')
}
function updateModifiedModel(code: string) {
const modified = diffEditor?.getModifiedEditor()
if (!modified) return
const modifiedModel = modified.getModel()
if (modifiedModel) {
modifiedModel.setValue(code ?? '')
}
}
$: updateModifiedModel(code ?? '')
$: diffEl &&
language &&
codeContext &&
getSmartLang(codeContext.lang) === getSmartLang(language) &&
setDiffEditor(diffEl)
</script>
<div class="flex flex-col gap-0.5 rounded-lg relative not-prose">
<div class="flex justify-end items-end">
<Button
color="dark"
size="xs2"
on:click={() => {
applyCode(code ?? '')
}}
>
Apply
</Button>
</div>
<div
class="relative w-full border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"
>
{#if (loading && !code) || !language}
<div class="flex flex-row gap-1 p-2 items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin" /> Generating code...
</div>
{:else if !loading && codeContext && getSmartLang(codeContext.lang) === getSmartLang(language)}
<div bind:this={diffEl} class="w-full h-full" />
{:else}
<HighlightCode
class="p-1"
code={code ?? ''}
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language)]}
language={undefined}
/>
{/if}
</div>
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import { Popover } from '$lib/components/meltComponents'
import { Loader2, X } from 'lucide-svelte'
import { ContextIconMap, type ContextElement } from './core'
import { Highlight } from 'svelte-highlight'
import { json } from 'svelte-highlight/languages'
import { twMerge } from 'tailwind-merge'
import {
formatGraphqlSchema,
formatSchema
} from '$lib/components/apps/components/display/dbtable/utils'
import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte'
import { createEventDispatcher } from 'svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
export let contextElement: ContextElement
export let deletable = false
const icon = ContextIconMap[contextElement.type]
let showDelete = false
const dispatch = createEventDispatcher<{
delete: void
}>()
</script>
<Popover disablePopup={contextElement.type === 'code' && deletable}>
<svelte:fragment slot="trigger">
<div
class={twMerge(
'border rounded-md px-1 py-0.5 flex flex-row items-center gap-1 text-tertiary text-xs cursor-default hover:bg-surface-hover',
contextElement.type === 'code' && deletable ? '' : 'hover:cursor-pointer'
)}
on:mouseenter={() => (showDelete = true)}
on:mouseleave={() => (showDelete = false)}
aria-label="Context element"
role="button"
tabindex={0}
>
<button on:click={() => dispatch('delete')} class:cursor-default={!deletable}>
{#if showDelete && deletable}
<X size={16} />
{:else}
<svelte:component this={icon} size={16} />
{/if}
</button>
{contextElement.title}
</div>
</svelte:fragment>
<svelte:fragment slot="content">
{#if contextElement.type === 'error'}
<div class="max-w-96 max-h-[300px] text-xs overflow-auto">
<Highlight language={json} code={contextElement.content} class="w-full p-2" />
</div>
{:else if contextElement.type === 'db'}
<div class="p-2 max-w-96 max-h-[300px] text-xs overflow-auto">
{#if contextElement.schema.lang === 'graphql'}
{#await import('$lib/components/GraphqlSchemaViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
code={formatGraphqlSchema(contextElement.schema.schema)}
class="h-full"
/>
{/await}
{:else}
<ObjectViewer json={formatSchema(contextElement.schema)} pureViewer collapseLevel={1} />
{/if}
</div>
{:else if contextElement.type === 'code'}
<div class="max-w-96 max-h-[300px] text-xs overflow-auto">
<HighlightCode
language={contextElement.lang}
code={contextElement.content}
class="w-full p-2 "
/>
</div>
{/if}
</svelte:fragment>
</Popover>
@@ -0,0 +1,19 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher<{
acceptAll: void
rejectAll: void
}>()
</script>
<div class="absolute flex flex-row bottom-10 left-1/2 z-10 -translate-x-1/2 bg-surface rounded-md">
<button
class="px-2 py-1 bg-[#a0e6a0] rounded-l text-sm font-semibold text-black"
on:click={() => dispatch('acceptAll')}>Accept all</button
>
<button
class="px-2 py-1 bg-[#e6a0a0] rounded-r text-sm font-semibold text-black"
on:click={() => dispatch('rejectAll')}>Reject all</button
>
</div>
@@ -0,0 +1,492 @@
import { ResourceService } from '$lib/gen/services.gen'
import type { ResourceType, ScriptLang } from '$lib/gen/types.gen'
import { capitalize, toCamel } from '$lib/utils'
import { get, type Writable } from 'svelte/store'
import { getCompletion } from '../lib'
import { compile, phpCompile, pythonCompile } from '../utils'
import { Code, Database, TriangleAlert } from 'lucide-svelte'
import type {
ChatCompletionChunk,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool
} from 'openai/resources/index.mjs'
import { workspaceStore, type DBSchema } from '$lib/stores'
import { scriptLangToEditorLang } from '$lib/scripts'
export function formatResourceTypes(
resourceTypes: ResourceType[],
lang: 'python3' | 'php' | 'bun' | 'deno' | 'nativets' | 'bunnative'
) {
if (lang === 'python3') {
const result = resourceTypes.map((resourceType) => {
return `class ${resourceType.name}(TypedDict):\n${pythonCompile(resourceType.schema as any)}`
})
return '\n**Make sure to rename conflicting imported modules**\n' + result.join('\n\n')
} else if (lang === 'php') {
const result = resourceTypes.map((resourceType) => {
return `class ${toCamel(capitalize(resourceType.name))} {\n${phpCompile(
resourceType.schema as any
)}\n}`
})
return '\n' + result.join('\n\n')
} else {
let resultStr = 'namespace RT {\n'
const result = resourceTypes
.filter(
(resourceType) => Boolean(resourceType.schema) && typeof resourceType.schema === 'object'
)
.map((resourceType) => {
return ` type ${toCamel(capitalize(resourceType.name))} = ${compile(
resourceType.schema as any
).replaceAll('\n', '\n ')}`
})
return resultStr + result.join('\n\n') + '\n}'
}
}
async function getResourceTypes(prompt: string, workspace: string) {
const resourceTypes = await ResourceService.queryResourceTypes({
workspace: workspace,
text: prompt,
limit: 5
})
return resourceTypes
}
const TS_RESOURCE_TYPE_SYSTEM = `On Windmill, credentials and configuration are stored in resources and passed as parameters to main.
If you need credentials, you should add a parameter to \`main\` with the corresponding resource type inside the \`RT\` namespace: for instance \`RT.Stripe\`.
You should only them if you need them to satisfy the user's instructions. Always use the RT namespace.
To query the RT namespace, you can use the \`search_resource_types\` function.`
const PYTHON_RESOURCE_TYPE_SYSTEM = `On Windmill, credentials and configuration are stored in resources and passed as parameters to main.
If you need credentials, you should add a parameter to \`main\` with the corresponding resource type.
To query the available resource types, you can use the \`search_resource_types\` function.
You need to **redefine** the type of the resources that are needed before the main function as TypedDict, but only include them if they are actually needed to achieve the function purpose.
The resource type name has to be exactly as specified (has to be IN LOWERCASE).
If an import conflicts with a resource type name, **you have to rename the imported object, not the type name**.
Make sure to import TypedDict from typing **if you're using it**`
const PHP_RESOURCE_TYPE_SYSTEM = `On Windmill, credentials and configuration are stored in resources and passed as parameters to main.
If you need credentials, you should add a parameter to \`main\` with the corresponding resource type
The available resource types are provided by the user under the \`RESOURCE_TYPE_CONTEXT\` key.
You need to **redefine** the type of the resources that are needed before the main function, but only include them if they are actually needed to achieve the function purpose.
Before defining each type, check if the class already exists using class_exists.
The resource type name has to be exactly as specified.`
export const SUPPORTED_CHAT_SCRIPT_LANGUAGES = [
'bunnative',
'nativets',
'bun',
'deno',
'python3',
'php',
'rust',
'go',
'bash',
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'graphql',
'powershell'
]
function getLangContext(lang: ScriptLang | 'bunnative') {
switch (lang) {
case 'bunnative':
case 'nativets':
return (
'The user is coding in TypeScript. On Windmill, it is expected that the script exports a single **async** function called `main`. You should use fetch (available globally, no need to import) and are not allowed to import any libraries.\n' +
TS_RESOURCE_TYPE_SYSTEM
)
case 'bun':
return (
'The user is coding in TypeScript (bun runtime). On Windmill, it is expected that the script exports a single **async** function called `main`. Do not call the main function. Libraries are installed automatically, do not show how to install them.\n' +
TS_RESOURCE_TYPE_SYSTEM
)
case 'deno':
return (
'The user is coding in TypeScript (deno runtime). On Windmill, it is expected that the script exports a single **async** function called `main`. Do not call the main function. Libraries are installed automatically, do not show how to install them.\n' +
TS_RESOURCE_TYPE_SYSTEM +
'\nYou can import deno libraries or you can import npm libraries like that: `import ... from "npm:{package}";`.'
)
case 'python3':
return (
'The user is coding in Python. On Windmill, it is expected the script contains at least one function called `main`. Do not call the main function. Libraries are installed automatically, do not show how to install them.' +
PYTHON_RESOURCE_TYPE_SYSTEM
)
case 'php':
return (
'The user is coding in PHP. On Windmill, it is expected the script contains at least one function called `main`. The script must start with <?php.' +
PHP_RESOURCE_TYPE_SYSTEM +
`\nIf you need to import libraries, you need to specify them as comments in the following manner before the main function:
\`\`\`
// require:
// mylibrary/mylibrary
// myotherlibrary/myotherlibrary@optionalversion
\`\`\`
Make sure to have one per line.
No need to require autoload, it is already done.`
)
case 'rust':
return `The user is coding in Rust. On Windmill, it is expected the script contains at least one function called \`main\` (without calling it) defined like this:
\`\`\`rust
use anyhow::anyhow;
use serde::Serialize;
#[derive(Serialize, Debug)]
struct ReturnType {
// ...
}
fn main(...) -> anyhow::Result<ReturnType>
\`\`\`
Arguments should be owned. Make sure the return type is serializable.
Packages must be made available with a partial cargo.toml by adding the following comment at the beginning of the script:
//! \`\`\`cargo
//! [dependencies]
//! anyhow = "1.0.86"
//! \`\`\'
Serde is already included, no need to add it again.
If you want to handle async functions (e.g., using tokio), you need to keep the main function sync and create the runtime inside.
`
case 'go':
return `The user is coding in Go. On Windmill, it is expected the script exports a single function called \`main\`. Its return type has to be (\`{return_type}\`, error). The file package has to be "inner".`
case 'bash':
return `The user is coding in Bash. Do not include "#!/bin/bash". On Windmill, arguments are always string and can only be obtained with "var1="$1"", "var2="$2"", etc..`
case 'postgresql':
return `The user is coding in PostgreSQL. On Windmill, arguments can be obtained directly in the statement with \`$1::{type}\`, \`$2::{type}\`, etc... Name the parameters (without specifying the type) by adding comments at the beginning of the script before the statement like that: \`-- $1 name1\` or \`-- $2 name = default\` (one per row)`
case 'mysql':
return 'The user is coding in MySQL. On Windmill, arguments can be obtained directly in the statement with ?. Name the parameters by adding comments before the statement like that: `-- ? name1 ({type})` or `-- ? name2 ({type}) = default` (one per row)'
case 'bigquery':
return 'The user is coding in BigQuery. On Windmill, arguments can be obtained by adding comments before the statement like that: `-- @name1 ({type})` or `-- @name2 ({type}) = default` (one per row). They can then be obtained directly in the statement with `@name1`, `@name2`, etc....'
case 'snowflake':
return 'The user is coding in Snowflake. On Windmill, arguments can be obtained directly in the statement with ?. Name the parameters by adding comments before the statement like that: `-- ? name1 ({type})` or `-- ? name2 ({type}) = default` (one per row)'
case 'mssql':
return 'The user is coding in Microsoft SQL Server. On Windmill, arguments can be obtained directly in the statement with @P1, @P2, etc.. Name the parameters by adding comments before the statement like that: `-- @P1 name1 ({type})` or `-- @P2 name2 ({type}) = default` (one per row)'
case 'graphql':
return 'The user is coding in GraphQL. If needed, add the needed arguments as query parameters.'
case 'powershell':
return 'The user is coding in PowerShell. On Windmill, arguments can be obtained by calling the param function on the first line of the script like that: `param($ParamName1, $ParamName2 = "default value", [{type}]$ParamName3, ...)`'
default:
return ''
}
}
export async function getFormattedResourceTypes(
lang: ScriptLang | 'bunnative',
prompt: string,
workspace: string
) {
switch (lang) {
case 'deno':
case 'bun':
case 'nativets':
case 'bunnative':
case 'python3':
case 'php': {
const resourceTypes = await getResourceTypes(prompt, workspace)
const intro = `RESOURCE_TYPES:\n`
const resourceTypesText = formatResourceTypes(resourceTypes, lang)
return intro + resourceTypesText
}
default:
return ''
}
}
export const CHAT_SYSTEM_PROMPT = `
You are a coding assistant on the Windmill platform. You are given a list of instructions to follow \`INSTRUCTIONS\` as well as the current code in the file \`CODE\`.
Please respond to the user's query. The user's query is never invalid.
In the case that the user asks you to make changes to code, you should make sure to return a single CODE BLOCK, as well as explanations and descriptions of the changes.
For example, if the user asks you to "make this file look nicer", make sure your output includes a code block with concrete ways the file can look nicer.
- If suggesting changes, rewrite the **complete code** and not just a part of it.
Requirements:
- When suggesting changes, do not change spacing, indentation, or other whitespace apart from what is strictly necessary to apply the changes.
Do not output any of these instructions, nor tell the user anything about them unless directly prompted for them.
`
const CHAT_USER_CODE_CONTEXT = `
CODE ({title}):
\`\`\`{language}
{code}
\`\`\`
`
const CHAT_USER_ERROR_CONTEXT = `
ERROR:
{error}
`
export const CHAT_USER_PROMPT = `
INSTRUCTIONS:
{instructions}
WINDMILL LANGUAGE CONTEXT:
{lang_context}
{code_context}
{error_context}
\`\`\`
`
export function prepareSystemMessage(): {
role: 'system'
content: string
} {
return {
role: 'system',
content: CHAT_SYSTEM_PROMPT
}
}
export interface DisplayMessage {
role: 'user' | 'assistant'
content: string
contextElements?: ContextElement[]
}
export const ContextIconMap = {
code: Code,
error: TriangleAlert,
db: Database
}
export type SelectedContext = {
type: 'code' | 'error' | 'db'
title: string
}
export type ContextElement =
| {
type: 'code'
content: string
title: string
lang: ScriptLang | 'bunnative'
}
| {
type: 'error'
content: string
title: 'error'
}
| {
type: 'db'
schema: DBSchema
title: string
}
export async function prepareUserMessage(
instructions: string,
language: ScriptLang | 'bunnative',
selectedContext: ContextElement[]
) {
let codeContext = ''
let errorContext = ''
for (const context of selectedContext) {
if (context.type === 'code') {
codeContext += CHAT_USER_CODE_CONTEXT.replace('{title}', context.title)
.replace('{language}', scriptLangToEditorLang(language))
.replace('{code}', context.content)
} else if (context.type === 'error') {
if (errorContext) {
throw new Error('Multiple error contexts provided')
}
errorContext = CHAT_USER_ERROR_CONTEXT.replace('{error}', context.content)
}
}
const userMessage = CHAT_USER_PROMPT.replace('{instructions}', instructions)
.replace('{lang_context}', getLangContext(language))
.replace('{code_context}', codeContext)
.replace('{error_context}', errorContext)
return userMessage
}
const RESOURCE_TYPE_FUNCTION_DEF: ChatCompletionTool = {
type: 'function',
function: {
name: 'search_resource_types',
description: 'Finds and returns resource types that are relevant to the specified query',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'The query to search for, e.g. specific integration (e.g. "stripe") or a specific feature (e.g. "send emails")'
}
},
required: ['query'],
additionalProperties: false
},
strict: true
}
}
const DB_SCHEMA_FUNCTION_DEF: ChatCompletionTool = {
type: 'function',
function: {
name: 'get_db_schema',
description: 'Gets the schema of the database in context'
}
}
export const MAX_SCHEMA_LENGTH = 100000 * 3.5
async function formatDBSchema(dbSchema: DBSchema) {
let { stringified } = dbSchema
if (dbSchema.lang === 'graphql') {
if (stringified.length > MAX_SCHEMA_LENGTH) {
stringified = stringified.slice(0, MAX_SCHEMA_LENGTH) + '...'
}
return 'GRAPHQL SCHEMA:\n' + stringified
} else {
if (stringified.length > MAX_SCHEMA_LENGTH) {
stringified = stringified.slice(0, MAX_SCHEMA_LENGTH) + '...'
}
return (
'DATABASE SCHEMA (each column is in the format [name, type, required, default?]):\n' +
stringified
)
}
}
async function callTool(
functionName: string,
args: any,
lang: ScriptLang | 'bunnative',
dbSchema: DBSchema | undefined,
workspace: string
) {
switch (functionName) {
case 'search_resource_types':
const formattedResourceTypes = await getFormattedResourceTypes(lang, args.query, workspace)
return formattedResourceTypes
case 'get_db_schema':
if (!dbSchema) {
throw new Error('No database schema provided')
}
const stringSchema = await formatDBSchema(dbSchema)
return stringSchema
default:
throw new Error(`Unknown tool call: ${functionName}`)
}
}
export async function chatRequest(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
lang: ScriptLang | 'bunnative',
dbSchema: DBSchema | undefined,
onNewToken: (token: string) => void
) {
const toolDefs: ChatCompletionTool[] = []
if (
lang === 'python3' ||
lang === 'php' ||
lang === 'bun' ||
lang === 'deno' ||
lang === 'nativets' ||
lang === 'bunnative'
) {
toolDefs.push(RESOURCE_TYPE_FUNCTION_DEF)
}
if (dbSchema) {
toolDefs.push(DB_SCHEMA_FUNCTION_DEF)
}
try {
let completion: any = null
while (true) {
completion = await getCompletion(messages, abortController, toolDefs)
if (completion) {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
for await (const chunk of completion) {
if (!('choices' in chunk)) {
continue
}
const c = chunk as ChatCompletionChunk
const delta = c.choices[0].delta.content
if (delta) {
onNewToken(delta)
}
const toolCalls = c.choices[0].delta.tool_calls || []
for (const toolCall of toolCalls) {
const { index } = toolCall
const finalToolCall = finalToolCalls[index]
if (!finalToolCall) {
finalToolCalls[index] = toolCall
} else {
if (toolCall.function?.arguments) {
if (!finalToolCall.function) {
finalToolCall.function = toolCall.function
} else {
finalToolCall.function.arguments =
(finalToolCall.function.arguments ?? '') + toolCall.function.arguments
}
}
}
}
}
const toolCalls = Object.values(finalToolCalls).filter(
(toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined
) as ChatCompletionMessageToolCall[]
if (toolCalls.length > 0) {
messages.push({
role: 'assistant',
tool_calls: toolCalls
})
for (const toolCall of toolCalls) {
try {
const args = JSON.parse(toolCall.function.arguments)
const result = await callTool(
toolCall.function.name,
args,
lang,
dbSchema,
get(workspaceStore) ?? ''
)
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result
})
} catch (err) {
console.error(err)
throw new Error('Error while calling tool')
}
}
} else {
break
}
}
}
return completion
} catch (err) {
if (!abortController.signal.aborted) {
throw err
}
}
}
export interface AIChatContext {
loading: Writable<boolean>
currentReply: Writable<string>
applyCode: (code: string) => void
}
@@ -0,0 +1,221 @@
import { diffLines } from 'diff'
import { KeyCode, type IDisposable, type editor as meditor } from 'monaco-editor'
import {
applyChange,
displayVisualChanges,
getLines,
setGlobalCSS,
type VisualChange
} from '../shared'
import { writable, type Writable } from 'svelte/store'
type ExcludeVariant<T, K extends keyof T, V> = T extends Record<K, V> ? never : T
type VisualChangeWithDiffIndex = ExcludeVariant<VisualChange, 'type', 'added_inline'> & {
diffIndex: number
}
export class AIChatEditorHandler {
editor: meditor.IStandaloneCodeEditor
viewZoneIds: string[] = []
decorationsCollections: meditor.IEditorDecorationsCollection[] = []
readOnlyDisposable: IDisposable | undefined = undefined
reviewingChanges: Writable<boolean> = writable(false)
groupChanges: { changes: VisualChangeWithDiffIndex[]; groupIndex: number }[] = []
constructor(editor: meditor.IStandaloneCodeEditor) {
this.editor = editor
}
clear() {
this.groupChanges = []
for (const collection of this.decorationsCollections) {
collection.clear()
}
this.editor.changeViewZones((acc) => {
for (const id of this.viewZoneIds) {
acc.removeZone(id)
}
this.viewZoneIds = []
})
setGlobalCSS('editor-windmill-chat-style', '')
}
preventWriting() {
if (this.readOnlyDisposable) {
this.readOnlyDisposable.dispose()
}
this.readOnlyDisposable = this.editor.onKeyDown((e) => {
if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyZ) {
// allow undo/redo
return
}
e.preventDefault()
e.stopPropagation()
})
this.editor.updateOptions({
scrollBeyondLastLine: true
})
}
allowWriting() {
if (this.readOnlyDisposable) {
this.readOnlyDisposable.dispose()
}
}
async finish() {
this.clear()
this.allowWriting()
this.reviewingChanges.set(false)
this.editor.updateOptions({
scrollBeyondLastLine: false
})
}
async acceptAll() {
this.groupChanges.reverse()
for (const group of this.groupChanges) {
this.applyGroup(group)
}
this.finish()
}
async rejectAll() {
this.finish()
}
applyGroup(group: { changes: VisualChangeWithDiffIndex[]; groupIndex: number }) {
// maximum of 2 changes per group with the deletion first
if (group.changes.length > 2) {
throw new Error('Invalid group')
} else if (group.changes.length === 2) {
const deletedChange = group.changes[0]
const addedChange = group.changes[1]
if (deletedChange.type === 'deleted' && addedChange.type === 'added_block') {
applyChange(this.editor, deletedChange)
addedChange.position.afterLineNumber = deletedChange.range.startLine - 1
applyChange(this.editor, addedChange)
} else {
throw new Error('Invalid group')
}
} else if (group.changes.length === 1) {
applyChange(this.editor, group.changes[0])
}
}
async reviewAndApply(newCode: string) {
this.preventWriting()
this.reviewingChanges.set(true)
const currentCode = this.editor.getValue()
const changedLines = diffLines(currentCode, newCode)
this.groupChanges = []
let visualChanges: VisualChangeWithDiffIndex[] = []
let lineNumber = 1
for (const [idx, change] of changedLines.entries()) {
const nbOfNewLines = change.count || 1
if (idx > 0 && changedLines[idx - 1].removed && !change.added) {
this.groupChanges.push({ changes: visualChanges, groupIndex: this.groupChanges.length })
visualChanges = []
}
if (change.added) {
const lines = getLines(change.value)
visualChanges.push({
type: 'added_block',
position: {
afterLineNumber: lineNumber - 1
},
value: lines.join('\n'),
options: {
greenHighlight: true
},
diffIndex: idx
})
this.groupChanges.push({ changes: visualChanges, groupIndex: this.groupChanges.length })
visualChanges = []
} else if (change.removed) {
visualChanges = []
visualChanges.push({
type: 'deleted',
range: {
startLine: lineNumber,
startColumn: 1,
endLine: lineNumber + (nbOfNewLines - 1),
endColumn: 10000
},
options: {
isWholeLine: true
},
diffIndex: idx
})
lineNumber += nbOfNewLines
} else {
lineNumber += nbOfNewLines
}
}
if (visualChanges.length > 0) {
this.groupChanges.push({ changes: visualChanges, groupIndex: this.groupChanges.length })
}
if (this.groupChanges.length === 0) {
this.finish()
return
}
let indicesOfRejectedLineChanges: number[] = []
for (const [groupIndex, group] of this.groupChanges.entries()) {
let collection: meditor.IEditorDecorationsCollection | undefined = undefined
let ids: string[] = []
const acceptFn = () => {
this.applyGroup(group)
this.clear()
let newCodeWithRejects = ''
for (const [idx, change] of changedLines.entries()) {
if (!change.added && !change.removed) {
newCodeWithRejects += change.value
} else if (change.added && !indicesOfRejectedLineChanges.includes(idx)) {
newCodeWithRejects += change.value
} else if (change.removed && indicesOfRejectedLineChanges.includes(idx)) {
newCodeWithRejects += change.value
}
}
this.reviewAndApply(newCodeWithRejects)
}
const rejectFn = () => {
indicesOfRejectedLineChanges.push(...group.changes.map((c) => c.diffIndex))
collection?.clear()
this.editor.changeViewZones((acc) => {
for (const id of ids) {
acc.removeZone(id)
}
})
this.groupChanges = this.groupChanges.filter((g) => g.groupIndex !== groupIndex)
if (this.groupChanges.length === 0) {
this.finish()
}
}
const changes = group.changes.map((c, i) => {
if (i === group.changes.length - 1) {
return {
...c,
options: { ...(c.options ?? {}), review: { acceptFn, rejectFn } }
}
} else {
return c
}
})
;({ collection, ids } = await displayVisualChanges(
'editor-windmill-chat-style',
this.editor,
changes
))
this.decorationsCollections.push(collection)
this.viewZoneIds.push(...ids)
}
}
}
@@ -1,110 +0,0 @@
import type { AIProvider } from '$lib/gen'
import { codeCompletionLoading } from '$lib/stores'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/index.mjs'
import { getNonStreamingCompletion } from './lib'
const systemPrompt = `You are a code completion assistant, return the code that should go instead of the <completion_tokens>.
- Only return the completion tokens. Do not include the surrounding code.
- Wrap the completion tokens in a code block (\`\`\`{language}\n<completion_tokens>\n\`\`\`).
- Maintain correct indentation based on the context. Take into account whether there are whitespaces or tabs before the completion tokens.
- You might need to add additional line breaks at the beginning or end of the completion tokens to make the code syntactically correct.
- Pay attention to not include tokens that are already present in the code, particularly after the completion like parenteses, brackets, etc.
- Return None with no code block if you think the code is already complete.
Examples:
User:
\`\`\`typescript
function greet() {
<completion_tokens>
}
\`\`\`
Assistant:
\`\`\`typescript
console.log('Hello, world!')
\`\`\`
User:
\`\`\`python
def main(name: str):
// log the name <completion_tokens>
\`\`\`
Assistant:
\`\`\`python
\n print(name)
\`\`\`
User:
\`\`\`typescript
function multiplyNumbers(<completion_tokens>)
\`\`\`
Assistant:
\`\`\`typescript
number1: number, number2: number
\`\`\`
User:
\`\`\`python
def greet():
<completion_tokens>
\`\`\`
Assistant:
\`\`\`python
print("Hello World!")
\`\`\`
User:
\`\`\`typescript
function multiplyNumbers(number1: number, number2: number) {<completion_tokens>}
\`\`\`
Assistant:
\`\`\`typescript
\n return number1 * number2\n
\`\`\`
`
const prompt = `\`\`\`{language}
{before}<completion_tokens>{after}
\`\`\`
`
export async function editorCodeCompletion(
before: string,
after: string,
lang: string,
abortController: AbortController,
aiProvider: AIProvider
) {
codeCompletionLoading.set(true)
const messages: ChatCompletionMessageParam[] = [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: prompt
.replace('{language}', lang)
.replace('{before}', before)
.replace('{after}', after)
}
]
try {
const result = await getNonStreamingCompletion(messages, abortController, aiProvider)
const match = result.match(/```[a-zA-Z]+\n([\s\S]*?)\n```/)
let completion = match?.[1] || ''
return completion
} catch (err) {
if (err.message !== 'Request was aborted.') {
console.log(err)
}
} finally {
codeCompletionLoading.set(false)
}
}
+4 -9
View File
@@ -1,6 +1,5 @@
import {
ScriptService,
type AIProvider,
type FlowModule,
type InputTransform,
type PathScript,
@@ -254,8 +253,7 @@ export async function stepCopilot(
})
| undefined,
isFirstInLoop: boolean,
abortController: AbortController,
aiProvider: AIProvider
abortController: AbortController
) {
if (module.source !== 'custom') {
throw new Error('Not a custom module')
@@ -310,8 +308,7 @@ export async function stepCopilot(
}
],
deltaCodeStore,
abortController,
aiProvider
abortController
)
return code
}
@@ -323,8 +320,7 @@ export async function glueCopilot(
value: RawScript | PathScript
},
isFirstInLoop: boolean,
abortController: AbortController,
aiProvider: AIProvider
abortController: AbortController
) {
const { prevCode, prevLang } = await getPreviousStepContent(pastModule, workspace)
@@ -359,8 +355,7 @@ export async function glueCopilot(
)
}
],
abortController,
aiProvider
abortController
)
const matches = response.matchAll(/([a-zA-Z_0-9.]+): (.+)/g)
+126 -366
View File
@@ -1,4 +1,4 @@
import type { AIProvider } from '$lib/gen'
import type { AIProvider, AIProviderModel } from '$lib/gen'
import {
copilotInfo,
copilotSessionModel,
@@ -6,11 +6,11 @@ import {
type GraphqlSchema,
type SQLSchema
} from '$lib/stores'
import { Anthropic } from '@anthropic-ai/sdk'
import { Mistral } from '@mistralai/mistralai'
import { buildClientSchema, printSchema } from 'graphql'
import { OpenAI } from 'openai'
import type {
ChatCompletionCreateParams,
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming,
ChatCompletionMessageParam
} from 'openai/resources/index.mjs'
@@ -19,17 +19,6 @@ import { OpenAPI, ResourceService, type Script } from '../../gen'
import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts'
import { formatResourceTypes } from './utils'
import type { MessageCreateParams, MessageParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import type {
AssistantMessage,
CompletionEvent,
ContentChunk,
SystemMessage,
ToolMessage,
UserMessage
} from '@mistralai/mistralai/models/components'
import type { ChatCompletionRequest } from '@mistralai/mistralai/models/components/chatcompletionrequest'
export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts))
// need at least one model for each provider except customai
@@ -49,17 +38,7 @@ export const AI_DEFAULT_MODELS: Record<AIProvider, string[]> = {
customai: []
}
export const OPENAI_COMPATIBLE_BASE_URLS = {
groq: 'https://api.groq.com/openai/v1',
openrouter: 'https://openrouter.ai/api/v1',
deepseek: 'https://api.deepseek.com/v1',
googleai: 'https://generativelanguage.googleapis.com/v1beta/openai'
} as const
function prepareOpenaiCompatibleMessages(
aiProvider: AIProvider,
messages: ChatCompletionMessageParam[]
) {
function prepareMessages(aiProvider: AIProvider, messages: ChatCompletionMessageParam[]) {
switch (aiProvider) {
case 'googleai':
// system messages are not supported by gemini
@@ -84,16 +63,18 @@ function prepareOpenaiCompatibleMessages(
}
}
const DEFAULT_COMPLETION_CONFIG: ChatCompletionCreateParamsStreaming = {
const DEFAULT_COMPLETION_CONFIG: ChatCompletionCreateParams = {
model: '',
max_tokens: 8192, //TODO: make this dynamic
temperature: 0,
seed: 42,
stream: true,
messages: []
}
export const OPENAI_COMPATIBLE_COMPLETION_CONFIG = {
export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCreateParams> = {
openai: {
...DEFAULT_COMPLETION_CONFIG,
max_tokens: 16384
},
groq: DEFAULT_COMPLETION_CONFIG,
openrouter: DEFAULT_COMPLETION_CONFIG,
deepseek: DEFAULT_COMPLETION_CONFIG,
@@ -101,18 +82,20 @@ export const OPENAI_COMPATIBLE_COMPLETION_CONFIG = {
googleai: {
...DEFAULT_COMPLETION_CONFIG,
seed: undefined // not supported by gemini
} as ChatCompletionCreateParamsStreaming
} as ChatCompletionCreateParams,
mistral: {
...DEFAULT_COMPLETION_CONFIG,
seed: undefined,
max_tokens: 32000
},
anthropic: DEFAULT_COMPLETION_CONFIG
} as const
class WorkspacedAIClients {
private openaiClient: OpenAI | undefined
private anthropicClient: Anthropic | undefined
private mistralClient: Mistral | undefined
init(workspace: string) {
this.initOpenai(workspace)
this.initAnthropic(workspace)
this.initMistral(workspace)
}
private getBaseURL(workspace: string) {
@@ -131,124 +114,16 @@ class WorkspacedAIClients {
})
}
private initAnthropic(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.anthropicClient = new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
}
private initMistral(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.mistralClient = new Mistral({
serverURL: baseURL
})
}
getOpenaiClient() {
if (!this.openaiClient) {
throw new Error('OpenAI not initialized')
}
return this.openaiClient
}
getAnthropicClient() {
if (!this.anthropicClient) {
throw new Error('Anthropic not initialized')
}
return this.anthropicClient
}
getMistralClient() {
if (!this.mistralClient) {
throw new Error('Mistral not initialized')
}
return this.mistralClient
}
}
export const workspaceAIClients = new WorkspacedAIClients()
namespace MistralAI {
export const mistralConfig: ChatCompletionRequest = {
temperature: 0,
model: null,
maxTokens: 32000,
messages: []
}
export type MistralParamsMessage =
| (SystemMessage & { role: 'system' })
| (UserMessage & { role: 'user' })
| (AssistantMessage & { role: 'assistant' })
| (ToolMessage & { role: 'tool' })
export function retrieveTextValue(chunks: string | ContentChunk[] | null | undefined): string {
let response = ''
if (Array.isArray(chunks)) {
for (const chunk of chunks) {
if (chunk.type === 'text') {
response += chunk.text
}
}
return response
}
return chunks as string
}
}
export namespace AnthropicAI {
export const config: MessageCreateParams = {
max_tokens: 8192,
model: '',
messages: []
}
export function getSystemPromptAndArrayMessages(
messages: ChatCompletionMessageParam[]
): [string, MessageParam[]] {
let system: string | undefined = undefined
if (messages[0].role == 'system') {
system = messages[0].content as string
messages.shift()
}
const anthropicMessages: MessageParam[] = messages.map((message) => {
return {
role: message.role == 'user' ? 'user' : 'assistant',
content: message.content as string
}
})
return [system as string, anthropicMessages ?? []]
}
export function retrieveTextValue(part: Anthropic.Messages.RawMessageStreamEvent) {
let response = ''
if (part.type == 'content_block_delta') {
if (part.delta.type == 'text_delta') {
response = part.delta.text
}
}
return response
}
}
namespace OpenAi {
export const openaiConfig: ChatCompletionCreateParamsStreaming = {
temperature: 0,
max_tokens: 16384,
model: '',
seed: 42,
stream: true,
messages: []
}
export function retrieveTextValue(part: OpenAI.Chat.Completions.ChatCompletionChunk) {
return part.choices[0]?.delta?.content || ''
}
}
export async function testKey({
apiKey,
resourcePath,
@@ -273,14 +148,14 @@ export async function testKey({
throw new Error('Missing a model to test')
}
await getNonStreamingCompletion(
messages,
abortController,
aiProvider,
await getNonStreamingCompletion(messages, abortController, {
apiKey,
resourcePath,
modelToTest
)
forceModelProvider: {
model: modelToTest,
provider: aiProvider
}
})
}
interface BaseOptions {
@@ -459,249 +334,136 @@ const PROMPTS_CONFIGS = {
gen: GEN_CONFIG
}
function getProviderAndCompletionConfig<K extends boolean>({
messages,
stream,
tools,
forceModelProvider
}: {
messages: ChatCompletionMessageParam[]
stream: K
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
forceModelProvider?: AIProviderModel
}): {
provider: AIProvider
config: K extends true
? ChatCompletionCreateParamsStreaming
: ChatCompletionCreateParamsNonStreaming
} {
let info = get(copilotInfo)
const modelProvider =
forceModelProvider ?? get(copilotSessionModel) ?? info.defaultModel ?? info.aiModels[0]
if (!modelProvider) {
throw new Error('No model selected')
}
const providerConfig = PROVIDER_COMPLETION_CONFIG_MAP[modelProvider.provider]
const processedMessages = prepareMessages(modelProvider.provider, messages)
return {
provider: modelProvider.provider,
config: {
...providerConfig,
...(modelProvider.model.endsWith('/thinking')
? {
thinking: {
type: 'enabled',
budget_tokens: 1024
},
model: modelProvider.model.slice(0, -9)
}
: {
model: modelProvider.model,
temperature: 0,
tools
}),
messages: processedMessages,
stream
} as any
}
}
export async function getNonStreamingCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
aiProvider: AIProvider,
apiKey?: string, // testing API KEY directly from the frontend
resourcePath?: string, // testing resource path passed as a header to the backend proxy
forceModel?: string
testOptions?: {
apiKey?: string // testing API KEY using the global ai proxy
resourcePath?: string // testing resource path passed as a header to the backend proxy
forceModelProvider: AIProviderModel
}
) {
let response: string | undefined = ''
let model = forceModel
if (!model) {
model = get(copilotSessionModel)
let info = get(copilotInfo)
const { ai_models: aiModels } = info
if (!model || !aiModels.includes(model)) {
console.warn('Invalid model, using default model:', aiModels[0])
model = aiModels[0]
}
}
if (!model) {
throw new Error('No model found')
}
const { provider, config } = getProviderAndCompletionConfig({
messages,
stream: false,
forceModelProvider: testOptions?.forceModelProvider
})
const fetchOptions: {
signal: AbortSignal
headers?: Record<string, string>
headers: Record<string, string>
} = {
signal: abortController.signal
signal: abortController.signal,
headers: {
'X-Provider': provider
}
}
if (resourcePath) {
if (testOptions?.resourcePath) {
fetchOptions.headers = {
'X-Resource-Path': resourcePath
...fetchOptions.headers,
'X-Resource-Path': testOptions.resourcePath
}
}
switch (aiProvider) {
case 'anthropic': {
const anthropicClient = apiKey
? new Anthropic({
apiKey,
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getAnthropicClient()
const [system, anthropicMessages] = AnthropicAI.getSystemPromptAndArrayMessages(messages)
const message = await anthropicClient.messages.create(
{
...AnthropicAI.config,
system,
...(model.endsWith('/thinking')
? {
thinking: {
type: 'enabled',
budget_tokens: 1024
},
model: model.slice(0, -9)
}
: {
model,
temperature: 0
}),
messages: anthropicMessages,
stream: false
},
fetchOptions
)
response = message.content[0].type === 'text' ? message.content[0].text : ''
break
} else if (testOptions?.apiKey) {
if (provider === 'customai') {
throw new Error('Cannot test API key for Custom AI, only resource path is supported')
}
case 'mistral': {
const mistralClient = apiKey
? new Mistral({
apiKey
})
: workspaceAIClients.getMistralClient()
const message = await mistralClient.chat.complete(
{
...MistralAI.mistralConfig,
model,
stream: false,
messages: messages as MistralAI.MistralParamsMessage[]
},
{
fetchOptions: fetchOptions
}
)
response = MistralAI.retrieveTextValue(message.choices && message.choices[0].message.content)
break
}
default: {
if (aiProvider === 'customai' && apiKey) {
throw new Error('Cannot test API key for Custom AI, only resource path is supported')
}
const baseURL = OPENAI_COMPATIBLE_BASE_URLS[aiProvider]
if (apiKey && aiProvider !== 'openai' && !baseURL) {
throw new Error('No base URL for this provider: ' + aiProvider)
}
const openaiClient = apiKey
? new OpenAI({
apiKey,
baseURL,
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getOpenaiClient()
const config =
aiProvider === 'openai'
? OpenAi.openaiConfig
: OPENAI_COMPATIBLE_COMPLETION_CONFIG[aiProvider]
if (!config) {
throw new Error('No config for this provider: ' + aiProvider)
}
const processedMessages = prepareOpenaiCompatibleMessages(aiProvider, messages)
const completion = await openaiClient.chat.completions.create(
{
...config,
messages: processedMessages,
model,
stream: false
},
fetchOptions
)
response = completion.choices[0]?.message.content || ''
fetchOptions.headers = {
...fetchOptions.headers,
'X-API-Key': testOptions.apiKey
}
}
const openaiClient = testOptions?.apiKey
? new OpenAI({
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getOpenaiClient()
const completion = await openaiClient.chat.completions.create(config, fetchOptions)
response = completion.choices[0]?.message.content || ''
return response
}
export async function getCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
aiProvider: AIProvider
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
) {
let model = get(copilotSessionModel)
let info = get(copilotInfo)
const { ai_models: aiModels } = info
if (!model || !aiModels.includes(model)) {
console.warn('Invalid model, using default model:', aiModels[0])
model = aiModels[0]
}
if (!model) {
throw new Error('No model found')
}
switch (aiProvider) {
case 'anthropic': {
const anthropicClient = workspaceAIClients.getAnthropicClient()
const [system, anthropicMessages] = AnthropicAI.getSystemPromptAndArrayMessages(messages)
const completion = await anthropicClient.messages.create(
{
...AnthropicAI.config,
...(model.endsWith('/thinking')
? {
thinking: {
type: 'enabled',
budget_tokens: 1024
},
model: model.slice(0, -9)
}
: {
model,
temperature: 0
}),
system,
messages: anthropicMessages,
stream: true
},
{ signal: abortController.signal }
)
return completion
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools })
const openaiClient = workspaceAIClients.getOpenaiClient()
const completion = await openaiClient.chat.completions.create(config, {
signal: abortController.signal,
headers: {
'X-Provider': provider
}
case 'mistral': {
const mistralClient = workspaceAIClients.getMistralClient()
const message = await mistralClient.chat.stream(
{
...MistralAI.mistralConfig,
model,
messages: messages as MistralAI.MistralParamsMessage[]
},
{
fetchOptions: {
signal: abortController.signal
}
}
)
return message
}
default: {
const openaiClient = workspaceAIClients.getOpenaiClient()
const config: ChatCompletionCreateParamsStreaming =
aiProvider === 'openai'
? OpenAi.openaiConfig
: OPENAI_COMPATIBLE_COMPLETION_CONFIG[aiProvider]
if (!config) {
throw new Error('No config for this provider: ' + aiProvider)
}
const processedMessages = prepareOpenaiCompatibleMessages(aiProvider, messages)
const completion = await openaiClient.chat.completions.create(
{
...config,
model,
messages: processedMessages
},
{
signal: abortController.signal
}
)
return completion
}
}
})
return completion
}
export function getResponseFromEvent(
part:
| Anthropic.Messages.RawMessageStreamEvent
| OpenAI.Chat.Completions.ChatCompletionChunk
| CompletionEvent,
aiProvider: AIProvider
): string {
switch (aiProvider) {
case 'anthropic': {
const messages = part as Anthropic.Messages.RawMessageStreamEvent
return AnthropicAI.retrieveTextValue(messages)
}
case 'mistral': {
const messages = part as CompletionEvent
return MistralAI.retrieveTextValue(messages.data.choices[0].delta.content)
}
default:
const messages = part as OpenAI.Chat.Completions.ChatCompletionChunk
return OpenAi.retrieveTextValue(messages)
}
export function getResponseFromEvent(part: OpenAI.Chat.Completions.ChatCompletionChunk): string {
return part.choices[0]?.delta?.content || ''
}
export async function copilot(
scriptOptions: CopilotOptions,
generatedCode: Writable<string>,
abortController: AbortController,
aiProvider: AIProvider,
generatedExplanation?: Writable<string>
) {
const { prompt, systemPrompt } = await getPrompts(scriptOptions)
@@ -717,14 +479,13 @@ export async function copilot(
content: prompt
}
],
abortController,
aiProvider
abortController
)
let response = ''
let code = ''
for await (const part of completion) {
response += getResponseFromEvent(part, aiProvider)
response += getResponseFromEvent(part)
let match = response.match(/```[a-zA-Z]+\n([\s\S]*?)\n```/)
if (match) {
@@ -790,16 +551,15 @@ function getStringEndDelta(prev: string, now: string) {
export async function deltaCodeCompletion(
messages: ChatCompletionMessageParam[],
generatedCodeDelta: Writable<string>,
abortController: AbortController,
aiProvider: AIProvider
abortController: AbortController
) {
const completion = await getCompletion(messages, abortController, aiProvider)
const completion = await getCompletion(messages, abortController)
let response = ''
let code = ''
let delta = ''
for await (const part of completion) {
response += getResponseFromEvent(part, aiProvider)
response += getResponseFromEvent(part)
let match = response.match(/```[a-zA-Z]+\n([\s\S]*?)\n```/)
if (match) {
@@ -0,0 +1,295 @@
import { type editor as meditor } from 'monaco-editor'
export type VisualChange =
| {
type: 'added_inline'
position: {
line: number
column: number
}
value: string
options?: {
greenHighlight?: boolean
}
}
| {
type: 'added_block'
position: {
afterLineNumber: number
}
value: string
options?: {
greenHighlight?: boolean
review?: {
acceptFn: () => void
rejectFn: () => void
}
extraChanges?: VisualChange[]
}
}
| {
type: 'deleted'
range: {
startLine: number
startColumn: number
endLine: number
endColumn: number
}
options?: {
isWholeLine?: boolean
review?: {
acceptFn: () => void
rejectFn: () => void
}
}
}
function applyMonacoStyles(targetEl: HTMLElement, greenHighlight?: boolean) {
const computedStyles = window.getComputedStyle(
document.querySelector('.monaco-editor .view-lines')!
)
Object.assign(targetEl.style, {
fontFamily: computedStyles.fontFamily,
fontSize: computedStyles.fontSize,
lineHeight: computedStyles.lineHeight,
color: 'gray',
whiteSpace: 'pre'
})
if (greenHighlight) {
targetEl.style.backgroundColor = 'var(--vscode-diffEditor-insertedTextBackground)'
}
}
export function setGlobalCSS(id: string, cssCode: string) {
let styleTag = document.getElementById(id)
if (!styleTag) {
styleTag = document.createElement('style')
styleTag.id = id
document.head.appendChild(styleTag)
}
styleTag.textContent = cssCode
}
function addInlineGhostText(change: Extract<VisualChange, { type: 'added_inline' }>) {
const cssId = crypto.randomUUID()
const decoration = {
range: {
startLineNumber: change.position.line,
startColumn: change.position.column,
endLineNumber: change.position.line,
endColumn: change.position.column + change.value.length
},
options: {
beforeContentClassName: `editor-ghost-text editor-ghost-text-content-${cssId} ${
change.options?.greenHighlight ? 'editor-ghost-text-green' : ''
}`
}
}
const safeContent = change.value.replaceAll('"', '\\"')
const css = `
.editor-ghost-text-content-${cssId}::before {
content: "${safeContent}";
white-space: pre;
}`
return { decoration, css }
}
function getReviewButtons(
editor: meditor.IStandaloneCodeEditor,
acceptFn: () => void,
rejectFn: () => void
) {
const { contentWidth, verticalScrollbarWidth } = editor.getLayoutInfo()
const scrollLeft = editor.getScrollLeft()
const reviewButtons = document.createElement('div')
reviewButtons.classList.add('absolute', 'flex', 'flex-row', 'z-10', 'rounded')
Object.assign(reviewButtons.style, {
fontFamily: 'Inter',
transform: 'translate(-100%, 100%)',
left: `${contentWidth - verticalScrollbarWidth + scrollLeft}px`,
bottom: '0'
})
editor.onDidLayoutChange((e) => {
const scrollLeft = editor.getScrollLeft()
reviewButtons.style.left = `${e.contentWidth - e.verticalScrollbarWidth + scrollLeft}px`
})
editor.onDidScrollChange((e) => {
const { contentWidth, verticalScrollbarWidth } = editor.getLayoutInfo()
reviewButtons.style.left = `${contentWidth - verticalScrollbarWidth + e.scrollLeft}px`
})
const acceptButton = document.createElement('button')
acceptButton.textContent = 'Accept'
Object.assign(acceptButton.style, {
color: 'black',
padding: '0.1rem 0.2rem',
backgroundColor: 'rgb(160, 230, 160)'
})
acceptButton.classList.add('text-xs', 'font-normal', 'rounded-bl')
acceptButton.addEventListener('click', () => {
acceptFn()
})
const layout = editor.getLayoutInfo()
layout.width
const rejectButton = document.createElement('button')
rejectButton.textContent = 'Reject'
Object.assign(rejectButton.style, {
color: 'black',
padding: '0.1rem 0.2rem',
backgroundColor: 'rgb(230, 160, 160)'
})
rejectButton.classList.add('text-xs', 'font-normal', 'rounded-br')
rejectButton.addEventListener('click', () => {
rejectFn()
})
reviewButtons.append(acceptButton)
reviewButtons.append(rejectButton)
return reviewButtons
}
async function addMultilineGhostText(
editor: meditor.IStandaloneCodeEditor,
text: string,
afterLineNumber: number,
heightInLines: number,
options?: {
greenHighlight?: boolean
review?: {
acceptFn: () => void
rejectFn: () => void
}
extraChanges?: VisualChange[]
}
) {
const el = document.createElement('div')
el.textContent = text
if (options?.review) {
const reviewButtons = getReviewButtons(editor, options.review.acceptFn, options.review.rejectFn)
el.append(reviewButtons)
}
applyMonacoStyles(el, options?.greenHighlight)
const addZonePromise = new Promise<string>((resolve, reject) => {
editor?.changeViewZones((acc) => {
const id = acc.addZone({
afterLineNumber,
afterColumn: 0,
heightInLines,
domNode: el
})
resolve(id)
})
})
return addZonePromise
}
export let VISUAL_CHANGES_CSS = `.editor-ghost-text-green { background-color: var(--vscode-diffEditor-insertedTextBackground) !important; }\n.editor-ghost-text-removed { background-color: var(--vscode-diffEditor-removedTextBackground); }\n\n.editor-ghost-text { display: inline-block; background-color: var(--vscode-editor-background); color: gray;}`
export async function displayVisualChanges(
cssId: string,
editor: meditor.IStandaloneCodeEditor,
visualChanges: VisualChange[]
) {
let decorations: meditor.IModelDeltaDecoration[] = []
let css = ''
let ids: string[] = []
for (const change of visualChanges) {
if (change.type === 'added_inline') {
const { css: newCss, decoration } = addInlineGhostText(change)
decorations.push(decoration)
css += newCss
} else if (change.type === 'deleted') {
const decoration: meditor.IModelDeltaDecoration = {
range: {
startLineNumber: change.range.startLine,
startColumn: change.range.startColumn,
endLineNumber: change.range.endLine,
endColumn: change.range.endColumn
},
options: {
className: 'editor-ghost-text-removed',
isWholeLine: change.options?.isWholeLine
}
}
if (change.options?.review) {
const id = await new Promise<string>((resolve, reject) => {
editor.changeViewZones((acc) => {
if (change.options?.review) {
const el = document.createElement('div')
const reviewButtons = getReviewButtons(
editor,
change.options.review.acceptFn,
change.options.review.rejectFn
)
el.append(reviewButtons)
resolve(
acc.addZone({
afterLineNumber: change.range.endLine,
afterColumn: 0,
heightInLines: 0,
domNode: el
})
)
}
})
})
ids.push(id)
}
decorations.push(decoration)
} else if (change.type === 'added_block') {
const id = await addMultilineGhostText(
editor,
change.value,
change.position.afterLineNumber,
change.value.split('\n').length, // we know it won't end by \n
change.options
)
ids.push(id)
}
}
const collection = editor.createDecorationsCollection(decorations)
setGlobalCSS(cssId, VISUAL_CHANGES_CSS + css)
return { collection, ids }
}
export function applyChange(editor: meditor.IStandaloneCodeEditor, change: VisualChange) {
if (change.type === 'added_block') {
editor.executeEdits('chat', [
{
range: {
startLineNumber: change.position.afterLineNumber + 1,
startColumn: 0,
endLineNumber: change.position.afterLineNumber + 1,
endColumn: 1
},
text: change.value + '\n'
}
])
} else if (change.type === 'deleted') {
editor.executeEdits('chat', [
{
range: {
startLineNumber: change.range.startLine,
startColumn: change.range.startColumn,
endLineNumber: change.range.endLine + 1,
endColumn: 0
},
text: ''
}
])
}
}
export function getLines(code: string) {
const lines = code.split('\n')
if (code.endsWith('\n')) {
lines.pop()
}
return lines
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { ResourceType } from '../../gen'
import { capitalize, toCamel } from '$lib/utils'
import YAML from 'yaml'
function compile(schema: Schema) {
export function compile(schema: Schema) {
function rec(x: { [name: string]: SchemaProperty }, root = false) {
let res = '{\n'
const entries = Object.entries(x)
@@ -87,7 +87,7 @@
}
async function onGenerate() {
if (!selectedCompletion && !$copilotInfo.exists_ai_resource) {
if (!selectedCompletion && !$copilotInfo.enabled) {
sendUserToast(
'Windmill AI is not enabled, you can activate it in the workspace settings',
true
@@ -100,7 +100,7 @@
kind
)
} else if (kind == 'forloop') {
;[module, state] = await createLoop(module.id, !disableAi && $copilotInfo.exists_ai_resource)
;[module, state] = await createLoop(module.id, !disableAi && $copilotInfo.enabled)
} else if (kind == 'whileloop') {
;[module, state] = await createWhileLoop(module.id)
} else if (kind == 'branchone') {
@@ -40,6 +40,7 @@
export let workspace: string | undefined = undefined
export let showCaptures: boolean = false
export let customUi: PreviewPanelUi | undefined = undefined
export let fixChatMode: boolean = false
type DrawerContent = {
mode: 'json' | Preview['language'] | 'plain'
@@ -140,6 +141,8 @@
<svelte:fragment slot="copilot-fix">
{#if lang && editor && diffEditor && args && previewJob?.result && typeof previewJob?.result == 'object' && `error` in previewJob?.result && previewJob?.result.error}
<ScriptFix
on:fix
chatMode={fixChatMode}
error={JSON.stringify(previewJob.result.error)}
{lang}
{editor}
+45 -9
View File
@@ -3,7 +3,9 @@ import { derived, type Readable, writable } from 'svelte/store'
import type { IntrospectionQuery } from 'graphql'
import {
type AIConfig,
type AIProvider,
type AIProviderModel,
type OperatorSettings,
type TokenResponse,
type UserWorkspaceList,
@@ -83,21 +85,47 @@ export const userWorkspaces: Readable<
}
})
export const copilotInfo = writable<{
ai_provider: AIProvider
exists_ai_resource: boolean
code_completion_model?: string
ai_models: string[]
enabled: boolean
codeCompletionModel?: AIProviderModel
defaultModel?: AIProviderModel
aiModels: AIProviderModel[]
}>({
ai_provider: 'openai',
exists_ai_resource: false,
ai_models: []
enabled: false,
codeCompletionModel: undefined,
defaultModel: undefined,
aiModels: []
})
export function setCopilotInfo(aiConfig: AIConfig) {
if (Object.keys(aiConfig.providers ?? {}).length > 0) {
const aiModels = Object.entries(aiConfig.providers ?? {}).flatMap(
([provider, providerConfig]) =>
providerConfig.models.map((m) => ({ model: m, provider: provider as AIProvider }))
)
copilotInfo.set({
enabled: true,
codeCompletionModel: aiConfig.code_completion_model,
defaultModel: aiConfig.default_model,
aiModels: aiModels
})
} else {
copilotInfo.set({
enabled: false,
codeCompletionModel: undefined,
defaultModel: undefined,
aiModels: []
})
}
}
export const codeCompletionLoading = writable<boolean>(false)
export const metadataCompletionEnabled = writable<boolean>(true)
export const stepInputCompletionEnabled = writable<boolean>(true)
export const FORMAT_ON_SAVE_SETTING_NAME = 'formatOnSave'
export const VIM_MODE_SETTING_NAME = 'vimMode'
export const CODE_COMPLETION_SETTING_NAME = 'codeCompletionSessionEnabled'
export const COPILOT_SESSION_MODEL_SETTING_NAME = 'copilotSessionModel'
export const COPILOT_SESSION_PROVIDER_SETTING_NAME = 'copilotSessionProvider'
export const formatOnSave = writable<boolean>(
getLocalSetting(FORMAT_ON_SAVE_SETTING_NAME) != 'false'
)
@@ -105,8 +133,16 @@ export const vimMode = writable<boolean>(getLocalSetting(VIM_MODE_SETTING_NAME)
export const codeCompletionSessionEnabled = writable<boolean>(
getLocalSetting(CODE_COMPLETION_SETTING_NAME) != 'false'
)
export const copilotSessionModel = writable<string | undefined>(
getLocalSetting(CODE_COMPLETION_SETTING_NAME) ?? undefined
const sessionModel = getLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME)
const sessionProvider = getLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME)
export const copilotSessionModel = writable<AIProviderModel | undefined>(
sessionModel && sessionProvider
? {
model: sessionModel,
provider: sessionProvider as AIProvider
}
: undefined
)
export const usedTriggerKinds = writable<string[]>([])
@@ -17,7 +17,6 @@
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
import {
enterpriseLicense,
copilotInfo,
isPremiumStore,
starStore,
superadmin,
@@ -29,7 +28,8 @@
defaultScripts,
hubBaseUrlStore,
usedTriggerKinds,
devopsRole
devopsRole,
setCopilotInfo
} from '$lib/stores'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { afterNavigate, beforeNavigate } from '$app/navigation'
@@ -257,18 +257,10 @@
workspaceAIClients.init(workspace)
try {
const info = await WorkspaceService.getCopilotInfo({ workspace })
copilotInfo.set({
...info,
ai_provider: info.ai_provider ?? 'openai'
})
setCopilotInfo(info)
} catch (err) {
copilotInfo.set({
ai_provider: 'openai',
exists_ai_resource: false,
code_completion_model: undefined,
ai_models: []
})
console.error('Could not get copilot info')
setCopilotInfo({})
console.error('Could not get copilot info', err)
}
}
@@ -114,11 +114,23 @@
})
await WorkspaceService.editCopilotConfig({
workspace: id,
requestBody: {
ai_resource: { path, provider: selected },
ai_models: aiKey ? AI_DEFAULT_MODELS[selected].slice(0, 1) : [],
code_completion_model: codeCompletionEnabled ? AI_DEFAULT_MODELS[selected][0] : undefined
}
requestBody: aiKey
? {
providers: {
[selected]: {
resource_path: path,
models: [AI_DEFAULT_MODELS[selected][0]]
}
},
default_model: {
model: AI_DEFAULT_MODELS[selected][0],
provider: selected
},
code_completion_model: codeCompletionEnabled
? { model: AI_DEFAULT_MODELS[selected][0], provider: selected }
: undefined
}
: {}
})
}
@@ -21,16 +21,17 @@
JobService,
ResourceService,
SettingService,
type AIProvider
type AIProvider,
type AIConfig
} from '$lib/gen'
import {
enterpriseLicense,
copilotInfo,
superadmin,
userStore,
usersWorkspaceStore,
workspaceStore,
isCriticalAlertsUIOpen
isCriticalAlertsUIOpen,
setCopilotInfo
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { emptyString, tryEvery } from '$lib/utils'
@@ -61,8 +62,6 @@
} from '$lib/workspace_settings'
import { base } from '$lib/base'
import { hubPaths } from '$lib/hub'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { AI_DEFAULT_MODELS } from '$lib/components/copilot/lib'
import Description from '$lib/components/Description.svelte'
import ConnectionSection from '$lib/components/ConnectionSection.svelte'
@@ -119,10 +118,29 @@
let criticalAlertUIMuted: boolean | undefined = undefined
let initialCriticalAlertUIMuted: boolean | undefined = undefined
let aiResourcePath: string | undefined = undefined
let aiProvider: AIProvider = 'openai'
let aiModels: string[] = []
const aiProviderLabels: [AIProvider, string][] = [
['openai', 'OpenAI'],
['anthropic', 'Anthropic'],
['mistral', 'Mistral'],
['deepseek', 'DeepSeek'],
['googleai', 'Google AI'],
['groq', 'Groq'],
['openrouter', 'OpenRouter'],
['customai', 'Custom AI']
]
let aiProviders: Exclude<AIConfig['providers'], undefined> = {}
let codeCompletionModel: string | undefined = undefined
let defaultModel: string | undefined = undefined
$: availableAIModels = Object.values(aiProviders).flatMap((p) => p.models)
$: modelProviderMap = Object.fromEntries(
Object.entries(aiProviders).flatMap(([provider, config]) =>
config.models.map((m) => [m, provider as AIProvider])
)
)
$: Object.keys(aiProviders).length < 1 &&
((codeCompletionModel = undefined), (defaultModel = undefined))
let s3ResourceSettings: S3ResourceSettings = {
resourceType: 's3',
@@ -221,39 +239,32 @@
}
async function editCopilotConfig(): Promise<void> {
if (aiResourcePath) {
if (Object.keys(aiProviders ?? {}).length > 0) {
const code_completion_model = codeCompletionModel
? { model: codeCompletionModel, provider: modelProviderMap[codeCompletionModel] }
: undefined
const default_model = defaultModel
? { model: defaultModel, provider: modelProviderMap[defaultModel] }
: undefined
await WorkspaceService.editCopilotConfig({
workspace: $workspaceStore!,
requestBody: {
ai_resource: {
path: aiResourcePath,
provider: aiProvider
},
code_completion_model: codeCompletionModel,
ai_models: aiModels
providers: aiProviders,
code_completion_model,
default_model
}
})
copilotInfo.set({
ai_provider: aiProvider,
exists_ai_resource: true,
code_completion_model: codeCompletionModel,
ai_models: aiModels
setCopilotInfo({
providers: aiProviders,
code_completion_model,
default_model
})
} else {
await WorkspaceService.editCopilotConfig({
workspace: $workspaceStore!,
requestBody: {
ai_resource: undefined,
code_completion_model: codeCompletionModel,
ai_models: []
}
})
copilotInfo.set({
ai_provider: 'openai',
exists_ai_resource: false,
code_completion_model: codeCompletionModel,
ai_models: []
requestBody: {}
})
setCopilotInfo({})
}
sendUserToast(`Copilot settings updated`)
}
@@ -433,10 +444,9 @@
workspaceToDeployTo = settings.deploy_to
webhook = settings.webhook
aiResourcePath = settings.ai_resource?.path
aiProvider = settings.ai_resource?.provider ?? 'openai'
codeCompletionModel = settings.code_completion_model
aiModels = settings.ai_models
aiProviders = settings.ai_config?.providers ?? {}
defaultModel = settings.ai_config?.default_model?.model
codeCompletionModel = settings.ai_config?.code_completion_model?.model
errorHandlerItemKind = settings.error_handler?.split('/')[0] as 'flow' | 'script'
errorHandlerScriptPath = (settings.error_handler ?? '').split('/').slice(1).join('/')
@@ -1056,109 +1066,147 @@
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-primary text-lg font-semibold"> Windmill AI</div>
<Description>Select an OpenAI resource to unlock Windmill AI features.</Description>
<Description link="https://www.windmill.dev/docs/core_concepts/ai_generation">
Windmill AI supports integration with your preferred AI provider for all AI features.
Windmill AI integrates with your favorite AI providers and models.
</Description>
</div>
</div>
<div class="flex flex-col gap-4">
<ToggleButtonGroup
bind:selected={aiProvider}
on:selected={() => {
aiResourcePath = ''
aiModels = []
codeCompletionModel = undefined
}}
let:item
>
<ToggleButton value="openai" label="OpenAI" {item} />
<ToggleButton value="anthropic" label="Anthropic" {item} />
<ToggleButton value="mistral" label="Mistral" {item} />
<ToggleButton value="deepseek" label="DeepSeek" {item} />
<ToggleButton value="googleai" label="Google AI" {item} />
<ToggleButton value="groq" label="Groq" {item} />
<ToggleButton value="openrouter" label="OpenRouter" {item} />
<ToggleButton
value="customai"
label={'Custom AI' + ($enterpriseLicense ? '' : ' (EE)')}
disabled={!$enterpriseLicense}
tooltip="Configure a custom AI provider that is OpenAI API compatible"
showTooltipIcon
{item}
/>
</ToggleButtonGroup>
<div class="flex gap-1">
{#key aiProvider}
<ResourcePicker
resourceType={usingOpenaiClientCredentialsOauth
? 'openai_client_credentials_oauth'
: aiProvider}
initialValue={aiResourcePath}
bind:value={aiResourcePath}
on:change={() => {
if (aiResourcePath && aiModels.length === 0) {
if (aiProvider !== 'customai') {
aiModels = AI_DEFAULT_MODELS[aiProvider].slice(0, 1)
}
}
}}
/>
<TestAIKey
disabled={!aiResourcePath || (aiProvider === 'customai' && aiModels.length === 0)}
resourcePath={aiResourcePath}
{aiProvider}
model={aiProvider === 'customai' ? aiModels[0] : AI_DEFAULT_MODELS[aiProvider][0]}
/>
{/key}
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-2">
<p class="font-semibold">AI Providers</p>
<div class="flex flex-col gap-4">
{#each aiProviderLabels as [provider, label]}
<div class="flex flex-col gap-2">
<Toggle
options={{
right:
label + (provider === 'customai' && !$enterpriseLicense ? ' (EE only)' : '')
}}
disabled={provider === 'customai' && !$enterpriseLicense}
checked={!!aiProviders[provider]}
on:change={(e) => {
if (e.detail) {
aiProviders[provider] = {
resource_path: '',
models:
AI_DEFAULT_MODELS[provider].length > 0
? [AI_DEFAULT_MODELS[provider][0]]
: []
}
if (AI_DEFAULT_MODELS[provider].length > 0 && !defaultModel) {
defaultModel = AI_DEFAULT_MODELS[provider][0]
}
} else {
aiProviders = Object.fromEntries(
Object.entries(aiProviders).filter(([key]) => key !== provider)
)
}
}}
/>
{#if aiProviders[provider]}
<div class="mb-4 flex flex-col gap-2">
<div class="flex flex-row gap-1">
{#key aiProviders[provider].resource_path}
<ResourcePicker
resourceType={usingOpenaiClientCredentialsOauth
? 'openai_client_credentials_oauth'
: provider}
initialValue={aiProviders[provider].resource_path}
bind:value={aiProviders[provider].resource_path}
on:change={() => {
if (
aiProviders[provider].resource_path &&
aiProviders[provider].models.length === 0 &&
AI_DEFAULT_MODELS[provider].length > 0
) {
aiProviders[provider].models = AI_DEFAULT_MODELS[provider].slice(0, 1)
}
}}
/>
{/key}
<TestAIKey
aiProvider={provider}
resourcePath={aiProviders[provider].resource_path}
model={aiProviders[provider].models[0]}
/>
</div>
<Label label="Enabled models">
<MultiSelect
options={AI_DEFAULT_MODELS[provider]}
ulOptionsClass={'!bg-surface-secondary'}
allowUserOptions="append"
bind:selected={aiProviders[provider].models}
/>
</Label>
</div>
{/if}
</div>
{/each}
</div>
</div>
{#if aiResourcePath}
<Label label="Enabled models">
<MultiSelect
options={AI_DEFAULT_MODELS[aiProvider]}
ulOptionsClass={'!bg-surface-secondary'}
allowUserOptions="append"
bind:selected={aiModels}
/>
</Label>
{#if Object.keys(aiProviders).length > 0}
<div class="flex flex-col gap-2">
<Toggle
on:change={() => {
if (codeCompletionModel != undefined) {
codeCompletionModel = undefined
} else {
codeCompletionModel = AI_DEFAULT_MODELS[aiProvider][0] ?? ''
}
}}
checked={codeCompletionModel != undefined}
options={{
right: 'Code completion'
}}
/>
{#if codeCompletionModel != undefined}
<Label label="Code completion model">
<p class="font-semibold">Settings</p>
<div class="flex flex-col gap-4">
<Label label="Default chat model">
<ArgEnum
enum_={AI_DEFAULT_MODELS[aiProvider]}
bind:value={codeCompletionModel}
enum_={availableAIModels}
bind:value={defaultModel}
disabled={false}
autofocus={false}
defaultValue={undefined}
valid={true}
create={true}
create={false}
required={false}
/>
</Label>
{/if}
<div class="flex flex-col gap-2">
<Toggle
on:change={(e) => {
if (e.detail) {
codeCompletionModel = ''
} else {
codeCompletionModel = undefined
}
}}
checked={codeCompletionModel != undefined}
options={{
right: 'Code completion'
}}
/>
{#if codeCompletionModel != undefined}
<Label label="Code completion model">
<ArgEnum
enum_={availableAIModels}
bind:value={codeCompletionModel}
disabled={false}
autofocus={false}
defaultValue={undefined}
valid={true}
create={false}
required={false}
/>
<p class="text-xs">
We highly recommend using Mistral's Codestral model for code completion.
</p>
</Label>
{/if}
</div>
</div>
</div>
{/if}
<Button
disabled={(aiResourcePath && aiModels.length === 0) ||
(codeCompletionModel != undefined && codeCompletionModel.length === 0)}
size="sm"
wrapperClasses="self-start"
disabled={!Object.values(aiProviders).every((p) => p.resource_path) ||
(codeCompletionModel != undefined && codeCompletionModel.length === 0) ||
(Object.keys(aiProviders).length > 0 && !defaultModel)}
on:click={editCopilotConfig}
>
Save
+1 -1
View File
@@ -14,5 +14,5 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval} from "./client";' >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql } from "./client";' >> "${script_dirpath}/src/index.ts"
+1 -1
View File
@@ -39,4 +39,4 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval } from "./client";' >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql } from "./client";' >> "${script_dirpath}/src/index.ts"