From f762a7645c060f602294ae027d019783c8ac98e4 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 8 Apr 2025 21:26:30 -0400 Subject: [PATCH 01/18] Initial chat schema --- cli/server.go | 48 +++ .../TestProvisioners_Golden/list.golden | 2 +- cli/testdata/coder_provisioner_list.golden | 2 +- cli/testdata/server-config.yaml.golden | 4 + coderd/ai/ai.go | 9 + coderd/apidoc/docs.go | 44 +++ coderd/apidoc/swagger.json | 44 +++ coderd/chat.go | 138 +++++++++ coderd/coderd.go | 10 + coderd/database/db2sdk/db2sdk.go | 13 + coderd/database/dbauthz/dbauthz.go | 40 +++ coderd/database/dbgen/dbgen.go | 10 + coderd/database/dbmem/dbmem.go | 125 ++++++++ coderd/database/dbmetrics/querymetrics.go | 49 +++ coderd/database/dbmock/dbmock.go | 103 +++++++ coderd/database/dump.sql | 40 +++ coderd/database/foreign_key_constraint.go | 2 + .../database/migrations/000318_chat.down.sql | 34 +++ coderd/database/migrations/000318_chat.up.sql | 17 ++ coderd/database/models.go | 17 ++ coderd/database/querier.go | 7 + coderd/database/queries.sql.go | 203 +++++++++++++ coderd/database/queries/chat.sql | 36 +++ coderd/database/unique_constraint.go | 2 + coderd/httpmw/chat.go | 59 ++++ coderd/httpmw/chat_test.go | 149 +++++++++ coderd/rbac/object_gen.go | 11 + coderd/rbac/policy/policy.go | 8 + codersdk/chat.go | 138 +++++++++ codersdk/deployment.go | 25 ++ codersdk/rbacresources_gen.go | 2 + docs/reference/api/general.md | 13 + docs/reference/api/members.md | 5 + docs/reference/api/schemas.md | 94 ++++++ site/package.json | 7 +- site/pnpm-lock.yaml | 99 ++++++ site/src/api/rbacresourcesGenerated.ts | 6 + site/src/api/typesGenerated.ts | 23 ++ site/src/pages/ChatPage/ChatPage.tsx | 282 ++++++++++++++++++ site/src/router.tsx | 3 + 40 files changed, 1920 insertions(+), 3 deletions(-) create mode 100644 coderd/ai/ai.go create mode 100644 coderd/chat.go create mode 100644 coderd/database/migrations/000318_chat.down.sql create mode 100644 coderd/database/migrations/000318_chat.up.sql create mode 100644 coderd/database/queries/chat.sql create mode 100644 coderd/httpmw/chat.go create mode 100644 coderd/httpmw/chat_test.go create mode 100644 codersdk/chat.go create mode 100644 site/src/pages/ChatPage/ChatPage.tsx diff --git a/cli/server.go b/cli/server.go index 580dae369446c..75f4f442d250c 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2621,6 +2621,54 @@ func redirectHTTPToHTTPSDeprecation(ctx context.Context, logger slog.Logger, inv } } +func ReadAIProvidersFromEnv(environ []string) ([]codersdk.AIProviderConfig, error) { + // The index numbers must be in-order. + sort.Strings(environ) + + var providers []codersdk.AIProviderConfig + for _, v := range serpent.ParseEnviron(environ, "CODER_AI_PROVIDER_") { + tokens := strings.SplitN(v.Name, "_", 2) + if len(tokens) != 2 { + return nil, xerrors.Errorf("invalid env var: %s", v.Name) + } + + providerNum, err := strconv.Atoi(tokens[0]) + if err != nil { + return nil, xerrors.Errorf("parse number: %s", v.Name) + } + + var provider codersdk.AIProviderConfig + switch { + case len(providers) < providerNum: + return nil, xerrors.Errorf( + "provider num %v skipped: %s", + len(providers), + v.Name, + ) + case len(providers) == providerNum: + // At the next next provider. + providers = append(providers, provider) + case len(providers) == providerNum+1: + // At the current provider. + provider = providers[providerNum] + } + + key := tokens[1] + switch key { + case "TYPE": + provider.Type = v.Value + case "API_KEY": + provider.APIKey = v.Value + case "BASE_URL": + provider.BaseURL = v.Value + case "MODELS": + provider.Models = strings.Split(v.Value, " ") + } + providers[providerNum] = provider + } + return providers, nil +} + // ReadExternalAuthProvidersFromEnv is provided for compatibility purposes with // the viper CLI. func ReadExternalAuthProvidersFromEnv(environ []string) ([]codersdk.ExternalAuthConfig, error) { diff --git a/cli/testdata/TestProvisioners_Golden/list.golden b/cli/testdata/TestProvisioners_Golden/list.golden index 3f50f90746744..35844d8b9c50e 100644 --- a/cli/testdata/TestProvisioners_Golden/list.golden +++ b/cli/testdata/TestProvisioners_Golden/list.golden @@ -1,4 +1,4 @@ -ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION +ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION 00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder 00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder 00000000-0000-0000-aaaa-000000000002 ====[timestamp]===== ====[timestamp]===== provisioner-2 v0.0.0 map[owner: scope:organization] built-in offline 00000000-0000-0000-bbbb-000000000003 succeeded Coder diff --git a/cli/testdata/coder_provisioner_list.golden b/cli/testdata/coder_provisioner_list.golden index 64941eebf5b89..e34db5605fd81 100644 --- a/cli/testdata/coder_provisioner_list.golden +++ b/cli/testdata/coder_provisioner_list.golden @@ -1,2 +1,2 @@ -CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS ====[timestamp]===== ====[timestamp]===== built-in test v0.0.0-devel idle map[owner: scope:organization] diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 8f34ee8cbe7be..c131e7c7ad119 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -519,6 +519,10 @@ client: # Support links to display in the top right drop down menu. # (default: , type: struct[[]codersdk.LinkConfig]) supportLinks: [] +# Configure AI providers. +# (default: , type: struct[codersdk.AIConfig]) +ai: + providers: [] # External Authentication providers. # (default: , type: struct[[]codersdk.ExternalAuthConfig]) externalAuthProviders: [] diff --git a/coderd/ai/ai.go b/coderd/ai/ai.go new file mode 100644 index 0000000000000..0308133cecd62 --- /dev/null +++ b/coderd/ai/ai.go @@ -0,0 +1,9 @@ +package ai + +import ( + "context" + + "github.com/kylecarbs/aisdk-go" +) + +type Provider func(ctx context.Context, messages []aisdk.Message) (aisdk.DataStream, error) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index daef10a90d422..707e88679ba89 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10388,6 +10388,37 @@ const docTemplate = `{ } } }, + "codersdk.AIConfig": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIProviderConfig" + } + } + } + }, + "codersdk.AIProviderConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "BaseURL is the base URL to use for the API provider.", + "type": "string" + }, + "models": { + "description": "Models is the list of models to use for the API provider.", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "description": "Type is the type of the API provider.", + "type": "string" + } + } + }, "codersdk.APIKey": { "type": "object", "required": [ @@ -11742,6 +11773,9 @@ const docTemplate = `{ "agent_stat_refresh_interval": { "type": "integer" }, + "ai": { + "$ref": "#/definitions/serpent.Struct-codersdk_AIConfig" + }, "allow_workspace_renames": { "type": "boolean" }, @@ -14272,6 +14306,7 @@ const docTemplate = `{ "assign_org_role", "assign_role", "audit_log", + "chat", "crypto_key", "debug_info", "deployment_config", @@ -14310,6 +14345,7 @@ const docTemplate = `{ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceChat", "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", @@ -18250,6 +18286,14 @@ const docTemplate = `{ } } }, + "serpent.Struct-codersdk_AIConfig": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/codersdk.AIConfig" + } + } + }, "serpent.URL": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3a7bc4c2c71ed..a82e34ff9b181 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9225,6 +9225,37 @@ } } }, + "codersdk.AIConfig": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIProviderConfig" + } + } + } + }, + "codersdk.AIProviderConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "BaseURL is the base URL to use for the API provider.", + "type": "string" + }, + "models": { + "description": "Models is the list of models to use for the API provider.", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "description": "Type is the type of the API provider.", + "type": "string" + } + } + }, "codersdk.APIKey": { "type": "object", "required": [ @@ -10500,6 +10531,9 @@ "agent_stat_refresh_interval": { "type": "integer" }, + "ai": { + "$ref": "#/definitions/serpent.Struct-codersdk_AIConfig" + }, "allow_workspace_renames": { "type": "boolean" }, @@ -12930,6 +12964,7 @@ "assign_org_role", "assign_role", "audit_log", + "chat", "crypto_key", "debug_info", "deployment_config", @@ -12968,6 +13003,7 @@ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceChat", "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", @@ -16705,6 +16741,14 @@ } } }, + "serpent.Struct-codersdk_AIConfig": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/codersdk.AIConfig" + } + } + }, "serpent.URL": { "type": "object", "properties": { diff --git a/coderd/chat.go b/coderd/chat.go new file mode 100644 index 0000000000000..eb1dfc578e0ef --- /dev/null +++ b/coderd/chat.go @@ -0,0 +1,138 @@ +package coderd + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + "github.com/kylecarbs/aisdk-go" +) + +// postChats creates a new chat. +// +// @Summary Create a chat +// @ID post-chat +// @Security CoderSessionToken +// @Produce json +// @Tags Chat +// @Success 201 {object} codersdk.Chat +// @Router /chats [post] +func (api *API) postChats(w http.ResponseWriter, r *http.Request) { + apiKey := httpmw.APIKey(r) + ctx := r.Context() + + chat, err := api.Database.InsertChat(ctx, database.InsertChatParams{ + ID: uuid.New(), + OwnerID: apiKey.UserID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Title: "New Chat", + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create chat", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, w, http.StatusCreated, db2sdk.Chat(chat)) +} + +// listChats lists all chats for a user. +// +// @Summary List chats +// @ID list-chats +// @Security CoderSessionToken +// @Produce json +// @Tags Chat +// @Success 200 {array} codersdk.Chat +// @Router /chats [get] +func (api *API) listChats(w http.ResponseWriter, r *http.Request) { + apiKey := httpmw.APIKey(r) + ctx := r.Context() + + chats, err := api.Database.GetChatsByOwnerID(ctx, apiKey.UserID) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list chats", + Detail: err.Error(), + }) + } + + httpapi.Write(ctx, w, http.StatusOK, db2sdk.Chats(chats)) +} + +// chat returns a chat by ID. +// +// @Summary Get a chat +// @ID get-chat +// @Security CoderSessionToken +// @Produce json +// @Tags Chat +// @Success 200 {object} codersdk.Chat +// @Router /chats/{chat} [get] +func (api *API) chat(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + httpapi.Write(ctx, w, http.StatusOK, db2sdk.Chat(chat)) +} + +// chatMessages returns the messages of a chat. +// +// @Summary Get chat messages +// @ID get-chat-messages +// @Security CoderSessionToken +// @Produce json +// @Tags Chat +// @Success 200 {array} aisdk.Message +// @Router /chats/{chat}/messages [get] +func (api *API) chatMessages(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + rawMessages, err := api.Database.GetChatMessagesByChatID(ctx, chat.ID) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat messages", + Detail: err.Error(), + }) + } + messages := make([]aisdk.Message, len(rawMessages)) + for i, message := range rawMessages { + var msg aisdk.Message + err = json.Unmarshal(message.Content, &msg) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal chat message", + Detail: err.Error(), + }) + } + messages[i] = msg + } + + httpapi.Write(ctx, w, http.StatusOK, messages) +} + +func (api *API) postChatMessage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + var message aisdk.Message + err := json.NewDecoder(r.Body).Decode(&message) + if err != nil { + httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to decode chat message", + Detail: err.Error(), + }) + } + + var stream aisdk.DataStream + stream.WithToolCalling(func(toolCall aisdk.ToolCall) any { + return nil + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index 288671c6cb6e9..d9337f446a201 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -998,6 +998,16 @@ func New(options *Options) *API { r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) + r.Route("/chats", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", api.listChats) + r.Post("/", api.postChats) + r.Route("/{chat}", func(r chi.Router) { + r.Use(httpmw.ExtractChatParam(options.Database)) + r.Get("/", api.chat) + r.Get("/messages", api.chatMessages) + }) + }) r.Route("/external-auth", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 7efcd009c6ef9..18d1d8a6ac788 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -751,3 +751,16 @@ func AgentProtoConnectionActionToAuditAction(action database.AuditAction) (agent return agentproto.Connection_ACTION_UNSPECIFIED, xerrors.Errorf("unknown agent connection action %q", action) } } + +func Chat(chat database.Chat) codersdk.Chat { + return codersdk.Chat{ + ID: chat.ID, + Title: chat.Title, + CreatedAt: chat.CreatedAt, + UpdatedAt: chat.UpdatedAt, + } +} + +func Chats(chats []database.Chat) []codersdk.Chat { + return List(chats, Chat) +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ceb5ba7f2a15a..7fc63995966b5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1269,6 +1269,13 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } +func (q *querier) DeleteChat(ctx context.Context, id uuid.UUID) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceChat.WithID(id)); err != nil { + return err + } + return q.db.DeleteChat(ctx, id) +} + func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil { return err @@ -1686,6 +1693,27 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } +func (q *querier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithID(id)); err != nil { + return database.Chat{}, err + } + return q.db.GetChatByID(ctx, id) +} + +func (q *querier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithID(chatID)); err != nil { + return nil, err + } + return q.db.GetChatMessagesByChatID(ctx, chatID) +} + +func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil { + return nil, err + } + return q.db.GetChatsByOwnerID(ctx, ownerID) +} + func (q *querier) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return "", err @@ -3315,6 +3343,14 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg) } +func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()), q.db.InsertChat)(ctx, arg) +} + +func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + return insert(q.log, q.auth, rbac.ResourceChat.WithID(arg.ChatID), q.db.InsertChatMessages)(ctx, arg) +} + func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -3963,6 +3999,10 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg) } +func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { + panic("not implemented") +} + func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 854c7c2974fe6..a3f982847cbcf 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -142,6 +142,16 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database return key, fmt.Sprintf("%s-%s", key.ID, secret) } +func Chat(t testing.TB, db database.Store, seed database.Chat) database.Chat { + chat, err := db.InsertChat(genCtx, database.InsertChatParams{ + ID: takeFirst(seed.ID, uuid.New()), + CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), + UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), + }) + require.NoError(t, err, "insert chat") + return chat +} + func WorkspaceAgentPortShare(t testing.TB, db database.Store, orig database.WorkspaceAgentPortShare) database.WorkspaceAgentPortShare { ps, err := db.UpsertWorkspaceAgentPortShare(genCtx, database.UpsertWorkspaceAgentPortShareParams{ WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 1359d2e63484d..5b595cce7f1d8 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -215,6 +215,8 @@ type data struct { // New tables auditLogs []database.AuditLog + chats []database.Chat + chatMessages []database.ChatMessage cryptoKeys []database.CryptoKey dbcryptKeys []database.DBCryptKey files []database.File @@ -1885,6 +1887,19 @@ func (q *FakeQuerier) DeleteApplicationConnectAPIKeysByUserID(_ context.Context, return nil } +func (q *FakeQuerier) DeleteChat(ctx context.Context, id uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, chat := range q.chats { + if chat.ID == id { + q.chats = append(q.chats[:i], q.chats[i+1:]...) + return nil + } + } + return sql.ErrNoRows +} + func (*FakeQuerier) DeleteCoordinator(context.Context, uuid.UUID) error { return ErrUnimplemented } @@ -2866,6 +2881,44 @@ func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U }, nil } +func (q *FakeQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, chat := range q.chats { + if chat.ID == id { + return chat, nil + } + } + return database.Chat{}, sql.ErrNoRows +} + +func (q *FakeQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + messages := []database.ChatMessage{} + for _, chatMessage := range q.chatMessages { + if chatMessage.ChatID == chatID { + messages = append(messages, chatMessage) + } + } + return messages, nil +} + +func (q *FakeQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + chats := []database.Chat{} + for _, chat := range q.chats { + if chat.OwnerID == ownerID { + chats = append(chats, chat) + } + } + return chats, nil +} + func (q *FakeQuerier) GetCoordinatorResumeTokenSigningKey(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -8385,6 +8438,57 @@ func (q *FakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit return alog, nil } +func (q *FakeQuerier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.Chat{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + chat := database.Chat{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OwnerID: arg.OwnerID, + Title: arg.Title, + } + q.chats = append(q.chats, chat) + + return chat, nil +} + +func (q *FakeQuerier) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + id := int64(0) + if len(q.chatMessages) > 0 { + id = q.chatMessages[len(q.chatMessages)-1].ID + } + + messages := make([]database.ChatMessage, 0) + for _, content := range arg.Content { + id++ + _ = content + messages = append(messages, database.ChatMessage{ + ID: id, + ChatID: arg.ChatID, + CreatedAt: arg.CreatedAt, + Model: arg.Model, + Provider: arg.Provider, + Content: content, + }) + } + return messages, nil +} + func (q *FakeQuerier) InsertCryptoKey(_ context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { err := validateDatabaseType(arg) if err != nil { @@ -10342,6 +10446,27 @@ func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI return sql.ErrNoRows } +func (q *FakeQuerier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, chat := range q.chats { + if chat.ID == arg.ID { + q.chats[i].Title = arg.Title + q.chats[i].UpdatedAt = arg.UpdatedAt + q.chats[i] = chat + return nil + } + } + + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateCryptoKeyDeletesAt(_ context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index b76d70c764cf6..128e741da1d76 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -249,6 +249,13 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return err } +func (m queryMetricsStore) DeleteChat(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteChat(ctx, id) + m.queryLatencies.WithLabelValues("DeleteChat").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteCoordinator(ctx, id) @@ -627,6 +634,27 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID return row, err } +func (m queryMetricsStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetChatByID(ctx, id) + m.queryLatencies.WithLabelValues("GetChatByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.GetChatMessagesByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("GetChatMessagesByChatID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetChatsByOwnerID(ctx, ownerID) + m.queryLatencies.WithLabelValues("GetChatsByOwnerID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetCoordinatorResumeTokenSigningKey(ctx) @@ -1992,6 +2020,20 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse return log, err } +func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.InsertChat(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChat").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.InsertChatMessages(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatMessages").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.InsertCryptoKey(ctx, arg) @@ -2517,6 +2559,13 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up return err } +func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { + start := time.Now() + r0 := m.s.UpdateChatByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.UpdateCryptoKeyDeletesAt(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 10adfd7c5a408..17b263dfb2e07 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -376,6 +376,20 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } +// DeleteChat mocks base method. +func (m *MockStore) DeleteChat(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChat", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChat indicates an expected call of DeleteChat. +func (mr *MockStoreMockRecorder) DeleteChat(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChat", reflect.TypeOf((*MockStore)(nil).DeleteChat), ctx, id) +} + // DeleteCoordinator mocks base method. func (m *MockStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -1234,6 +1248,51 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared) } +// GetChatByID mocks base method. +func (m *MockStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatByID", ctx, id) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatByID indicates an expected call of GetChatByID. +func (mr *MockStoreMockRecorder) GetChatByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatByID", reflect.TypeOf((*MockStore)(nil).GetChatByID), ctx, id) +} + +// GetChatMessagesByChatID mocks base method. +func (m *MockStore) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatMessagesByChatID", ctx, chatID) + ret0, _ := ret[0].([]database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatMessagesByChatID indicates an expected call of GetChatMessagesByChatID. +func (mr *MockStoreMockRecorder) GetChatMessagesByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessagesByChatID", reflect.TypeOf((*MockStore)(nil).GetChatMessagesByChatID), ctx, chatID) +} + +// GetChatsByOwnerID mocks base method. +func (m *MockStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatsByOwnerID", ctx, ownerID) + ret0, _ := ret[0].([]database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatsByOwnerID indicates an expected call of GetChatsByOwnerID. +func (mr *MockStoreMockRecorder) GetChatsByOwnerID(ctx, ownerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetChatsByOwnerID), ctx, ownerID) +} + // GetCoordinatorResumeTokenSigningKey mocks base method. func (m *MockStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -4203,6 +4262,36 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg) } +// InsertChat mocks base method. +func (m *MockStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChat", ctx, arg) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChat indicates an expected call of InsertChat. +func (mr *MockStoreMockRecorder) InsertChat(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChat", reflect.TypeOf((*MockStore)(nil).InsertChat), ctx, arg) +} + +// InsertChatMessages mocks base method. +func (m *MockStore) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatMessages", ctx, arg) + ret0, _ := ret[0].([]database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatMessages indicates an expected call of InsertChatMessages. +func (mr *MockStoreMockRecorder) InsertChatMessages(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatMessages", reflect.TypeOf((*MockStore)(nil).InsertChatMessages), ctx, arg) +} + // InsertCryptoKey mocks base method. func (m *MockStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -5337,6 +5426,20 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg) } +// UpdateChatByID mocks base method. +func (m *MockStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateChatByID indicates an expected call of UpdateChatByID. +func (mr *MockStoreMockRecorder) UpdateChatByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatByID", reflect.TypeOf((*MockStore)(nil).UpdateChatByID), ctx, arg) +} + // UpdateCryptoKeyDeletesAt mocks base method. func (m *MockStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 968b6a24d4bf8..9ce3b0171d2d4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -755,6 +755,32 @@ CREATE TABLE audit_logs ( resource_icon text NOT NULL ); +CREATE TABLE chat_messages ( + id bigint NOT NULL, + chat_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + model text NOT NULL, + provider text NOT NULL, + content jsonb NOT NULL +); + +CREATE SEQUENCE chat_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE chat_messages_id_seq OWNED BY chat_messages.id; + +CREATE TABLE chats ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + owner_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + title text NOT NULL +); + CREATE TABLE crypto_keys ( feature crypto_key_feature NOT NULL, sequence integer NOT NULL, @@ -2195,6 +2221,8 @@ CREATE VIEW workspaces_expanded AS COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.'; +ALTER TABLE ONLY chat_messages ALTER COLUMN id SET DEFAULT nextval('chat_messages_id_seq'::regclass); + ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass); ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass); @@ -2216,6 +2244,12 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY chat_messages + ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_pkey PRIMARY KEY (id); + ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); @@ -2699,6 +2733,12 @@ CREATE TRIGGER user_status_change_trigger AFTER INSERT OR UPDATE ON users FOR EA ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY chat_messages + ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 3f5ce963e6fdb..0db3e9522547e 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -7,6 +7,8 @@ type ForeignKeyConstraint string // ForeignKeyConstraint enums. const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/migrations/000318_chat.down.sql b/coderd/database/migrations/000318_chat.down.sql new file mode 100644 index 0000000000000..f816da77e231d --- /dev/null +++ b/coderd/database/migrations/000318_chat.down.sql @@ -0,0 +1,34 @@ +-- Remove the created_at column +ALTER TABLE chat_messages +DROP COLUMN IF EXISTS created_at; + +-- Add back message_type and message_index (assuming previous state was TEXT and SERIAL) +ALTER TABLE chat_messages +ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT '', -- Provide a default or handle NULLs +ADD COLUMN IF NOT EXISTS message_index SERIAL; + +-- Change content back to TEXT (data loss may occur if JSONB data is not representable as TEXT) +ALTER TABLE chat_messages +ALTER COLUMN content TYPE TEXT USING content::TEXT; + +-- Attempt to revert id back to UUID with default +-- WARNING: This is complex and potentially destructive. It might fail if data exists. +-- It drops the existing primary key, sequence, and default, then attempts to set a new one. +ALTER TABLE chat_messages DROP CONSTRAINT IF EXISTS chat_messages_pkey; +DROP SEQUENCE IF EXISTS chat_messages_id_seq; +ALTER TABLE chat_messages ALTER COLUMN id DROP DEFAULT; +ALTER TABLE chat_messages ALTER COLUMN id TYPE UUID USING (gen_random_uuid()); -- Attempt conversion, may fail +ALTER TABLE chat_messages ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE chat_messages ADD PRIMARY KEY (id); + +-- Revert changes to chat_conversations (removing deleted_at) +ALTER TABLE chat_conversations +DROP COLUMN IF EXISTS deleted_at; +ALTER TABLE chat_conversations DROP CONSTRAINT IF EXISTS chat_conversations_user_id_fkey; +ALTER TABLE chat_conversations ADD CONSTRAINT chat_conversations_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); +ALTER TABLE chat_conversations ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE chat_conversations ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; + +-- Revert changes to chat_messages (removing ON DELETE CASCADE) +ALTER TABLE chat_messages DROP CONSTRAINT IF EXISTS chat_messages_conversation_id_fkey; +ALTER TABLE chat_messages ADD CONSTRAINT chat_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id); diff --git a/coderd/database/migrations/000318_chat.up.sql b/coderd/database/migrations/000318_chat.up.sql new file mode 100644 index 0000000000000..a53942239c9e2 --- /dev/null +++ b/coderd/database/migrations/000318_chat.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS chats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + title TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + -- BIGSERIAL is auto-incrementing so we know the exact order of messages. + id BIGSERIAL PRIMARY KEY, + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + model TEXT NOT NULL, + provider TEXT NOT NULL, + content JSONB NOT NULL +); diff --git a/coderd/database/models.go b/coderd/database/models.go index f817ff2712d54..c8ac71e8b9398 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2570,6 +2570,23 @@ type AuditLog struct { ResourceIcon string `db:"resource_icon" json:"resource_icon"` } +type Chat struct { + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Title string `db:"title" json:"title"` +} + +type ChatMessage struct { + ID int64 `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Model string `db:"model" json:"model"` + Provider string `db:"provider" json:"provider"` + Content json.RawMessage `db:"content" json:"content"` +} + type CryptoKey struct { Feature CryptoKeyFeature `db:"feature" json:"feature"` Sequence int32 `db:"sequence" json:"sequence"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9fbfbde410d40..d0f74ee609724 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -79,6 +79,7 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + DeleteChat(ctx context.Context, id uuid.UUID) error DeleteCoordinator(ctx context.Context, id uuid.UUID) error DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error) DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error @@ -151,6 +152,9 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) + GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) + GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) @@ -447,6 +451,8 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) + InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error) InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error) InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error @@ -540,6 +546,7 @@ type sqlcQuerier interface { UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) error UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error) UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3908dab715e31..ea37bfa10c62d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -766,6 +766,209 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam return i, err } +const deleteChat = `-- name: DeleteChat :exec +DELETE FROM chats WHERE id = $1 +` + +func (q *sqlQuerier) DeleteChat(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteChat, id) + return err +} + +const getChatByID = `-- name: GetChatByID :one +SELECT id, owner_id, created_at, updated_at, title FROM chats +WHERE id = $1 +` + +func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) { + row := q.db.QueryRowContext(ctx, getChatByID, id) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Title, + ) + return i, err +} + +const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many +SELECT id, chat_id, created_at, model, provider, content FROM chat_messages +WHERE chat_id = $1 +ORDER BY id ASC +` + +func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) { + rows, err := q.db.QueryContext(ctx, getChatMessagesByChatID, chatID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatMessage + for rows.Next() { + var i ChatMessage + if err := rows.Scan( + &i.ID, + &i.ChatID, + &i.CreatedAt, + &i.Model, + &i.Provider, + &i.Content, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatsByOwnerID = `-- name: GetChatsByOwnerID :many +SELECT id, owner_id, created_at, updated_at, title FROM chats +WHERE owner_id = $1 +ORDER BY created_at DESC +` + +func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) { + rows, err := q.db.QueryContext(ctx, getChatsByOwnerID, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chat + for rows.Next() { + var i Chat + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Title, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertChat = `-- name: InsertChat :one +INSERT INTO chats (id, owner_id, created_at, updated_at, title) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, owner_id, created_at, updated_at, title +` + +type InsertChatParams struct { + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Title string `db:"title" json:"title"` +} + +func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) { + row := q.db.QueryRowContext(ctx, insertChat, + arg.ID, + arg.OwnerID, + arg.CreatedAt, + arg.UpdatedAt, + arg.Title, + ) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Title, + ) + return i, err +} + +const insertChatMessages = `-- name: InsertChatMessages :many +INSERT INTO chat_messages (chat_id, created_at, model, provider, content) +SELECT + $1 :: uuid AS chat_id, + $2 :: timestamptz AS created_at, + $3 :: VARCHAR(127) AS model, + $4 :: VARCHAR(127) AS provider, + unnest($5 :: jsonb [ ]) AS content +RETURNING chat_messages.id, chat_messages.chat_id, chat_messages.created_at, chat_messages.model, chat_messages.provider, chat_messages.content +` + +type InsertChatMessagesParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Model string `db:"model" json:"model"` + Provider string `db:"provider" json:"provider"` + Content []json.RawMessage `db:"content" json:"content"` +} + +func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) { + rows, err := q.db.QueryContext(ctx, insertChatMessages, + arg.ChatID, + arg.CreatedAt, + arg.Model, + arg.Provider, + pq.Array(arg.Content), + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatMessage + for rows.Next() { + var i ChatMessage + if err := rows.Scan( + &i.ID, + &i.ChatID, + &i.CreatedAt, + &i.Model, + &i.Provider, + &i.Content, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateChatByID = `-- name: UpdateChatByID :exec +UPDATE chats +SET title = $2, updated_at = $3 +WHERE id = $1 +` + +type UpdateChatByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + Title string `db:"title" json:"title"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) error { + _, err := q.db.ExecContext(ctx, updateChatByID, arg.ID, arg.Title, arg.UpdatedAt) + return err +} + const deleteCryptoKey = `-- name: DeleteCryptoKey :one UPDATE crypto_keys SET secret = NULL, secret_key_id = NULL diff --git a/coderd/database/queries/chat.sql b/coderd/database/queries/chat.sql new file mode 100644 index 0000000000000..62f82c64211a8 --- /dev/null +++ b/coderd/database/queries/chat.sql @@ -0,0 +1,36 @@ +-- name: InsertChat :one +INSERT INTO chats (id, owner_id, created_at, updated_at, title) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: UpdateChatByID :exec +UPDATE chats +SET title = $2, updated_at = $3 +WHERE id = $1; + +-- name: GetChatsByOwnerID :many +SELECT * FROM chats +WHERE owner_id = $1 +ORDER BY created_at DESC; + +-- name: GetChatByID :one +SELECT * FROM chats +WHERE id = $1; + +-- name: InsertChatMessages :many +INSERT INTO chat_messages (chat_id, created_at, model, provider, content) +SELECT + @chat_id :: uuid AS chat_id, + @created_at :: timestamptz AS created_at, + @model :: VARCHAR(127) AS model, + @provider :: VARCHAR(127) AS provider, + unnest(@content :: jsonb [ ]) AS content +RETURNING chat_messages.*; + +-- name: GetChatMessagesByChatID :many +SELECT * FROM chat_messages +WHERE chat_id = $1 +ORDER BY id ASC; + +-- name: DeleteChat :exec +DELETE FROM chats WHERE id = $1; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 2b91f38c88d42..4c9c8cedcba23 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -9,6 +9,8 @@ const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + UniqueChatsPkey UniqueConstraint = "chats_pkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); diff --git a/coderd/httpmw/chat.go b/coderd/httpmw/chat.go new file mode 100644 index 0000000000000..19a0aa874ce16 --- /dev/null +++ b/coderd/httpmw/chat.go @@ -0,0 +1,59 @@ +package httpmw + +import ( + "context" + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type chatContextKey struct{} + +func ChatParam(r *http.Request) database.Chat { + return r.Context().Value(chatContextKey{}).(database.Chat) +} + +func ExtractChatParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + arg := chi.URLParam(r, "chat") + if arg == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "\"chat\" must be provided.", + }) + return + } + chatID, err := uuid.Parse(arg) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid chat ID.", + }) + return + } + chat, err := db.GetChatByID(ctx, chatID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat.", + }) + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat.", + Detail: err.Error(), + }) + return + } + ctx = context.WithValue(ctx, chatContextKey{}, chat) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/chat_test.go b/coderd/httpmw/chat_test.go new file mode 100644 index 0000000000000..b0302324b6dcd --- /dev/null +++ b/coderd/httpmw/chat_test.go @@ -0,0 +1,149 @@ +package httpmw_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractChat(t *testing.T) { + t.Parallel() + + setupAuthentication := func(db database.Store) (*http.Request, database.User) { + r := httptest.NewRequest("GET", "/", nil) + + user := dbgen.User(t, db, database.User{ + ID: uuid.New(), + }) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + }) + r.Header.Set(codersdk.SessionTokenHeader, token) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.NewRouteContext())) + return r, user + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + rw = httptest.NewRecorder() + r, _ = setupAuthentication(db) + rtr = chi.NewRouter() + ) + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", nil) + rtr.ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("InvalidUUID", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + rw = httptest.NewRecorder() + r, _ = setupAuthentication(db) + rtr = chi.NewRouter() + ) + chi.RouteContext(r.Context()).URLParams.Add("chat", "not-a-uuid") + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", nil) + rtr.ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) // Changed from NotFound in org test to BadRequest as per chat.go + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + rw = httptest.NewRecorder() + r, _ = setupAuthentication(db) + rtr = chi.NewRouter() + ) + chi.RouteContext(r.Context()).URLParams.Add("chat", uuid.NewString()) + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", nil) + rtr.ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + rw = httptest.NewRecorder() + r, user = setupAuthentication(db) + rtr = chi.NewRouter() + ) + + // Create a test chat + testChat := dbgen.Chat(t, db, database.Chat{ + ID: uuid.New(), + OwnerID: user.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Title: "Test Chat", + }) + + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + chat := httpmw.ChatParam(r) + require.NotZero(t, chat) + assert.Equal(t, testChat.ID, chat.ID) + assert.WithinDuration(t, testChat.CreatedAt, chat.CreatedAt, time.Second) + assert.WithinDuration(t, testChat.UpdatedAt, chat.UpdatedAt, time.Second) + assert.Equal(t, testChat.Title, chat.Title) + rw.WriteHeader(http.StatusOK) + }) + + // Try by ID + chi.RouteContext(r.Context()).URLParams.Add("chat", testChat.ID.String()) + rtr.ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode, "by id") + }) +} diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 7c0933c4241b0..40b7dc87a56f8 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -54,6 +54,16 @@ var ( Type: "audit_log", } + // ResourceChat + // Valid Actions + // - "ActionCreate" :: create a chat + // - "ActionDelete" :: delete a chat + // - "ActionRead" :: read a chat + // - "ActionUpdate" :: update a chat + ResourceChat = Object{ + Type: "chat", + } + // ResourceCryptoKey // Valid Actions // - "ActionCreate" :: create crypto keys @@ -354,6 +364,7 @@ func AllResources() []Objecter { ResourceAssignOrgRole, ResourceAssignRole, ResourceAuditLog, + ResourceChat, ResourceCryptoKey, ResourceDebugInfo, ResourceDeploymentConfig, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 5b661243dc127..35da0892abfdb 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -104,6 +104,14 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionRead: actDef("read and use a workspace proxy"), }, }, + "chat": { + Actions: map[Action]ActionDefinition{ + ActionCreate: actDef("create a chat"), + ActionRead: actDef("read a chat"), + ActionDelete: actDef("delete a chat"), + ActionUpdate: actDef("update a chat"), + }, + }, "license": { Actions: map[Action]ActionDefinition{ ActionCreate: actDef("create a license"), diff --git a/codersdk/chat.go b/codersdk/chat.go new file mode 100644 index 0000000000000..2768edcdf6b77 --- /dev/null +++ b/codersdk/chat.go @@ -0,0 +1,138 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/kylecarbs/aisdk-go" + "golang.org/x/xerrors" +) + +// CreateChat creates a new chat. +func (c *Client) CreateChat(ctx context.Context) (Chat, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/chats", nil) + if err != nil { + return Chat{}, xerrors.Errorf("execute request: %w", err) + } + if res.StatusCode != http.StatusCreated { + return Chat{}, ReadBodyAsError(res) + } + defer res.Body.Close() + var chat Chat + return chat, json.NewDecoder(res.Body).Decode(&chat) +} + +type Chat struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` +} + +// ListChats lists all chats. +func (c *Client) ListChats(ctx context.Context) ([]Chat, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/chats", nil) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + var chats []Chat + return chats, json.NewDecoder(res.Body).Decode(&chats) +} + +// Chat returns a chat by ID. +func (c *Client) Chat(ctx context.Context, id uuid.UUID) (Chat, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s", id), nil) + if err != nil { + return Chat{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Chat{}, ReadBodyAsError(res) + } + var chat Chat + return chat, json.NewDecoder(res.Body).Decode(&chat) +} + +// ChatMessages returns the messages of a chat. +func (c *Client) ChatMessages(ctx context.Context, id uuid.UUID) ([]aisdk.Message, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/messages", id), nil) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var messages []aisdk.Message + return messages, json.NewDecoder(res.Body).Decode(&messages) +} + +// CreateChatMessage creates a new chat message and streams the response. +// If the provided message has a conflicting ID with an existing message, +// it will be overwritten. +func (c *Client) CreateChatMessage(ctx context.Context, id uuid.UUID, message aisdk.Message) (<-chan aisdk.DataStreamPart, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/chats/%s/messages", id), message) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + nextEvent := ServerSentEventReader(ctx, res.Body) + + wc := make(chan aisdk.DataStreamPart, 256) + go func() { + defer close(wc) + defer res.Body.Close() + + for { + select { + case <-ctx.Done(): + return + default: + sse, err := nextEvent() + if err != nil { + return + } + if sse.Type != ServerSentEventTypeData { + continue + } + var part aisdk.DataStreamPart + b, ok := sse.Data.([]byte) + if !ok { + return + } + err = json.Unmarshal(b, &part) + if err != nil { + return + } + select { + case <-ctx.Done(): + return + case wc <- part: + } + } + } + }() + + return wc, nil +} + +func (c *Client) DeleteChat(ctx context.Context, id uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/chats/%s", id), nil) + if err != nil { + return xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 154d7f6cb92e4..f99a8d4631dd2 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -383,6 +383,7 @@ type DeploymentValues struct { DisablePasswordAuth serpent.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` Support SupportConfig `json:"support,omitempty" typescript:",notnull"` ExternalAuthConfigs serpent.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"` + AI serpent.Struct[AIConfig] `json:"ai,omitempty" typescript:",notnull"` SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` @@ -2660,6 +2661,15 @@ Write out the current server config as YAML to stdout.`, Value: &c.Support.Links, Hidden: false, }, + { + // Env handling is done in cli.ReadAIProvidersFromEnv + Name: "AI", + Description: "Configure AI providers.", + YAML: "ai", + Value: &c.AI, + // Hidden because this is experimental. + Hidden: true, + }, { // Env handling is done in cli.ReadGitAuthFromEnvironment Name: "External Auth Providers", @@ -3081,6 +3091,21 @@ Write out the current server config as YAML to stdout.`, return opts } +type AIProviderConfig struct { + // Type is the type of the API provider. + Type string `json:"type" yaml:"type"` + // APIKey is the API key to use for the API provider. + APIKey string `json:"-" yaml:"api_key"` + // Models is the list of models to use for the API provider. + Models []string `json:"models" yaml:"models"` + // BaseURL is the base URL to use for the API provider. + BaseURL string `json:"base_url" yaml:"base_url"` +} + +type AIConfig struct { + Providers []AIProviderConfig `json:"providers" yaml:"providers"` +} + type SupportConfig struct { Links serpent.Struct[[]LinkConfig] `json:"links" typescript:",notnull"` } diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 7f1bd5da4eb3c..54f65767928d6 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -9,6 +9,7 @@ const ( ResourceAssignOrgRole RBACResource = "assign_org_role" ResourceAssignRole RBACResource = "assign_role" ResourceAuditLog RBACResource = "audit_log" + ResourceChat RBACResource = "chat" ResourceCryptoKey RBACResource = "crypto_key" ResourceDebugInfo RBACResource = "debug_info" ResourceDeploymentConfig RBACResource = "deployment_config" @@ -69,6 +70,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate}, ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, ResourceAuditLog: {ActionCreate, ActionRead}, + ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceDebugInfo: {ActionRead}, ResourceDeploymentConfig: {ActionRead, ActionUpdate}, diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 3c27ddb6dea1d..daff756a6a89c 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -161,6 +161,19 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "user": {} }, "agent_stat_refresh_interval": 0, + "ai": { + "value": { + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] + } + }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 972313001f3ea..a58a597d1ea2a 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -185,6 +185,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -351,6 +352,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -517,6 +519,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -652,6 +655,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -1009,6 +1013,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e2ba1373aa613..ca37850851d10 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -305,6 +305,48 @@ | `groups` | array of [codersdk.Group](#codersdkgroup) | false | | | | `users` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | | +## codersdk.AIConfig + +```json +{ + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|-----------------------------------------------------------------|----------|--------------|-------------| +| `providers` | array of [codersdk.AIProviderConfig](#codersdkaiproviderconfig) | false | | | + +## codersdk.AIProviderConfig + +```json +{ + "base_url": "string", + "models": [ + "string" + ], + "type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|-----------------|----------|--------------|-----------------------------------------------------------| +| `base_url` | string | false | | Base URL is the base URL to use for the API provider. | +| `models` | array of string | false | | Models is the list of models to use for the API provider. | +| `type` | string | false | | Type is the type of the API provider. | + ## codersdk.APIKey ```json @@ -1812,6 +1854,19 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "user": {} }, "agent_stat_refresh_interval": 0, + "ai": { + "value": { + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] + } + }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, @@ -2297,6 +2352,19 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "user": {} }, "agent_stat_refresh_interval": 0, + "ai": { + "value": { + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] + } + }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, @@ -2673,6 +2741,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `address` | [serpent.HostPort](#serpenthostport) | false | | Deprecated: Use HTTPAddress or TLS.Address instead. | | `agent_fallback_troubleshooting_url` | [serpent.URL](#serpenturl) | false | | | | `agent_stat_refresh_interval` | integer | false | | | +| `ai` | [serpent.Struct-codersdk_AIConfig](#serpentstruct-codersdk_aiconfig) | false | | | | `allow_workspace_renames` | boolean | false | | | | `autobuild_poll_interval` | integer | false | | | | `browser_only` | boolean | false | | | @@ -5354,6 +5423,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `assign_org_role` | | `assign_role` | | `audit_log` | +| `chat` | | `crypto_key` | | `debug_info` | | `deployment_config` | @@ -11118,6 +11188,30 @@ None |---------|-----------------------------------------------------|----------|--------------|-------------| | `value` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | +## serpent.Struct-codersdk_AIConfig + +```json +{ + "value": { + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|----------------------------------------|----------|--------------|-------------| +| `value` | [codersdk.AIConfig](#codersdkaiconfig) | false | | | + ## serpent.URL ```json diff --git a/site/package.json b/site/package.json index 23c1cf9d22428..b5eb1d063ddcb 100644 --- a/site/package.json +++ b/site/package.json @@ -35,6 +35,7 @@ "update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis" }, "dependencies": { + "@ai-sdk/react": "1.2.6", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@emotion/cache": "11.14.0", @@ -192,7 +193,11 @@ "vite-plugin-checker": "0.8.0", "vite-plugin-turbosnap": "1.0.3" }, - "browserslist": ["chrome 110", "firefox 111", "safari 16.0"], + "browserslist": [ + "chrome 110", + "firefox 111", + "safari 16.0" + ], "resolutions": { "optionator": "0.9.3", "semver": "7.6.2" diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index e20e5b322b2c2..2a1e8a0fb9c73 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: .: dependencies: + '@ai-sdk/react': + specifier: 1.2.6 + version: 1.2.6(react@18.3.1)(zod@3.24.2) '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 @@ -489,6 +492,32 @@ packages: '@adobe/css-tools@4.4.1': resolution: {integrity: sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==, tarball: https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz} + '@ai-sdk/provider-utils@2.2.4': + resolution: {integrity: sha512-13sEGBxB6kgaMPGOgCLYibF6r8iv8mgjhuToFrOTU09bBxbFQd8ZoARarCfJN6VomCUbUvMKwjTBLb1vQnN+WA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.4.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + + '@ai-sdk/provider@1.1.0': + resolution: {integrity: sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.0.tgz} + engines: {node: '>=18'} + + '@ai-sdk/react@1.2.6': + resolution: {integrity: sha512-5BFChNbcYtcY9MBStcDev7WZRHf0NpTrk8yfSoedWctB3jfWkFd1HECBvdc8w3mUQshF2MumLHtAhRO7IFtGGQ==, tarball: https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.6.tgz} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + + '@ai-sdk/ui-utils@1.2.5': + resolution: {integrity: sha512-XDgqnJcaCkDez7qolvk+PDbs/ceJvgkNkxkOlc9uDWqxfDJxtvCZ+14MP/1qr4IBwGIgKVHzMDYDXvqVhSWLzg==, tarball: https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.5.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==, tarball: https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz} engines: {node: '>=10'} @@ -4484,6 +4513,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, tarball: https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, tarball: https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz} @@ -5603,6 +5635,9 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==, tarball: https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz} + semver@7.6.2: resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==, tarball: https://registry.npmjs.org/semver/-/semver-7.6.2.tgz} engines: {node: '>=10'} @@ -5844,6 +5879,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, tarball: https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz} engines: {node: '>= 0.4'} + swr@2.3.3: + resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==, tarball: https://registry.npmjs.org/swr/-/swr-2.3.3.tgz} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, tarball: https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz} @@ -5881,6 +5921,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, tarball: https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==, tarball: https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz} + engines: {node: '>=18'} + tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==, tarball: https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz} @@ -6417,6 +6461,13 @@ packages: zod@3.24.3: resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==, tarball: https://registry.npmjs.org/zod/-/zod-3.24.3.tgz} + zod-to-json-schema@3.24.5: + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==, tarball: https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz} + peerDependencies: + zod: ^3.24.1 + + zod@3.24.2: + resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.24.2.tgz} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==, tarball: https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz} @@ -6428,6 +6479,34 @@ snapshots: '@adobe/css-tools@4.4.1': {} + '@ai-sdk/provider-utils@2.2.4(zod@3.24.2)': + dependencies: + '@ai-sdk/provider': 1.1.0 + nanoid: 3.3.8 + secure-json-parse: 2.7.0 + zod: 3.24.2 + + '@ai-sdk/provider@1.1.0': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@1.2.6(react@18.3.1)(zod@3.24.2)': + dependencies: + '@ai-sdk/provider-utils': 2.2.4(zod@3.24.2) + '@ai-sdk/ui-utils': 1.2.5(zod@3.24.2) + react: 18.3.1 + swr: 2.3.3(react@18.3.1) + throttleit: 2.1.0 + optionalDependencies: + zod: 3.24.2 + + '@ai-sdk/ui-utils@1.2.5(zod@3.24.2)': + dependencies: + '@ai-sdk/provider': 1.1.0 + '@ai-sdk/provider-utils': 2.2.4(zod@3.24.2) + zod: 3.24.2 + zod-to-json-schema: 3.24.5(zod@3.24.2) + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -10968,6 +11047,8 @@ snapshots: json-schema-traverse@0.4.1: optional: true + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: optional: true @@ -12448,6 +12529,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + secure-json-parse@2.7.0: {} + semver@7.6.2: {} send@0.19.0: @@ -12701,6 +12784,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swr@2.3.3(react@18.3.1): + dependencies: + dequal: 2.0.3 + react: 18.3.1 + use-sync-external-store: 1.4.0(react@18.3.1) + symbol-tree@3.2.4: {} tailwind-merge@2.6.0: {} @@ -12759,6 +12848,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttleit@2.1.0: {} + tiny-case@1.0.3: {} tiny-invariant@1.3.3: {} @@ -13259,10 +13350,18 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 +<<<<<<< HEAD zod-validation-error@3.4.0(zod@3.24.3): dependencies: zod: 3.24.3 zod@3.24.3: {} +======= + zod-to-json-schema@3.24.5(zod@3.24.2): + dependencies: + zod: 3.24.2 + + zod@3.24.2: {} +>>>>>>> da5ee701e (Initial chat schema) zwitch@2.0.4: {} diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index ffb5b541e3a4a..079dcb4a87a61 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -31,6 +31,12 @@ export const RBACResourceActions: Partial< create: "create new audit log entries", read: "read audit logs", }, + chat: { + create: "create a chat", + delete: "delete a chat", + read: "read a chat", + update: "update a chat", + }, crypto_key: { create: "create crypto keys", delete: "delete crypto keys", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d879c09d119b2..4a55b89eb172c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6,6 +6,18 @@ export interface ACLAvailable { readonly groups: readonly Group[]; } +// From codersdk/deployment.go +export interface AIConfig { + readonly providers: readonly AIProviderConfig[]; +} + +// From codersdk/deployment.go +export interface AIProviderConfig { + readonly type: string; + readonly models: readonly string[]; + readonly base_url: string; +} + // From codersdk/apikey.go export interface APIKey { readonly id: string; @@ -291,6 +303,14 @@ export interface ChangePasswordWithOneTimePasscodeRequest { readonly one_time_passcode: string; } +// From codersdk/chat.go +export interface Chat { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly title: string; +} + // From codersdk/client.go export const CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry"; @@ -677,6 +697,7 @@ export interface DeploymentValues { readonly disable_password_auth?: boolean; readonly support?: SupportConfig; readonly external_auth?: SerpentStruct; + readonly ai?: SerpentStruct; readonly config_ssh?: SSHConfig; readonly wgtunnel_host?: string; readonly disable_owner_workspace_exec?: boolean; @@ -2061,6 +2082,7 @@ export type RBACResource = | "assign_org_role" | "assign_role" | "audit_log" + | "chat" | "crypto_key" | "debug_info" | "deployment_config" @@ -2099,6 +2121,7 @@ export const RBACResources: RBACResource[] = [ "assign_org_role", "assign_role", "audit_log", + "chat", "crypto_key", "debug_info", "deployment_config", diff --git a/site/src/pages/ChatPage/ChatPage.tsx b/site/src/pages/ChatPage/ChatPage.tsx new file mode 100644 index 0000000000000..bec4277d3920c --- /dev/null +++ b/site/src/pages/ChatPage/ChatPage.tsx @@ -0,0 +1,282 @@ +import { FC, FormEvent } from "react"; +import { useChat } from "@ai-sdk/react"; +import { useTheme } from "@emotion/react"; +import { Margins } from "components/Margins/Margins"; +import TextField from "@mui/material/TextField"; +import Paper from "@mui/material/Paper"; +import IconButton from "@mui/material/IconButton"; +import SendIcon from "@mui/icons-material/Send"; +import CircularProgress from "@mui/material/CircularProgress"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; + +export const ChatPage: FC = () => { + const { user } = useAuthenticated(); + + const theme = useTheme(); + const { + messages, + input, + handleInputChange, + handleSubmit, + isLoading, + setInput, + } = useChat({ + api: "/api/v2/chat", + }); + + const handleFormSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!input.trim() || isLoading) return; + handleSubmit(e); + }; + + const handleSuggestionClick = (suggestion: string) => { + setInput(suggestion); + }; + + return ( + +
+ {messages.length === 0 && !isLoading ? ( +
+

+ Good evening, {user?.name.split(" ")[0]} +

+

+ How can I help you today? +

+ + + + + + + + + + + + +
+ ) : ( + <> +
+ {messages.map((m) => ( + +
+ {m.content} +
+
+ ))} + {isLoading && messages.length > 0 && ( + + +
+ Thinking... +
+
+ )} + {isLoading && messages.length === 0 && ( +
+ +
+ )} +
+ +
+ + + + + + + )} +
+
+ ); +}; + +export default ChatPage; diff --git a/site/src/router.tsx b/site/src/router.tsx index 76e9adfd00b09..3ae6d8f31a30a 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -31,6 +31,7 @@ const NotFoundPage = lazy(() => import("./pages/404Page/404Page")); const DeploymentSettingsLayout = lazy( () => import("./modules/management/DeploymentSettingsLayout"), ); +const ChatPage = lazy(() => import("./pages/ChatPage/ChatPage")); const DeploymentConfigProvider = lazy( () => import("./modules/management/DeploymentConfigProvider"), ); @@ -422,6 +423,8 @@ export const router = createBrowserRouter( } /> + } /> + }> } /> From dd506e6df24a1c57e9d0e6ae22404a1c81c88070 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 9 Apr 2025 12:05:54 -0400 Subject: [PATCH 02/18] And we have chat! --- cli/server.go | 41 ++ coderd/ai/ai.go | 134 ++++- coderd/apidoc/docs.go | 354 ++++++++++++ coderd/apidoc/swagger.json | 330 +++++++++++ coderd/chat.go | 174 +++++- coderd/coderd.go | 6 +- coderd/database/dbmem/dbmem.go | 9 +- coderd/database/queries.sql.go | 14 +- coderd/database/queries/chat.sql | 2 +- coderd/deployment.go | 24 + codersdk/chat.go | 16 +- codersdk/deployment.go | 26 + docs/reference/api/chat.md | 254 +++++++++ docs/reference/api/general.md | 37 ++ docs/reference/api/schemas.md | 298 ++++++++++ site/package.json | 1 + site/pnpm-lock.yaml | 99 ++++ site/src/api/api.ts | 22 + site/src/api/queries/chats.ts | 25 + site/src/api/queries/deployment.ts | 7 + site/src/api/typesGenerated.ts | 34 ++ site/src/pages/ChatPage/ChatLanding.tsx | 183 ++++++ site/src/pages/ChatPage/ChatLayout.tsx | 220 +++++++ site/src/pages/ChatPage/ChatMessages.tsx | 539 ++++++++++++++++++ site/src/pages/ChatPage/ChatPage.tsx | 282 --------- .../pages/ChatPage/LanguageModelSelector.tsx | 67 +++ site/src/router.tsx | 9 +- 27 files changed, 2902 insertions(+), 305 deletions(-) create mode 100644 docs/reference/api/chat.md create mode 100644 site/src/api/queries/chats.ts create mode 100644 site/src/pages/ChatPage/ChatLanding.tsx create mode 100644 site/src/pages/ChatPage/ChatLayout.tsx create mode 100644 site/src/pages/ChatPage/ChatMessages.tsx delete mode 100644 site/src/pages/ChatPage/ChatPage.tsx create mode 100644 site/src/pages/ChatPage/LanguageModelSelector.tsx diff --git a/cli/server.go b/cli/server.go index 75f4f442d250c..873de5aae8d54 100644 --- a/cli/server.go +++ b/cli/server.go @@ -61,6 +61,7 @@ import ( "github.com/coder/serpent" "github.com/coder/wgtunnel/tunnelsdk" + "github.com/coder/coder/v2/coderd/ai" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/notifications/reports" "github.com/coder/coder/v2/coderd/runtimeconfig" @@ -610,6 +611,22 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ) } + aiProviders, err := ReadAIProvidersFromEnv(os.Environ()) + if err != nil { + return xerrors.Errorf("read ai providers from env: %w", err) + } + vals.AI.Value.Providers = append(vals.AI.Value.Providers, aiProviders...) + for _, provider := range aiProviders { + logger.Debug( + ctx, "loaded ai provider", + slog.F("type", provider.Type), + ) + } + languageModels, err := ai.ModelsFromConfig(ctx, vals.AI.Value.Providers) + if err != nil { + return xerrors.Errorf("create language models: %w", err) + } + realIPConfig, err := httpmw.ParseRealIPConfig(vals.ProxyTrustedHeaders, vals.ProxyTrustedOrigins) if err != nil { return xerrors.Errorf("parse real ip config: %w", err) @@ -640,6 +657,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. CacheDir: cacheDir, GoogleTokenValidator: googleTokenValidator, ExternalAuthConfigs: externalAuthConfigs, + LanguageModels: languageModels, RealIPConfig: realIPConfig, SSHKeygenAlgorithm: sshKeygenAlgorithm, TracerProvider: tracerProvider, @@ -2666,6 +2684,29 @@ func ReadAIProvidersFromEnv(environ []string) ([]codersdk.AIProviderConfig, erro } providers[providerNum] = provider } + for _, envVar := range environ { + tokens := strings.SplitN(envVar, "=", 2) + if len(tokens) != 2 { + continue + } + switch tokens[0] { + case "OPENAI_API_KEY": + providers = append(providers, codersdk.AIProviderConfig{ + Type: "openai", + APIKey: tokens[1], + }) + case "ANTHROPIC_API_KEY": + providers = append(providers, codersdk.AIProviderConfig{ + Type: "anthropic", + APIKey: tokens[1], + }) + case "GOOGLE_API_KEY": + providers = append(providers, codersdk.AIProviderConfig{ + Type: "google", + APIKey: tokens[1], + }) + } + } return providers, nil } diff --git a/coderd/ai/ai.go b/coderd/ai/ai.go index 0308133cecd62..f54bb92231b21 100644 --- a/coderd/ai/ai.go +++ b/coderd/ai/ai.go @@ -2,8 +2,140 @@ package ai import ( "context" + "fmt" + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + "github.com/coder/coder/v2/codersdk" "github.com/kylecarbs/aisdk-go" + "github.com/openai/openai-go" + openaioption "github.com/openai/openai-go/option" + "google.golang.org/genai" ) -type Provider func(ctx context.Context, messages []aisdk.Message) (aisdk.DataStream, error) +type LanguageModel struct { + codersdk.LanguageModel + StreamFunc StreamFunc +} + +type StreamOptions struct { + Model string + Messages []aisdk.Message + Thinking bool + Tools []aisdk.Tool +} + +type StreamFunc func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) + +// LanguageModels is a map of language model ID to language model. +type LanguageModels map[string]LanguageModel + +func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) (LanguageModels, error) { + models := make(LanguageModels) + + for _, config := range configs { + var streamFunc StreamFunc + + switch config.Type { + case "openai": + client := openai.NewClient(openaioption.WithAPIKey(config.APIKey)) + streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { + openaiMessages, err := aisdk.MessagesToOpenAI(options.Messages) + if err != nil { + return nil, err + } + tools := aisdk.ToolsToOpenAI(options.Tools) + return aisdk.OpenAIToDataStream(client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openaiMessages, + Model: options.Model, + Tools: tools, + MaxTokens: openai.Int(8192), + })), nil + } + if config.Models == nil { + models, err := client.Models.List(ctx) + if err != nil { + return nil, err + } + config.Models = make([]string, len(models.Data)) + for i, model := range models.Data { + config.Models[i] = model.ID + } + } + break + case "anthropic": + client := anthropic.NewClient(anthropicoption.WithAPIKey(config.APIKey)) + streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { + anthropicMessages, systemMessage, err := aisdk.MessagesToAnthropic(options.Messages) + if err != nil { + return nil, err + } + return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + Messages: anthropicMessages, + Model: options.Model, + System: systemMessage, + Tools: aisdk.ToolsToAnthropic(options.Tools), + MaxTokens: 8192, + })), nil + } + if config.Models == nil { + models, err := client.Models.List(ctx, anthropic.ModelListParams{}) + if err != nil { + return nil, err + } + config.Models = make([]string, len(models.Data)) + for i, model := range models.Data { + config.Models[i] = model.ID + } + } + break + case "google": + client, err := genai.NewClient(ctx, &genai.ClientConfig{ + APIKey: config.APIKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + return nil, err + } + streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { + googleMessages, err := aisdk.MessagesToGoogle(options.Messages) + if err != nil { + return nil, err + } + tools, err := aisdk.ToolsToGoogle(options.Tools) + if err != nil { + return nil, err + } + return aisdk.GoogleToDataStream(client.Models.GenerateContentStream(ctx, options.Model, googleMessages, &genai.GenerateContentConfig{ + Tools: tools, + })), nil + } + if config.Models == nil { + models, err := client.Models.List(ctx, &genai.ListModelsConfig{}) + if err != nil { + return nil, err + } + config.Models = make([]string, len(models.Items)) + for i, model := range models.Items { + config.Models[i] = model.Name + } + } + break + default: + return nil, fmt.Errorf("unsupported model type: %s", config.Type) + } + + for _, model := range config.Models { + models[model] = LanguageModel{ + LanguageModel: codersdk.LanguageModel{ + ID: model, + DisplayName: model, + Provider: config.Type, + }, + StreamFunc: streamFunc, + } + } + } + + return models, nil +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 707e88679ba89..4757963d36725 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -343,6 +343,110 @@ const docTemplate = `{ } } }, + "/chats": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "List chats", + "operationId": "list-chats", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Create a chat", + "operationId": "post-chat", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "/chats/{chat}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Get a chat", + "operationId": "get-chat", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "/chats/{chat}/messages": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Get chat messages", + "operationId": "get-chat-messages", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Message" + } + } + } + } + } + }, "/csp/reports": { "post": { "security": [ @@ -659,6 +763,31 @@ const docTemplate = `{ } } }, + "/deployment/llms": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Get language models", + "operationId": "get-language-models", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.LanguageModelConfig" + } + } + } + } + }, "/deployment/ssh": { "get": { "security": [ @@ -10297,6 +10426,187 @@ const docTemplate = `{ } } }, + "aisdk.Attachment": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "aisdk.Message": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": {} + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "array", + "items": { + "type": "integer" + } + }, + "experimental_attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Attachment" + } + }, + "id": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Part" + } + }, + "role": { + "type": "string" + } + } + }, + "aisdk.Part": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.ReasoningDetail" + } + }, + "mimeType": { + "description": "Type: \"file\"", + "type": "string" + }, + "reasoning": { + "description": "Type: \"reasoning\"", + "type": "string" + }, + "source": { + "description": "Type: \"source\"", + "allOf": [ + { + "$ref": "#/definitions/aisdk.SourceInfo" + } + ] + }, + "text": { + "description": "Type: \"text\"", + "type": "string" + }, + "toolInvocation": { + "description": "Type: \"tool-invocation\"", + "allOf": [ + { + "$ref": "#/definitions/aisdk.ToolInvocation" + } + ] + }, + "type": { + "$ref": "#/definitions/aisdk.PartType" + } + } + }, + "aisdk.PartType": { + "type": "string", + "enum": [ + "text", + "reasoning", + "tool-invocation", + "source", + "file", + "step-start" + ], + "x-enum-varnames": [ + "PartTypeText", + "PartTypeReasoning", + "PartTypeToolInvocation", + "PartTypeSource", + "PartTypeFile", + "PartTypeStepStart" + ] + }, + "aisdk.ReasoningDetail": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "aisdk.SourceInfo": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "data": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "uri": { + "type": "string" + } + } + }, + "aisdk.ToolInvocation": { + "type": "object", + "properties": { + "args": {}, + "result": {}, + "state": { + "$ref": "#/definitions/aisdk.ToolInvocationState" + }, + "step": { + "type": "integer" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + } + } + }, + "aisdk.ToolInvocationState": { + "type": "string", + "enum": [ + "call", + "partial-call", + "result" + ], + "x-enum-varnames": [ + "ToolInvocationStateCall", + "ToolInvocationStatePartialCall", + "ToolInvocationStateResult" + ] + }, "coderd.SCIMUser": { "type": "object", "properties": { @@ -11004,6 +11314,23 @@ const docTemplate = `{ } } }, + "codersdk.Chat": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -12572,6 +12899,33 @@ const docTemplate = `{ "RequiredTemplateVariables" ] }, + "codersdk.LanguageModel": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "id": { + "description": "ID is used by the provider to identify the LLM.", + "type": "string" + }, + "provider": { + "description": "Provider is the provider of the LLM. e.g. openai, anthropic, etc.", + "type": "string" + } + } + }, + "codersdk.LanguageModelConfig": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LanguageModel" + } + } + } + }, "codersdk.License": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a82e34ff9b181..041d483762066 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -291,6 +291,94 @@ } } }, + "/chats": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "List chats", + "operationId": "list-chats", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "Create a chat", + "operationId": "post-chat", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "/chats/{chat}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "Get a chat", + "operationId": "get-chat", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "/chats/{chat}/messages": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "Get chat messages", + "operationId": "get-chat-messages", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Message" + } + } + } + } + } + }, "/csp/reports": { "post": { "security": [ @@ -563,6 +651,27 @@ } } }, + "/deployment/llms": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Get language models", + "operationId": "get-language-models", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.LanguageModelConfig" + } + } + } + } + }, "/deployment/ssh": { "get": { "security": [ @@ -9134,6 +9243,183 @@ } } }, + "aisdk.Attachment": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "aisdk.Message": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": {} + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "array", + "items": { + "type": "integer" + } + }, + "experimental_attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Attachment" + } + }, + "id": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Part" + } + }, + "role": { + "type": "string" + } + } + }, + "aisdk.Part": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.ReasoningDetail" + } + }, + "mimeType": { + "description": "Type: \"file\"", + "type": "string" + }, + "reasoning": { + "description": "Type: \"reasoning\"", + "type": "string" + }, + "source": { + "description": "Type: \"source\"", + "allOf": [ + { + "$ref": "#/definitions/aisdk.SourceInfo" + } + ] + }, + "text": { + "description": "Type: \"text\"", + "type": "string" + }, + "toolInvocation": { + "description": "Type: \"tool-invocation\"", + "allOf": [ + { + "$ref": "#/definitions/aisdk.ToolInvocation" + } + ] + }, + "type": { + "$ref": "#/definitions/aisdk.PartType" + } + } + }, + "aisdk.PartType": { + "type": "string", + "enum": [ + "text", + "reasoning", + "tool-invocation", + "source", + "file", + "step-start" + ], + "x-enum-varnames": [ + "PartTypeText", + "PartTypeReasoning", + "PartTypeToolInvocation", + "PartTypeSource", + "PartTypeFile", + "PartTypeStepStart" + ] + }, + "aisdk.ReasoningDetail": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "aisdk.SourceInfo": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "data": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "uri": { + "type": "string" + } + } + }, + "aisdk.ToolInvocation": { + "type": "object", + "properties": { + "args": {}, + "result": {}, + "state": { + "$ref": "#/definitions/aisdk.ToolInvocationState" + }, + "step": { + "type": "integer" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + } + } + }, + "aisdk.ToolInvocationState": { + "type": "string", + "enum": ["call", "partial-call", "result"], + "x-enum-varnames": [ + "ToolInvocationStateCall", + "ToolInvocationStatePartialCall", + "ToolInvocationStateResult" + ] + }, "coderd.SCIMUser": { "type": "object", "properties": { @@ -9802,6 +10088,23 @@ } } }, + "codersdk.Chat": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -11310,6 +11613,33 @@ "enum": ["REQUIRED_TEMPLATE_VARIABLES"], "x-enum-varnames": ["RequiredTemplateVariables"] }, + "codersdk.LanguageModel": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "id": { + "description": "ID is used by the provider to identify the LLM.", + "type": "string" + }, + "provider": { + "description": "Provider is the provider of the LLM. e.g. openai, anthropic, etc.", + "type": "string" + } + } + }, + "codersdk.LanguageModelConfig": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LanguageModel" + } + } + } + }, "codersdk.License": { "type": "object", "properties": { diff --git a/coderd/chat.go b/coderd/chat.go index eb1dfc578e0ef..1165031bda19d 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -5,13 +5,18 @@ import ( "net/http" "time" + "github.com/coder/coder/v2/coderd/ai" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/codersdk" + codermcp "github.com/coder/coder/v2/mcp" "github.com/google/uuid" "github.com/kylecarbs/aisdk-go" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" ) // postChats creates a new chat. @@ -119,11 +124,11 @@ func (api *API) chatMessages(w http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, w, http.StatusOK, messages) } -func (api *API) postChatMessage(w http.ResponseWriter, r *http.Request) { +func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { ctx := r.Context() chat := httpmw.ChatParam(r) - var message aisdk.Message - err := json.NewDecoder(r.Body).Decode(&message) + var req codersdk.CreateChatMessageRequest + err := json.NewDecoder(r.Body).Decode(&req) if err != nil { httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ Message: "Failed to decode chat message", @@ -131,8 +136,165 @@ func (api *API) postChatMessage(w http.ResponseWriter, r *http.Request) { }) } - var stream aisdk.DataStream - stream.WithToolCalling(func(toolCall aisdk.ToolCall) any { - return nil + dbMessages, err := api.Database.GetChatMessagesByChatID(ctx, chat.ID) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat messages", + Detail: err.Error(), + }) + } + + messages := make([]aisdk.Message, len(dbMessages)) + for i, message := range dbMessages { + err = json.Unmarshal(message.Content, &messages[i]) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal chat message", + Detail: err.Error(), + }) + return + } + } + messages = append(messages, req.Message) + + toolMap := codermcp.AllTools() + toolsByName := make(map[string]server.ToolHandlerFunc) + client := codersdk.New(api.AccessURL) + client.SetSessionToken(httpmw.APITokenFromRequest(r)) + toolDeps := codermcp.ToolDeps{ + Client: client, + Logger: &api.Logger, + } + for _, tool := range toolMap { + toolsByName[tool.Tool.Name] = tool.MakeHandler(toolDeps) + } + convertedTools := make([]aisdk.Tool, len(toolMap)) + for i, tool := range toolMap { + schema := aisdk.Schema{ + Required: tool.Tool.InputSchema.Required, + Properties: tool.Tool.InputSchema.Properties, + } + if tool.Tool.InputSchema.Required == nil { + schema.Required = []string{} + } + convertedTools[i] = aisdk.Tool{ + Name: tool.Tool.Name, + Description: tool.Tool.Description, + Schema: schema, + } + } + + provider, ok := api.LanguageModels[req.Model] + if !ok { + httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ + Message: "Model not found", + }) + return + } + + // Write headers for the data stream! + aisdk.WriteDataStreamHeaders(w) + + // Insert the user-requested message into the database! + raw, err := json.Marshal([]aisdk.Message{req.Message}) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal chat message", + Detail: err.Error(), + }) + return + } + _, err = api.Database.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedAt: dbtime.Now(), + Model: req.Model, + Provider: provider.Provider, + Content: raw, }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert chat messages", + Detail: err.Error(), + }) + return + } + + for { + var acc aisdk.DataStreamAccumulator + stream, err := provider.StreamFunc(ctx, ai.StreamOptions{ + Model: req.Model, + Messages: messages, + Tools: convertedTools, + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create stream", + Detail: err.Error(), + }) + return + } + stream = stream.WithToolCalling(func(toolCall aisdk.ToolCall) any { + tool, ok := toolsByName[toolCall.Name] + if !ok { + return nil + } + result, err := tool(ctx, mcp.CallToolRequest{ + Params: struct { + Name string "json:\"name\"" + Arguments map[string]interface{} "json:\"arguments,omitempty\"" + Meta *struct { + ProgressToken mcp.ProgressToken "json:\"progressToken,omitempty\"" + } "json:\"_meta,omitempty\"" + }{ + Name: toolCall.Name, + Arguments: toolCall.Args, + }, + }) + if err != nil { + return map[string]any{ + "error": err.Error(), + } + } + return result.Content + }).WithAccumulator(&acc) + + err = stream.Pipe(w) + if err != nil { + // The client disppeared! + api.Logger.Error(ctx, "stream pipe error", "error", err) + return + } + + raw, err := json.Marshal(acc.Messages()) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal chat message", + Detail: err.Error(), + }) + return + } + messages = append(messages, acc.Messages()...) + + // Insert these messages into the database! + _, err = api.Database.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedAt: dbtime.Now(), + Model: req.Model, + Provider: provider.Provider, + Content: raw, + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert chat messages", + Detail: err.Error(), + }) + return + } + + if acc.FinishReason() == aisdk.FinishReasonToolCalls { + continue + } + + break + } } diff --git a/coderd/coderd.go b/coderd/coderd.go index d9337f446a201..0a215e5de49ac 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -41,6 +41,7 @@ import ( "github.com/coder/quartz" "github.com/coder/serpent" + "github.com/coder/coder/v2/coderd/ai" "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/files" @@ -155,6 +156,7 @@ type Options struct { Authorizer rbac.Authorizer AzureCertificates x509.VerifyOptions GoogleTokenValidator *idtoken.Validator + LanguageModels ai.LanguageModels GithubOAuth2Config *GithubOAuth2Config OIDCConfig *OIDCConfig PrometheusRegistry *prometheus.Registry @@ -851,7 +853,7 @@ func New(options *Options) *API { next.ServeHTTP(w, r) }) }, - httpmw.CSRF(options.DeploymentValues.HTTPCookies), + // httpmw.CSRF(options.DeploymentValues.HTTPCookies), ) // This incurs a performance hit from the middleware, but is required to make sure @@ -956,6 +958,7 @@ func New(options *Options) *API { r.Get("/config", api.deploymentValues) r.Get("/stats", api.deploymentStats) r.Get("/ssh", api.sshConfig) + r.Get("/llms", api.deploymentLLMs) }) r.Route("/experiments", func(r chi.Router) { r.Use(apiKeyMiddleware) @@ -1006,6 +1009,7 @@ func New(options *Options) *API { r.Use(httpmw.ExtractChatParam(options.Database)) r.Get("/", api.chat) r.Get("/messages", api.chatMessages) + r.Post("/messages", api.postChatMessages) }) }) r.Route("/external-auth", func(r chi.Router) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 5b595cce7f1d8..e64f6d822e536 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8474,7 +8474,14 @@ func (q *FakeQuerier) InsertChatMessages(ctx context.Context, arg database.Inser } messages := make([]database.ChatMessage, 0) - for _, content := range arg.Content { + + rawMessages := make([]json.RawMessage, 0) + err = json.Unmarshal(arg.Content, &rawMessages) + if err != nil { + return nil, err + } + + for _, content := range rawMessages { id++ _ = content messages = append(messages, database.ChatMessage{ diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ea37bfa10c62d..e1a73c8e051f5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -904,16 +904,16 @@ SELECT $2 :: timestamptz AS created_at, $3 :: VARCHAR(127) AS model, $4 :: VARCHAR(127) AS provider, - unnest($5 :: jsonb [ ]) AS content + jsonb_array_elements($5 :: jsonb) AS content RETURNING chat_messages.id, chat_messages.chat_id, chat_messages.created_at, chat_messages.model, chat_messages.provider, chat_messages.content ` type InsertChatMessagesParams struct { - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - Model string `db:"model" json:"model"` - Provider string `db:"provider" json:"provider"` - Content []json.RawMessage `db:"content" json:"content"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Model string `db:"model" json:"model"` + Provider string `db:"provider" json:"provider"` + Content json.RawMessage `db:"content" json:"content"` } func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) { @@ -922,7 +922,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa arg.CreatedAt, arg.Model, arg.Provider, - pq.Array(arg.Content), + arg.Content, ) if err != nil { return nil, err diff --git a/coderd/database/queries/chat.sql b/coderd/database/queries/chat.sql index 62f82c64211a8..6dc76184ad163 100644 --- a/coderd/database/queries/chat.sql +++ b/coderd/database/queries/chat.sql @@ -24,7 +24,7 @@ SELECT @created_at :: timestamptz AS created_at, @model :: VARCHAR(127) AS model, @provider :: VARCHAR(127) AS provider, - unnest(@content :: jsonb [ ]) AS content + jsonb_array_elements(@content :: jsonb) AS content RETURNING chat_messages.*; -- name: GetChatMessagesByChatID :many diff --git a/coderd/deployment.go b/coderd/deployment.go index 4c78563a80456..eb01281db3f7a 100644 --- a/coderd/deployment.go +++ b/coderd/deployment.go @@ -1,12 +1,14 @@ package coderd import ( + "context" "net/http" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" + "github.com/kylecarbs/aisdk-go" ) // @Summary Get deployment config @@ -84,3 +86,25 @@ func buildInfoHandler(resp codersdk.BuildInfoResponse) http.HandlerFunc { func (api *API) sshConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, api.SSHConfig) } + +type LanguageModel struct { + codersdk.LanguageModel + Provider func(ctx context.Context, messages []aisdk.Message, thinking bool) (aisdk.DataStream, error) +} + +// @Summary Get language models +// @ID get-language-models +// @Security CoderSessionToken +// @Produce json +// @Tags General +// @Success 200 {object} codersdk.LanguageModelConfig +// @Router /deployment/llms [get] +func (api *API) deploymentLLMs(rw http.ResponseWriter, r *http.Request) { + models := make([]codersdk.LanguageModel, 0, len(api.LanguageModels)) + for _, model := range api.LanguageModels { + models = append(models, model.LanguageModel) + } + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.LanguageModelConfig{ + Models: models, + }) +} diff --git a/codersdk/chat.go b/codersdk/chat.go index 2768edcdf6b77..b099e11273605 100644 --- a/codersdk/chat.go +++ b/codersdk/chat.go @@ -60,7 +60,7 @@ func (c *Client) Chat(ctx context.Context, id uuid.UUID) (Chat, error) { } // ChatMessages returns the messages of a chat. -func (c *Client) ChatMessages(ctx context.Context, id uuid.UUID) ([]aisdk.Message, error) { +func (c *Client) ChatMessages(ctx context.Context, id uuid.UUID) ([]ChatMessage, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/messages", id), nil) if err != nil { return nil, xerrors.Errorf("execute request: %w", err) @@ -69,15 +69,23 @@ func (c *Client) ChatMessages(ctx context.Context, id uuid.UUID) ([]aisdk.Messag if res.StatusCode != http.StatusOK { return nil, ReadBodyAsError(res) } - var messages []aisdk.Message + var messages []ChatMessage return messages, json.NewDecoder(res.Body).Decode(&messages) } +type ChatMessage = aisdk.Message + +type CreateChatMessageRequest struct { + Model string `json:"model"` + Message ChatMessage `json:"message"` + Thinking bool `json:"thinking"` +} + // CreateChatMessage creates a new chat message and streams the response. // If the provided message has a conflicting ID with an existing message, // it will be overwritten. -func (c *Client) CreateChatMessage(ctx context.Context, id uuid.UUID, message aisdk.Message) (<-chan aisdk.DataStreamPart, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/chats/%s/messages", id), message) +func (c *Client) CreateChatMessage(ctx context.Context, id uuid.UUID, req CreateChatMessageRequest) (<-chan aisdk.DataStreamPart, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/chats/%s/messages", id), req) if err != nil { return nil, xerrors.Errorf("execute request: %w", err) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index f99a8d4631dd2..8c529091d42f0 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3542,6 +3542,32 @@ func (c *Client) SSHConfiguration(ctx context.Context) (SSHConfigResponse, error return sshConfig, json.NewDecoder(res.Body).Decode(&sshConfig) } +type LanguageModelConfig struct { + Models []LanguageModel `json:"models"` +} + +// LanguageModel is a language model that can be used for chat. +type LanguageModel struct { + // ID is used by the provider to identify the LLM. + ID string `json:"id"` + DisplayName string `json:"display_name"` + // Provider is the provider of the LLM. e.g. openai, anthropic, etc. + Provider string `json:"provider"` +} + +func (c *Client) LanguageModelConfig(ctx context.Context) (LanguageModelConfig, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/deployment/llms", nil) + if err != nil { + return LanguageModelConfig{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return LanguageModelConfig{}, ReadBodyAsError(res) + } + var llms LanguageModelConfig + return llms, json.NewDecoder(res.Body).Decode(&llms) +} + type CryptoKeyFeature string const ( diff --git a/docs/reference/api/chat.md b/docs/reference/api/chat.md new file mode 100644 index 0000000000000..9500432762f98 --- /dev/null +++ b/docs/reference/api/chat.md @@ -0,0 +1,254 @@ +# Chat + +## List chats + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/chats \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /chats` + +### Example responses + +> 200 Response + +```json +[ + { + "created_at": "string", + "id": "string", + "title": "string", + "updated_at": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|---------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Chat](schemas.md#codersdkchat) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|----------------|--------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» created_at` | string | false | | | +| `» id` | string | false | | | +| `» title` | string | false | | | +| `» updated_at` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Create a chat + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/chats \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /chats` + +### Example responses + +> 201 Response + +```json +{ + "created_at": "string", + "id": "string", + "title": "string", + "updated_at": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|--------------------------------------------------------------|-------------|------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Chat](schemas.md#codersdkchat) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get a chat + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/chats/{chat} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /chats/{chat}` + +### Example responses + +> 200 Response + +```json +{ + "created_at": "string", + "id": "string", + "title": "string", + "updated_at": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get chat messages + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/chats/{chat}/messages \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /chats/{chat}/messages` + +### Example responses + +> 200 Response + +```json +[ + { + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": "string", + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|---------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [aisdk.Message](schemas.md#aisdkmessage) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|------------------------------|------------------------------------------------------------------|----------|--------------|-------------------------| +| `[array item]` | array | false | | | +| `» annotations` | array | false | | | +| `» content` | string | false | | | +| `» createdAt` | array | false | | | +| `» experimental_attachments` | array | false | | | +| `»» contentType` | string | false | | | +| `»» name` | string | false | | | +| `»» url` | string | false | | | +| `» id` | string | false | | | +| `» parts` | array | false | | | +| `»» data` | string | false | | | +| `»» details` | array | false | | | +| `»»» data` | string | false | | | +| `»»» signature` | string | false | | | +| `»»» text` | string | false | | | +| `»»» type` | string | false | | | +| `»» mimeType` | string | false | | Type: "file" | +| `»» reasoning` | string | false | | Type: "reasoning" | +| `»» source` | [aisdk.SourceInfo](schemas.md#aisdksourceinfo) | false | | Type: "source" | +| `»»» contentType` | string | false | | | +| `»»» data` | string | false | | | +| `»»» metadata` | object | false | | | +| `»»»» [any property]` | any | false | | | +| `»»» uri` | string | false | | | +| `»» text` | string | false | | Type: "text" | +| `»» toolInvocation` | [aisdk.ToolInvocation](schemas.md#aisdktoolinvocation) | false | | Type: "tool-invocation" | +| `»»» args` | any | false | | | +| `»»» result` | any | false | | | +| `»»» state` | [aisdk.ToolInvocationState](schemas.md#aisdktoolinvocationstate) | false | | | +| `»»» step` | integer | false | | | +| `»»» toolCallId` | string | false | | | +| `»»» toolName` | string | false | | | +| `»» type` | [aisdk.PartType](schemas.md#aisdkparttype) | false | | | +| `» role` | string | false | | | + +#### Enumerated Values + +| Property | Value | +|----------|-------------------| +| `state` | `call` | +| `state` | `partial-call` | +| `state` | `result` | +| `type` | `text` | +| `type` | `reasoning` | +| `type` | `tool-invocation` | +| `type` | `source` | +| `type` | `file` | +| `type` | `step-start` | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index daff756a6a89c..c14c317066a39 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -583,6 +583,43 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get language models + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/deployment/llms \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /deployment/llms` + +### Example responses + +> 200 Response + +```json +{ + "models": [ + { + "display_name": "string", + "id": "string", + "provider": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.LanguageModelConfig](schemas.md#codersdklanguagemodelconfig) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## SSH Config ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ca37850851d10..d6332f3326bda 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -182,6 +182,246 @@ | `icon` | string | false | | | | `id` | string | false | | ID is a unique identifier for the log source. It is scoped to a workspace agent, and can be statically defined inside code to prevent duplicate sources from being created for the same agent. | +## aisdk.Attachment + +```json +{ + "contentType": "string", + "name": "string", + "url": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `contentType` | string | false | | | +| `name` | string | false | | | +| `url` | string | false | | | + +## aisdk.Message + +```json +{ + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": "string", + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|-----------------------------------------------|----------|--------------|-------------| +| `annotations` | array of undefined | false | | | +| `content` | string | false | | | +| `createdAt` | array of integer | false | | | +| `experimental_attachments` | array of [aisdk.Attachment](#aisdkattachment) | false | | | +| `id` | string | false | | | +| `parts` | array of [aisdk.Part](#aisdkpart) | false | | | +| `role` | string | false | | | + +## aisdk.Part + +```json +{ + "data": "string", + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------|---------------------------------------------------------|----------|--------------|-------------------------| +| `data` | string | false | | | +| `details` | array of [aisdk.ReasoningDetail](#aisdkreasoningdetail) | false | | | +| `mimeType` | string | false | | Type: "file" | +| `reasoning` | string | false | | Type: "reasoning" | +| `source` | [aisdk.SourceInfo](#aisdksourceinfo) | false | | Type: "source" | +| `text` | string | false | | Type: "text" | +| `toolInvocation` | [aisdk.ToolInvocation](#aisdktoolinvocation) | false | | Type: "tool-invocation" | +| `type` | [aisdk.PartType](#aisdkparttype) | false | | | + +## aisdk.PartType + +```json +"text" +``` + +### Properties + +#### Enumerated Values + +| Value | +|-------------------| +| `text` | +| `reasoning` | +| `tool-invocation` | +| `source` | +| `file` | +| `step-start` | + +## aisdk.ReasoningDetail + +```json +{ + "data": "string", + "signature": "string", + "text": "string", + "type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|--------|----------|--------------|-------------| +| `data` | string | false | | | +| `signature` | string | false | | | +| `text` | string | false | | | +| `type` | string | false | | | + +## aisdk.SourceInfo + +```json +{ + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|--------|----------|--------------|-------------| +| `contentType` | string | false | | | +| `data` | string | false | | | +| `metadata` | object | false | | | +| » `[any property]` | any | false | | | +| `uri` | string | false | | | + +## aisdk.ToolInvocation + +```json +{ + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------------------------------------------------------|----------|--------------|-------------| +| `args` | any | false | | | +| `result` | any | false | | | +| `state` | [aisdk.ToolInvocationState](#aisdktoolinvocationstate) | false | | | +| `step` | integer | false | | | +| `toolCallId` | string | false | | | +| `toolName` | string | false | | | + +## aisdk.ToolInvocationState + +```json +"call" +``` + +### Properties + +#### Enumerated Values + +| Value | +|----------------| +| `call` | +| `partial-call` | +| `result` | + ## coderd.SCIMUser ```json @@ -1080,6 +1320,26 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `one_time_passcode` | string | true | | | | `password` | string | true | | | +## codersdk.Chat + +```json +{ + "created_at": "string", + "id": "string", + "title": "string", + "updated_at": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------|----------|--------------|-------------| +| `created_at` | string | false | | | +| `id` | string | false | | | +| `title` | string | false | | | +| `updated_at` | string | false | | | + ## codersdk.ConnectionLatency ```json @@ -3515,6 +3775,44 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-------------------------------| | `REQUIRED_TEMPLATE_VARIABLES` | +## codersdk.LanguageModel + +```json +{ + "display_name": "string", + "id": "string", + "provider": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|--------|----------|--------------|-------------------------------------------------------------------| +| `display_name` | string | false | | | +| `id` | string | false | | ID is used by the provider to identify the LLM. | +| `provider` | string | false | | Provider is the provider of the LLM. e.g. openai, anthropic, etc. | + +## codersdk.LanguageModelConfig + +```json +{ + "models": [ + { + "display_name": "string", + "id": "string", + "provider": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|-----------------------------------------------------------|----------|--------------|-------------| +| `models` | array of [codersdk.LanguageModel](#codersdklanguagemodel) | false | | | + ## codersdk.License ```json diff --git a/site/package.json b/site/package.json index b5eb1d063ddcb..7c7cc16848b26 100644 --- a/site/package.json +++ b/site/package.json @@ -112,6 +112,7 @@ "react-virtualized-auto-sizer": "1.0.24", "react-window": "1.8.11", "recharts": "2.15.0", + "rehype-raw": "7.0.0", "remark-gfm": "4.0.0", "resize-observer-polyfill": "1.5.1", "rollup-plugin-visualizer": "5.14.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 2a1e8a0fb9c73..259f234a65517 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: recharts: specifier: 2.15.0 version: 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rehype-raw: + specifier: 7.0.0 + version: 7.0.0 remark-gfm: specifier: 4.0.0 version: 4.0.0 @@ -3975,18 +3978,33 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==, tarball: https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz} + hast-util-parse-selector@2.2.5: resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz} + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==, tarball: https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz} + hast-util-to-jsx-runtime@2.3.2: resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==, tarball: https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz} + hast-util-to-parse5@8.0.0: + resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==, tarball: https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==, tarball: https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz} hastscript@6.0.0: resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==, tarball: https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz} @@ -4009,6 +4027,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==, tarball: https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==, tarball: https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz} engines: {node: '>= 0.8'} @@ -5272,6 +5293,9 @@ packages: property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==, tarball: https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz} + property-information@7.0.0: + resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==, tarball: https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz} + protobufjs@7.4.0: resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==, tarball: https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz} engines: {node: '>=12.0.0'} @@ -5528,6 +5552,9 @@ packages: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==, tarball: https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz} engines: {node: '>= 0.4'} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==, tarball: https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz} + remark-gfm@4.0.0: resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==, tarball: https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz} @@ -6211,6 +6238,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmjs.org/vary/-/vary-1.1.2.tgz} engines: {node: '>= 0.8'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==, tarball: https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz} + vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==, tarball: https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz} @@ -6322,6 +6352,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==, tarball: https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==, tarball: https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz} engines: {node: '>=12'} @@ -10268,8 +10301,39 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.0.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + hast-util-parse-selector@2.2.5: {} + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.1.2 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.2: dependencies: '@types/estree': 1.0.6 @@ -10290,6 +10354,16 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -10302,6 +10376,14 @@ snapshots: property-information: 5.6.0 space-separated-tokens: 1.1.5 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.0.0 + space-separated-tokens: 2.0.2 + headers-polyfill@4.0.3: {} highlight.js@10.7.3: {} @@ -10320,6 +10402,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -12073,6 +12157,8 @@ snapshots: property-information@6.5.0: {} + property-information@7.0.0: {} + protobufjs@7.4.0: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -12390,6 +12476,12 @@ snapshots: define-properties: 1.2.1 set-function-name: 2.0.1 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + remark-gfm@4.0.0: dependencies: '@types/mdast': 4.0.3 @@ -13140,6 +13232,11 @@ snapshots: vary@1.1.2: {} + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 @@ -13236,6 +13333,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} webpack-sources@3.2.3: {} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ef15beb8166f5..498a1aa65322f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -827,6 +827,13 @@ class ApiMethods { return response.data; }; + getDeploymentLLMs = async (): Promise => { + const response = await this.axios.get( + "/api/v2/deployment/llms", + ); + return response.data; + }; + getOrganizationIdpSyncClaimFieldValues = async ( organization: string, field: string, @@ -2489,6 +2496,21 @@ class ApiMethods { markAllInboxNotificationsAsRead = async () => { await this.axios.put("/api/v2/notifications/inbox/mark-all-as-read"); }; + + createChat = async () => { + const res = await this.axios.post("/api/v2/chats"); + return res.data; + } + + getChats = async () => { + const res = await this.axios.get("/api/v2/chats"); + return res.data; + } + + getChatMessages = async (chatId: string) => { + const res = await this.axios.get(`/api/v2/chats/${chatId}/messages`); + return res.data; + } } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts new file mode 100644 index 0000000000000..c486214978cc3 --- /dev/null +++ b/site/src/api/queries/chats.ts @@ -0,0 +1,25 @@ +import { QueryClient } from "react-query" +import { API } from "api/api" + +export const createChat = (queryClient: QueryClient) => { + return { + mutationFn: API.createChat, + onSuccess: async () => { + await queryClient.invalidateQueries(["chats"]); + }, + }; +}; + +export const getChats = () => { + return { + queryKey: ["chats"], + queryFn: API.getChats, + }; +}; + +export const getChatMessages = (chatID: string) => { + return { + queryKey: ["chatMessages", chatID], + queryFn: () => API.getChatMessages(chatID), + }; +}; diff --git a/site/src/api/queries/deployment.ts b/site/src/api/queries/deployment.ts index 999dd2ee4cbd5..463f555d57761 100644 --- a/site/src/api/queries/deployment.ts +++ b/site/src/api/queries/deployment.ts @@ -36,3 +36,10 @@ export const deploymentIdpSyncFieldValues = (field: string) => { queryFn: () => API.getDeploymentIdpSyncFieldValues(field), }; }; + +export const deploymentLanguageModels = () => { + return { + queryKey: ["deployment", "llms"], + queryFn: API.getDeploymentLLMs, + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4a55b89eb172c..a418effe03414 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -311,6 +311,20 @@ export interface Chat { readonly title: string; } +// From codersdk/chat.go +export interface ChatMessage { + readonly id: string; + readonly createdAt?: Record; + readonly content: string; + readonly role: string; + // external type "github.com/kylecarbs/aisdk-go.Part", to include this type the package must be explicitly included in the parsing + readonly parts?: readonly unknown[]; + // empty interface{} type, falling back to unknown + readonly annotations?: readonly unknown[]; + // external type "github.com/kylecarbs/aisdk-go.Attachment", to include this type the package must be explicitly included in the parsing + readonly experimental_attachments?: readonly unknown[]; +} + // From codersdk/client.go export const CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry"; @@ -332,6 +346,14 @@ export interface ConvertLoginRequest { readonly password: string; } +// From codersdk/chat.go +export interface CreateChatMessageRequest { + readonly model: string; + // embedded anonymous struct, please fix by naming it + readonly message: unknown; + readonly thinking: boolean; +} + // From codersdk/users.go export interface CreateFirstUserRequest { readonly email: string; @@ -1207,6 +1229,18 @@ export type JobErrorCode = "REQUIRED_TEMPLATE_VARIABLES"; export const JobErrorCodes: JobErrorCode[] = ["REQUIRED_TEMPLATE_VARIABLES"]; +// From codersdk/deployment.go +export interface LanguageModel { + readonly id: string; + readonly display_name: string; + readonly provider: string; +} + +// From codersdk/deployment.go +export interface LanguageModelConfig { + readonly models: readonly LanguageModel[]; +} + // From codersdk/licenses.go export interface License { readonly id: number; diff --git a/site/src/pages/ChatPage/ChatLanding.tsx b/site/src/pages/ChatPage/ChatLanding.tsx new file mode 100644 index 0000000000000..9badc419bde6c --- /dev/null +++ b/site/src/pages/ChatPage/ChatLanding.tsx @@ -0,0 +1,183 @@ +import { useTheme } from "@emotion/react"; +import SendIcon from "@mui/icons-material/Send"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { Margins } from "components/Margins/Margins"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { FC, FormEvent, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { LanguageModelSelector } from "./LanguageModelSelector"; +import { createChat } from "api/queries/chats"; +import { useMutation, useQueryClient } from "react-query"; +import { Chat } from "api/typesGenerated"; + +export interface ChatLandingLocationState { + chat: Chat + message: string +} + +export const ChatLanding: FC = () => { + const { user } = useAuthenticated(); + const theme = useTheme(); + const [input, setInput] = useState(""); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const createChatMutation = useMutation(createChat(queryClient)); + + const handleInputChange = (event: React.ChangeEvent) => { + setInput(event.target.value); + }; + + // Placeholder submit handler + const handleFormSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!input.trim()) return; + console.log("Form submitted with input:", input); + // Actual submission logic will go elsewhere + setInput(""); // Clear input after submit (optional) + + createChatMutation.mutateAsync().then((chat) => { + navigate(`/chat/${chat.id}`, { state: { + chat, + message: input + } }); + }) + + }; + + // Placeholder suggestion handler + const handleSuggestionClick = (suggestion: string) => { + setInput(suggestion); + // Optionally trigger focus on the input field here + }; + + return ( + +
+ {/* Initial Welcome Message Area */} +
+

+ Good evening, {user?.name.split(" ")[0]} +

+

+ How can I help you today? +

+
+ + {/* Input Form and Suggestions - Always Visible */} +
+ + + + + + + + + + + + +
+
+
+ ); +}; + +export default ChatLanding; diff --git a/site/src/pages/ChatPage/ChatLayout.tsx b/site/src/pages/ChatPage/ChatLayout.tsx new file mode 100644 index 0000000000000..a6ac690c2595c --- /dev/null +++ b/site/src/pages/ChatPage/ChatLayout.tsx @@ -0,0 +1,220 @@ +import { + createContext, + FC, + PropsWithChildren, + useContext, + useEffect, + useState, +} from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link, Outlet, useNavigate, useParams } from "react-router-dom"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemText from "@mui/material/ListItemText"; +import Paper from "@mui/material/Paper"; +import { useTheme } from "@emotion/react"; +import { createChat, getChats } from "api/queries/chats"; +import { deploymentLanguageModels } from "api/queries/deployment"; +import { Chat, LanguageModelConfig } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; +import Button from "@mui/material/Button"; +import AddIcon from '@mui/icons-material/Add'; + +export interface ChatContext { + selectedModel: string; + modelConfig: LanguageModelConfig; + + setSelectedModel: (model: string) => void; +} + +export const useChatContext = (): ChatContext => { + const context = useContext(ChatContext); + if (!context) { + throw new Error("useChatContext must be used within a ChatProvider"); + } + return context; +}; + +export const ChatContext = createContext(undefined); + +const SELECTED_MODEL_KEY = "coder_chat_selected_model"; + +export const ChatProvider: FC = ({ children }) => { + const [selectedModel, setSelectedModel] = useState(() => { + const savedModel = localStorage.getItem(SELECTED_MODEL_KEY); + return savedModel || ""; + }); + const modelConfigQuery = useQuery(deploymentLanguageModels()); + useEffect(() => { + if (!modelConfigQuery.data) { + return; + } + if (selectedModel === "") { + const firstModel = modelConfigQuery.data.models[0]?.id; // Handle empty models array + if (firstModel) { + setSelectedModel(firstModel); + localStorage.setItem(SELECTED_MODEL_KEY, firstModel); + } + } + }, [modelConfigQuery.data, selectedModel]); + + if (modelConfigQuery.error) { + return ; + } + + if (!modelConfigQuery.data) { + return ; + } + + const handleSetSelectedModel = (model: string) => { + setSelectedModel(model); + localStorage.setItem(SELECTED_MODEL_KEY, model); + }; + + return ( + + {children} + + ); +}; + +export const ChatLayout: FC = () => { + const queryClient = useQueryClient(); + const { data: chats, isLoading: chatsLoading } = useQuery(getChats()); + const createChatMutation = useMutation(createChat(queryClient)); + const theme = useTheme(); + const navigate = useNavigate(); + const { chatID } = useParams<{ chatID?: string }>(); + + const handleNewChat = () => { + navigate("/chat"); + }; + + console.log(chats) + + return ( + // Outermost container: controls height and prevents page scroll +
+ {/* Sidebar Container (using Paper for background/border) */} + + {/* Sidebar Header */} +
+ {/* Replaced Typography with div + styling */} +
+ Chats +
+ +
+ {/* Sidebar Scrollable List Area */} +
+ {chatsLoading ? ( + + ) : chats && chats.length > 0 ? ( + + {chats.map((chat) => ( + + + + + + ))} + + ) : ( + // Replaced Typography with div + styling +
+ No chats yet. Start a new one! +
+ )} +
+
+ + {/* Main Content Area Container */} +
+ + {/* Outlet renders ChatMessages, which should have its own internal scroll */} + + +
+
+ ); +}; + +export default ChatLayout; diff --git a/site/src/pages/ChatPage/ChatMessages.tsx b/site/src/pages/ChatPage/ChatMessages.tsx new file mode 100644 index 0000000000000..1c411dc746b52 --- /dev/null +++ b/site/src/pages/ChatPage/ChatMessages.tsx @@ -0,0 +1,539 @@ +import { Message, useChat } from "@ai-sdk/react"; +import { useTheme, Theme, keyframes } from "@emotion/react"; +import SendIcon from "@mui/icons-material/Send"; +import IconButton from "@mui/material/IconButton"; +import Paper, { PaperProps } from "@mui/material/Paper"; +import TextField from "@mui/material/TextField"; +import { getChatMessages, getChats } from "api/queries/chats"; +import { CreateChatMessageRequest, ChatMessage } from "api/typesGenerated"; +import { FC, memo, useEffect, useRef, KeyboardEvent } from "react"; +import { useQuery, useQueryClient } from "react-query"; +import { useLocation, useParams } from "react-router-dom"; +import { ChatLandingLocationState } from "./ChatLanding"; +import { useChatContext } from "./ChatLayout"; +import { LanguageModelSelector } from "./LanguageModelSelector"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; + +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +const pulseAnimation = keyframes` + 0% { opacity: 0.6; } + 50% { opacity: 1; } + 100% { opacity: 0.6; } +`; + +const renderToolInvocation = (toolInvocation: any, theme: Theme) => ( +
+
+ 🛠️ Tool Call: {toolInvocation.toolName} +
+
+
+ Arguments: +
+ {JSON.stringify(toolInvocation.args, null, 2)} +
+
+ {toolInvocation.result && ( +
+ Result: +
+ {JSON.stringify(toolInvocation.result, null, 2)} +
+
+ )} +
+
+); + +const renderReasoning = (reasoning: string, theme: Theme) => ( +
+
+ 💭 Reasoning: +
+
+ {reasoning} +
+
+); + +interface MessageBubbleProps { + message: Message; +} + +const MessageBubble: FC = memo(({ message }) => { + const theme = useTheme(); + const isUser = message.role === "user"; + + return ( +
+ code)": { + backgroundColor: isUser + ? theme.palette.grey[700] + : theme.palette.action.hover, + color: isUser ? theme.palette.grey[50] : theme.palette.text.primary, + padding: theme.spacing(0.25, 0.75), + borderRadius: "4px", + fontSize: "0.875em", + fontFamily: "monospace", + }, + "& pre": { + backgroundColor: isUser + ? theme.palette.common.black + : theme.palette.grey[100], + color: isUser ? theme.palette.grey[100] : theme.palette.text.primary, + padding: theme.spacing(1.5), + borderRadius: "8px", + overflowX: "auto", + margin: theme.spacing(1.5, 0), + width: "100%", + "& code": { + backgroundColor: "transparent", + padding: 0, + fontSize: "0.875em", + fontFamily: "monospace", + color: "inherit", + }, + }, + "& a": { + color: isUser + ? theme.palette.grey[100] + : theme.palette.primary.main, + textDecoration: "underline", + fontWeight: 500, + "&:hover": { + textDecoration: "none", + color: isUser ? theme.palette.grey[300] : theme.palette.primary.dark, + }, + }, + }} + > + {message.role === "assistant" && message.parts ? ( +
+ {message.parts.map((part, partIndex) => { + switch (part.type) { + case "text": + return ( + + {part.text} + + ); + case "tool-invocation": + return ( +
+ {renderToolInvocation(part.toolInvocation, theme)} +
+ ); + case "reasoning": + return ( +
+ {renderReasoning(part.reasoning, theme)} +
+ ); + default: + return null; + } + })} +
+ ) : ( + + {message.content} + + )} +
+
+ ); +}); + +interface ChatViewProps { + messages: Message[]; + input: string; + handleInputChange: React.ChangeEventHandler; + hhandleSubmit: (e?: React.FormEvent) => void; + isLoading: boolean; + chatID: string; +} + +const ChatView: FC = ({ + messages, + input, + handleInputChange, + hhandleSubmit, + isLoading, + chatID +}) => { + const theme = useTheme(); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const timer = setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, 50); + return () => clearTimeout(timer); + }, [messages, isLoading]); + + useEffect(() => { + inputRef.current?.focus(); + }, [chatID]); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + hhandleSubmit(); + } + }; + + return ( +
+
+
+ {messages.map((message, index) => ( + + ))} + {isLoading && ( +
+ + Thinking... + +
+ )} +
+
+
+ +
+ +
+ +
+ + + + +
+
+
+ ); +}; + +export const ChatMessages: FC = () => { + const { chatID } = useParams(); + if (!chatID) { + throw new Error("Chat ID is required in URL path /chat/:chatID"); + } + + const { state } = useLocation(); + const transferedState = state as ChatLandingLocationState | undefined; + + const messagesQuery = useQuery(getChatMessages(chatID)); + + const chatContext = useChatContext(); + + const { + messages, + input, + handleInputChange, + handleSubmit: originalHandleSubmit, + isLoading, + setInput, + } = useChat({ + id: chatID, + api: `/api/v2/chats/${chatID}/messages`, + experimental_prepareRequestBody: (options): CreateChatMessageRequest => { + const userMessages = options.messages.filter( + (message) => message.role === "user", + ); + const mostRecentUserMessage = userMessages.at(-1); + return { + model: chatContext.selectedModel, + message: mostRecentUserMessage, + thinking: false, + }; + }, + initialInput: transferedState?.message, + initialMessages: messagesQuery.data as Message[] | undefined, + }); + useEffect(() => { + // console.log(transferedState?.message, input) + if (transferedState?.message && input === transferedState?.message) { + // handleSubmit(); + } + }, [transferedState?.message]) + + const handleSubmit = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!input.trim()) return; + originalHandleSubmit(); + setInput(''); + }; + + useEffect(() => { + if (transferedState?.message) { + } + }, [transferedState?.message]); + + if (messagesQuery.error) { + return ; + } + + if (messagesQuery.isLoading && messages.length === 0) { + return ; + } + + return ( + + ); +}; + +export default ChatMessages; diff --git a/site/src/pages/ChatPage/ChatPage.tsx b/site/src/pages/ChatPage/ChatPage.tsx deleted file mode 100644 index bec4277d3920c..0000000000000 --- a/site/src/pages/ChatPage/ChatPage.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { FC, FormEvent } from "react"; -import { useChat } from "@ai-sdk/react"; -import { useTheme } from "@emotion/react"; -import { Margins } from "components/Margins/Margins"; -import TextField from "@mui/material/TextField"; -import Paper from "@mui/material/Paper"; -import IconButton from "@mui/material/IconButton"; -import SendIcon from "@mui/icons-material/Send"; -import CircularProgress from "@mui/material/CircularProgress"; -import Button from "@mui/material/Button"; -import Stack from "@mui/material/Stack"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; - -export const ChatPage: FC = () => { - const { user } = useAuthenticated(); - - const theme = useTheme(); - const { - messages, - input, - handleInputChange, - handleSubmit, - isLoading, - setInput, - } = useChat({ - api: "/api/v2/chat", - }); - - const handleFormSubmit = (e: FormEvent) => { - e.preventDefault(); - if (!input.trim() || isLoading) return; - handleSubmit(e); - }; - - const handleSuggestionClick = (suggestion: string) => { - setInput(suggestion); - }; - - return ( - -
- {messages.length === 0 && !isLoading ? ( -
-

- Good evening, {user?.name.split(" ")[0]} -

-

- How can I help you today? -

- - - - - - - - - - - - -
- ) : ( - <> -
- {messages.map((m) => ( - -
- {m.content} -
-
- ))} - {isLoading && messages.length > 0 && ( - - -
- Thinking... -
-
- )} - {isLoading && messages.length === 0 && ( -
- -
- )} -
- -
- - - - - - - )} -
-
- ); -}; - -export default ChatPage; diff --git a/site/src/pages/ChatPage/LanguageModelSelector.tsx b/site/src/pages/ChatPage/LanguageModelSelector.tsx new file mode 100644 index 0000000000000..3467706ce6651 --- /dev/null +++ b/site/src/pages/ChatPage/LanguageModelSelector.tsx @@ -0,0 +1,67 @@ +import { deploymentLanguageModels } from "api/queries/deployment"; +import { useQuery } from "react-query"; +import { FC, ChangeEvent } from "react"; +import { useTheme } from "@emotion/react"; +import { LanguageModel } from "api/typesGenerated"; // Assuming types live here based on project structure +import { useChatContext } from "./ChatLayout"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import { Loader } from "components/Loader/Loader"; + +export const LanguageModelSelector: FC = () => { + const theme = useTheme(); + const { setSelectedModel, modelConfig, selectedModel } = useChatContext(); + const { + data: languageModelConfig, + isLoading, + error, + } = useQuery(deploymentLanguageModels()); + + if (isLoading) { + return ; + } + + if (error || !languageModelConfig) { + console.error("Failed to load language models:", error); + return ( +
Error loading models.
+ ); + } + + const models = languageModelConfig.models ?? []; + + if (models.length === 0) { + return ( +
+ No language models available. +
+ ); + } + + return ( + + Model + + + ); +}; + diff --git a/site/src/router.tsx b/site/src/router.tsx index 3ae6d8f31a30a..b96d7fdb1a9a4 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -23,6 +23,8 @@ import UserSettingsLayout from "./pages/UserSettingsPage/Layout"; import UsersPage from "./pages/UsersPage/UsersPage"; import { WorkspaceSettingsLayout } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"; import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage"; +import { ChatLayout } from "pages/ChatPage/ChatLayout"; +import { ChatMessages } from "pages/ChatPage/ChatMessages"; // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed @@ -31,7 +33,7 @@ const NotFoundPage = lazy(() => import("./pages/404Page/404Page")); const DeploymentSettingsLayout = lazy( () => import("./modules/management/DeploymentSettingsLayout"), ); -const ChatPage = lazy(() => import("./pages/ChatPage/ChatPage")); +const ChatLanding = lazy(() => import("./pages/ChatPage/ChatLanding")); const DeploymentConfigProvider = lazy( () => import("./modules/management/DeploymentConfigProvider"), ); @@ -423,7 +425,10 @@ export const router = createBrowserRouter( } /> - } /> + }> + } /> + } /> + }> } /> From d848a941b0b8947417531727d5c87d4b9c963b39 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 9 Apr 2025 21:49:04 -0400 Subject: [PATCH 03/18] Add more MCP stuff --- coderd/ai/ai.go | 39 ++++-- coderd/chat.go | 112 +++++++++++------- coderd/database/dbauthz/dbauthz.go | 5 +- coderd/database/dbmem/dbmem.go | 8 +- coderd/deployment.go | 3 +- coderd/httpmw/chat.go | 5 +- coderd/httpmw/chat_test.go | 9 +- go.mod | 6 +- site/package.json | 1 + site/pnpm-lock.yaml | 37 ++++++ .../modules/dashboard/Navbar/NavbarView.tsx | 8 ++ site/src/pages/ChatPage/ChatMessages.tsx | 7 +- .../ChatPage/ChatToolInvocation.stories.tsx | 0 .../src/pages/ChatPage/ChatToolInvocation.tsx | 78 ++++++++++++ 14 files changed, 248 insertions(+), 70 deletions(-) create mode 100644 site/src/pages/ChatPage/ChatToolInvocation.stories.tsx create mode 100644 site/src/pages/ChatPage/ChatToolInvocation.tsx diff --git a/coderd/ai/ai.go b/coderd/ai/ai.go index f54bb92231b21..9c8fbc62e21ef 100644 --- a/coderd/ai/ai.go +++ b/coderd/ai/ai.go @@ -2,15 +2,16 @@ package ai import ( "context" - "fmt" "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" - "github.com/coder/coder/v2/codersdk" "github.com/kylecarbs/aisdk-go" "github.com/openai/openai-go" openaioption "github.com/openai/openai-go/option" + "golang.org/x/xerrors" "google.golang.org/genai" + + "github.com/coder/coder/v2/codersdk" ) type LanguageModel struct { @@ -19,10 +20,11 @@ type LanguageModel struct { } type StreamOptions struct { - Model string - Messages []aisdk.Message - Thinking bool - Tools []aisdk.Tool + SystemPrompt string + Model string + Messages []aisdk.Message + Thinking bool + Tools []aisdk.Tool } type StreamFunc func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) @@ -45,6 +47,12 @@ func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) return nil, err } tools := aisdk.ToolsToOpenAI(options.Tools) + if options.SystemPrompt != "" { + openaiMessages = append([]openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(options.SystemPrompt), + }, openaiMessages...) + } + return aisdk.OpenAIToDataStream(client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ Messages: openaiMessages, Model: options.Model, @@ -70,6 +78,11 @@ func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) if err != nil { return nil, err } + if options.SystemPrompt != "" { + systemMessage = []anthropic.TextBlockParam{ + *anthropic.NewTextBlock(options.SystemPrompt).OfRequestTextBlock, + } + } return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ Messages: anthropicMessages, Model: options.Model, @@ -106,8 +119,18 @@ func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) if err != nil { return nil, err } + var systemInstruction *genai.Content + if options.SystemPrompt != "" { + systemInstruction = &genai.Content{ + Parts: []*genai.Part{ + genai.NewPartFromText(options.SystemPrompt), + }, + Role: "model", + } + } return aisdk.GoogleToDataStream(client.Models.GenerateContentStream(ctx, options.Model, googleMessages, &genai.GenerateContentConfig{ - Tools: tools, + SystemInstruction: systemInstruction, + Tools: tools, })), nil } if config.Models == nil { @@ -122,7 +145,7 @@ func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) } break default: - return nil, fmt.Errorf("unsupported model type: %s", config.Type) + return nil, xerrors.Errorf("unsupported model type: %s", config.Type) } for _, model := range config.Models { diff --git a/coderd/chat.go b/coderd/chat.go index 1165031bda19d..66d7a20fce79a 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -2,9 +2,13 @@ package coderd import ( "encoding/json" + "io" "net/http" "time" + "github.com/google/uuid" + "github.com/kylecarbs/aisdk-go" + "github.com/coder/coder/v2/coderd/ai" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -12,11 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/codersdk" - codermcp "github.com/coder/coder/v2/mcp" - "github.com/google/uuid" - "github.com/kylecarbs/aisdk-go" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/coder/coder/v2/codersdk/toolsdk" ) // postChats creates a new chat. @@ -142,9 +142,10 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { Message: "Failed to get chat messages", Detail: err.Error(), }) + return } - messages := make([]aisdk.Message, len(dbMessages)) + messages := make([]aisdk.Message, 0, len(dbMessages)) for i, message := range dbMessages { err = json.Unmarshal(message.Content, &messages[i]) if err != nil { @@ -157,31 +158,17 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { } messages = append(messages, req.Message) - toolMap := codermcp.AllTools() - toolsByName := make(map[string]server.ToolHandlerFunc) client := codersdk.New(api.AccessURL) client.SetSessionToken(httpmw.APITokenFromRequest(r)) - toolDeps := codermcp.ToolDeps{ - Client: client, - Logger: &api.Logger, - } - for _, tool := range toolMap { - toolsByName[tool.Tool.Name] = tool.MakeHandler(toolDeps) - } - convertedTools := make([]aisdk.Tool, len(toolMap)) - for i, tool := range toolMap { - schema := aisdk.Schema{ - Required: tool.Tool.InputSchema.Required, - Properties: tool.Tool.InputSchema.Properties, - } - if tool.Tool.InputSchema.Required == nil { - schema.Required = []string{} - } - convertedTools[i] = aisdk.Tool{ - Name: tool.Tool.Name, - Description: tool.Tool.Description, - Schema: schema, + + tools := make([]aisdk.Tool, len(toolsdk.All)) + handlers := map[string]toolsdk.GenericHandlerFunc{} + for i, tool := range toolsdk.All { + if tool.Tool.Schema.Required == nil { + tool.Tool.Schema.Required = []string{} } + tools[i] = tool.Tool + handlers[tool.Tool.Name] = tool.Handler } provider, ok := api.LanguageModels[req.Model] @@ -192,6 +179,44 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { return } + // If it's the user's first message, generate a title for the chat. + if len(messages) == 1 { + var acc aisdk.DataStreamAccumulator + stream, err := provider.StreamFunc(ctx, ai.StreamOptions{ + Model: req.Model, + SystemPrompt: `- You will generate a short title based on the user's message. +- It should be maximum of 40 characters. +- Do not use quotes, colons, special characters, or emojis.`, + Messages: messages, + Tools: tools, + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create stream", + Detail: err.Error(), + }) + } + stream = stream.WithAccumulator(&acc) + err = stream.Pipe(io.Discard) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to pipe stream", + Detail: err.Error(), + }) + } + err = api.Database.UpdateChatByID(ctx, database.UpdateChatByIDParams{ + ID: chat.ID, + Title: acc.Messages()[0].Content, + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update chat title", + Detail: err.Error(), + }) + return + } + } + // Write headers for the data stream! aisdk.WriteDataStreamHeaders(w) @@ -219,12 +244,20 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { return } + deps := toolsdk.Deps{ + CoderClient: client, + } + for { var acc aisdk.DataStreamAccumulator stream, err := provider.StreamFunc(ctx, ai.StreamOptions{ Model: req.Model, Messages: messages, - Tools: convertedTools, + Tools: tools, + SystemPrompt: `You are a chat assistant for Coder. You will attempt to resolve the user's +request to the maximum utilization of your tools. + +Try your best to not ask the user for help - solve the task with your tools!`, }) if err != nil { httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ @@ -234,28 +267,21 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { return } stream = stream.WithToolCalling(func(toolCall aisdk.ToolCall) any { - tool, ok := toolsByName[toolCall.Name] + tool, ok := handlers[toolCall.Name] if !ok { return nil } - result, err := tool(ctx, mcp.CallToolRequest{ - Params: struct { - Name string "json:\"name\"" - Arguments map[string]interface{} "json:\"arguments,omitempty\"" - Meta *struct { - ProgressToken mcp.ProgressToken "json:\"progressToken,omitempty\"" - } "json:\"_meta,omitempty\"" - }{ - Name: toolCall.Name, - Arguments: toolCall.Args, - }, - }) + toolArgs, err := json.Marshal(toolCall.Args) + if err != nil { + return nil + } + result, err := tool(ctx, deps, toolArgs) if err != nil { return map[string]any{ "error": err.Error(), } } - return result.Content + return result }).WithAccumulator(&acc) err = stream.Pipe(w) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 7fc63995966b5..f55c4d3d96a12 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4000,7 +4000,10 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe } func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { - panic("not implemented") + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat.WithID(arg.ID)); err != nil { + return err + } + return q.db.UpdateChatByID(ctx, arg) } func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index e64f6d822e536..f140b0aa6a74f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8447,13 +8447,7 @@ func (q *FakeQuerier) InsertChat(ctx context.Context, arg database.InsertChatPar q.mutex.Lock() defer q.mutex.Unlock() - chat := database.Chat{ - ID: arg.ID, - CreatedAt: arg.CreatedAt, - UpdatedAt: arg.UpdatedAt, - OwnerID: arg.OwnerID, - Title: arg.Title, - } + chat := database.Chat(arg) q.chats = append(q.chats, chat) return chat, nil diff --git a/coderd/deployment.go b/coderd/deployment.go index eb01281db3f7a..60988aeb2ce5a 100644 --- a/coderd/deployment.go +++ b/coderd/deployment.go @@ -4,11 +4,12 @@ import ( "context" "net/http" + "github.com/kylecarbs/aisdk-go" + "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" - "github.com/kylecarbs/aisdk-go" ) // @Summary Get deployment config diff --git a/coderd/httpmw/chat.go b/coderd/httpmw/chat.go index 19a0aa874ce16..b6e25b49f2643 100644 --- a/coderd/httpmw/chat.go +++ b/coderd/httpmw/chat.go @@ -4,11 +4,12 @@ import ( "context" "net/http" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" ) type chatContextKey struct{} diff --git a/coderd/httpmw/chat_test.go b/coderd/httpmw/chat_test.go index b0302324b6dcd..a8bad05f33797 100644 --- a/coderd/httpmw/chat_test.go +++ b/coderd/httpmw/chat_test.go @@ -7,16 +7,17 @@ import ( "testing" "time" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/codersdk" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestExtractChat(t *testing.T) { diff --git a/go.mod b/go.mod index 8ff0ba1fa2376..a32f08998359c 100644 --- a/go.mod +++ b/go.mod @@ -487,10 +487,13 @@ require ( ) require ( + github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 github.com/coder/preview v0.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/kylecarbs/aisdk-go v0.0.5 github.com/mark3labs/mcp-go v0.23.1 + github.com/openai/openai-go v0.1.0-beta.6 + google.golang.org/genai v0.7.0 ) require ( @@ -502,7 +505,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect - github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/trivy v0.58.2 // indirect github.com/aws/aws-sdk-go v1.55.6 // indirect @@ -516,7 +518,6 @@ require ( github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/moby/sys/user v0.3.0 // indirect - github.com/openai/openai-go v0.1.0-beta.6 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/samber/lo v1.49.1 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect @@ -527,6 +528,5 @@ require ( go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect - google.golang.org/genai v0.7.0 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect ) diff --git a/site/package.json b/site/package.json index 7c7cc16848b26..39037740abf08 100644 --- a/site/package.json +++ b/site/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@ai-sdk/react": "1.2.6", + "@ai-sdk/ui-utils": "1.2.7", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@emotion/cache": "11.14.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 259f234a65517..e185310621747 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: '@ai-sdk/react': specifier: 1.2.6 version: 1.2.6(react@18.3.1)(zod@3.24.2) + '@ai-sdk/ui-utils': + specifier: 1.2.7 + version: 1.2.7(zod@3.24.2) '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 @@ -501,10 +504,20 @@ packages: peerDependencies: zod: ^3.23.8 + '@ai-sdk/provider-utils@2.2.6': + resolution: {integrity: sha512-sUlZ7Gnq84DCGWMQRIK8XVbkzIBnvPR1diV4v6JwPgpn5armnLI/j+rqn62MpLrU5ZCQZlDKl/Lw6ed3ulYqaA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.6.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + '@ai-sdk/provider@1.1.0': resolution: {integrity: sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.0.tgz} engines: {node: '>=18'} + '@ai-sdk/provider@1.1.2': + resolution: {integrity: sha512-ITdgNilJZwLKR7X5TnUr1BsQW6UTX5yFp0h66Nfx8XjBYkWD9W3yugr50GOz3CnE9m/U/Cd5OyEbTMI0rgi6ZQ==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.2.tgz} + engines: {node: '>=18'} + '@ai-sdk/react@1.2.6': resolution: {integrity: sha512-5BFChNbcYtcY9MBStcDev7WZRHf0NpTrk8yfSoedWctB3jfWkFd1HECBvdc8w3mUQshF2MumLHtAhRO7IFtGGQ==, tarball: https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.6.tgz} engines: {node: '>=18'} @@ -521,6 +534,12 @@ packages: peerDependencies: zod: ^3.23.8 + '@ai-sdk/ui-utils@1.2.7': + resolution: {integrity: sha512-OVRxa4SDj0wVsMZ8tGr/whT89oqNtNoXBKmqWC2BRv5ZG6azL2LYZ5ZK35u3lb4l1IE7cWGsLlmq0py0ttsL7A==, tarball: https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.7.tgz} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==, tarball: https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz} engines: {node: '>=10'} @@ -6519,10 +6538,21 @@ snapshots: secure-json-parse: 2.7.0 zod: 3.24.2 + '@ai-sdk/provider-utils@2.2.6(zod@3.24.2)': + dependencies: + '@ai-sdk/provider': 1.1.2 + nanoid: 3.3.8 + secure-json-parse: 2.7.0 + zod: 3.24.2 + '@ai-sdk/provider@1.1.0': dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@1.1.2': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/react@1.2.6(react@18.3.1)(zod@3.24.2)': dependencies: '@ai-sdk/provider-utils': 2.2.4(zod@3.24.2) @@ -6540,6 +6570,13 @@ snapshots: zod: 3.24.2 zod-to-json-schema: 3.24.5(zod@3.24.2) + '@ai-sdk/ui-utils@1.2.7(zod@3.24.2)': + dependencies: + '@ai-sdk/provider': 1.1.2 + '@ai-sdk/provider-utils': 2.2.6(zod@3.24.2) + zod: 3.24.2 + zod-to-json-schema: 3.24.5(zod@3.24.2) + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 0447e762ed67e..9b8f4b41c9efd 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -154,6 +154,14 @@ const NavItems: FC = ({ className }) => { > Templates + { + return cn(linkStyles.default, isActive ? linkStyles.active : ""); + }} + to="/chat" + > + Chat + ); }; diff --git a/site/src/pages/ChatPage/ChatMessages.tsx b/site/src/pages/ChatPage/ChatMessages.tsx index 1c411dc746b52..03248e3780041 100644 --- a/site/src/pages/ChatPage/ChatMessages.tsx +++ b/site/src/pages/ChatPage/ChatMessages.tsx @@ -49,7 +49,7 @@ const renderToolInvocation = (toolInvocation: any, theme: Theme) => ( >
= memo(({ message }) => { key={partIndex} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} + css={{ + "& pre": { + backgroundColor: theme.palette.background.default, + }, + }} > {part.text} diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/site/src/pages/ChatPage/ChatToolInvocation.tsx b/site/src/pages/ChatPage/ChatToolInvocation.tsx new file mode 100644 index 0000000000000..c123eebb2467c --- /dev/null +++ b/site/src/pages/ChatPage/ChatToolInvocation.tsx @@ -0,0 +1,78 @@ +import { FC } from "react"; +import type { ToolInvocation } from "@ai-sdk/ui-utils"; +import { useTheme } from "@emotion/react"; + +interface ChatToolInvocationProps { + toolInvocation: ToolInvocation; +} + +export const ChatToolInvocation: FC = ({ + toolInvocation, +}) => { + const theme = useTheme(); + return ( +
+
+ 🛠️ Tool Call: {toolInvocation.toolName} +
+
+
+ Arguments: +
+ {JSON.stringify(toolInvocation.args, null, 2)} +
+
+ {"result" in toolInvocation && ( +
+ Result: +
+ {JSON.stringify(toolInvocation.result, null, 2)} +
+
+ )} +
+
+ ); +}; From 113473621c99bdc6747cd1ebf51da11bcf28ba7f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 10 Apr 2025 11:20:53 -0400 Subject: [PATCH 04/18] Add tools --- site/package.json | 1 + site/pnpm-lock.yaml | 3 + site/src/pages/ChatPage/ChatMessages.tsx | 3 +- .../ChatPage/ChatToolInvocation.stories.tsx | 42 +++ .../src/pages/ChatPage/ChatToolInvocation.tsx | 280 ++++++++++++++---- 5 files changed, 275 insertions(+), 54 deletions(-) diff --git a/site/package.json b/site/package.json index 39037740abf08..29597a705cc0c 100644 --- a/site/package.json +++ b/site/package.json @@ -35,6 +35,7 @@ "update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis" }, "dependencies": { + "@ai-sdk/provider-utils": "2.2.6", "@ai-sdk/react": "1.2.6", "@ai-sdk/ui-utils": "1.2.7", "@emoji-mart/data": "1.2.1", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index e185310621747..42d1156937dfd 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: .: dependencies: + '@ai-sdk/provider-utils': + specifier: 2.2.6 + version: 2.2.6(zod@3.24.2) '@ai-sdk/react': specifier: 1.2.6 version: 1.2.6(react@18.3.1)(zod@3.24.2) diff --git a/site/src/pages/ChatPage/ChatMessages.tsx b/site/src/pages/ChatPage/ChatMessages.tsx index 03248e3780041..1aa16c718985e 100644 --- a/site/src/pages/ChatPage/ChatMessages.tsx +++ b/site/src/pages/ChatPage/ChatMessages.tsx @@ -17,6 +17,7 @@ import { Loader } from "components/Loader/Loader"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; +import { ChatToolInvocation } from "./ChatToolInvocation"; const fadeIn = keyframes` from { @@ -259,7 +260,7 @@ const MessageBubble: FC = memo(({ message }) => { case "tool-invocation": return (
- {renderToolInvocation(part.toolInvocation, theme)} +
); case "reasoning": diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx index e69de29bb2d1d..8fcb3270a0d42 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx @@ -0,0 +1,42 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { ChatToolInvocation } from "./ChatToolInvocation"; +import { MockWorkspace } from "testHelpers/entities"; + +const meta: Meta = { + title: "pages/ChatPage/ChatToolInvocation", + component: ChatToolInvocation, +}; + +export default meta; +type Story = StoryObj; + +export const GetWorkspace: Story = { + args: { + toolInvocation: { + toolName: "coder_get_workspace", + args: { + id: MockWorkspace.id, + }, + result: MockWorkspace, + state: "result", + toolCallId: "some-id", + }, + }, +}; + +export const CreateWorkspace: Story = { + args: { + toolInvocation: { + toolName: "coder_create_workspace", + args: { + name: MockWorkspace.name, + rich_parameters: {}, + template_version_id: MockWorkspace.template_active_version_id, + user: MockWorkspace.owner_name, + }, + result: MockWorkspace, + state: "result", + toolCallId: "some-id", + }, + }, +}; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.tsx b/site/src/pages/ChatPage/ChatToolInvocation.tsx index c123eebb2467c..e526599d6085e 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.tsx @@ -1,15 +1,78 @@ -import { FC } from "react"; -import type { ToolInvocation } from "@ai-sdk/ui-utils"; +import { FC, useMemo } from "react"; import { useTheme } from "@emotion/react"; +import type { ToolCall, ToolResult } from "@ai-sdk/provider-utils"; +import * as TypesGen from "api/typesGenerated"; +import CheckCircle from "@mui/icons-material/CheckCircle"; +import CircularProgress from "@mui/material/CircularProgress"; +import ErrorIcon from "@mui/icons-material/Error"; interface ChatToolInvocationProps { - toolInvocation: ToolInvocation; + toolInvocation: ChatToolInvocations; } export const ChatToolInvocation: FC = ({ toolInvocation, }) => { const theme = useTheme(); + + let preview: React.ReactNode; + switch (toolInvocation.toolName) { + case "coder_get_workspace": + switch (toolInvocation.state) { + case "partial-call": + case "call": + preview =
Getting Workspace By ID...
; + break; + case "result": + preview = ( +
+ {toolInvocation.result.name} +
+
{toolInvocation.result.name}
+
{toolInvocation.result.template_display_name}
+
+
+ ); + break; + } + break; + default: + switch (toolInvocation.state) { + case "partial-call": + case "call": + preview =
Running Tool...
; + break; + case "result": + preview =
{JSON.stringify(toolInvocation.result, null, 2)}
; + break; + } + } + + const hasError = useMemo(() => { + if (toolInvocation.state !== "result") { + return false; + } + return ( + typeof toolInvocation.result === "object" && + "error" in toolInvocation.result + ); + }, [toolInvocation]); + const statusColor = useMemo(() => { + if (toolInvocation.state !== "result") { + return theme.palette.primary.main; + } + return hasError ? theme.palette.error.main : theme.palette.success.main; + }, [toolInvocation, hasError]); + const friendlyName = useMemo(() => { + return toolInvocation.toolName + .replace("coder_", "") + .replace("_", " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + }, [toolInvocation.toolName]); + return (
= ({ marginLeft: theme.spacing(2), borderLeft: `2px solid ${theme.palette.info.light}`, paddingLeft: theme.spacing(1.5), - fontSize: "0.875em", - fontFamily: "monospace", + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), }} > -
- 🛠️ Tool Call: {toolInvocation.toolName} -
-
-
- Arguments: -
+ {toolInvocation.state !== "result" && ( + - {JSON.stringify(toolInvocation.args, null, 2)} -
-
- {"result" in toolInvocation && ( -
- Result: -
- {JSON.stringify(toolInvocation.result, null, 2)} -
-
+ /> )} + {toolInvocation.state === "result" ? ( + hasError ? ( + + ) : ( + + ) + ) : null} +
+ {friendlyName} +
+
{preview}
); }; + +export type ChatToolInvocations = + | ToolInvocation< + "coder_get_workspace", + { + id: string; + }, + TypesGen.Workspace + > + | ToolInvocation< + "coder_create_workspace", + { + user: string; + template_version_id: string; + name: string; + rich_parameters: Record; + }, + TypesGen.Workspace + > + | ToolInvocation< + "coder_list_workspaces", + { + owner: string; + }, + Pick< + TypesGen.Workspace, + | "id" + | "name" + | "template_id" + | "template_name" + | "template_display_name" + | "template_icon" + | "template_active_version_id" + | "outdated" + >[] + > + | ToolInvocation< + "coder_list_templates", + {}, + Pick< + TypesGen.Template, + | "id" + | "name" + | "description" + | "active_version_id" + | "active_user_count" + >[] + > + | ToolInvocation< + "coder_template_version_parameters", + { + template_version_id: string; + }, + TypesGen.TemplateVersionParameter[] + > + | ToolInvocation<"coder_get_authenticated_user", {}, TypesGen.User> + | ToolInvocation< + "coder_create_workspace_build", + { + workspace_id: string; + transition: "start" | "stop" | "delete"; + }, + TypesGen.WorkspaceBuild + > + | ToolInvocation< + "coder_create_template_version", + { + template_id?: string; + file_id: string; + }, + TypesGen.TemplateVersion + > + | ToolInvocation< + "coder_get_workspace_agent_logs", + { + workspace_agent_id: string; + }, + string[] + > + | ToolInvocation< + "coder_get_workspace_build_logs", + { + workspace_build_id: string; + }, + string[] + > + | ToolInvocation< + "coder_get_template_version_logs", + { + template_version_id: string; + }, + string[] + > + | ToolInvocation< + "coder_update_template_active_version", + { + template_id: string; + template_version_id: string; + }, + string + > + | ToolInvocation< + "coder_upload_tar_file", + { + mime_type: string; + files: Record; + }, + TypesGen.UploadResponse + > + | ToolInvocation< + "coder_create_template", + { + name: string; + }, + TypesGen.Template + > + | ToolInvocation< + "coder_delete_template", + { + template_id: string; + }, + string + >; + +type ToolInvocation = + | ({ + state: "partial-call"; + step?: number; + } & ToolCall) + | ({ + state: "call"; + step?: number; + } & ToolCall) + | ({ + state: "result"; + step?: number; + } & ToolResult); From 6a583e090e7c3fdc5fd8be97d87f006bf4c29999 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 10 Apr 2025 11:56:53 -0400 Subject: [PATCH 05/18] Add tool invocation stories stuff --- .../src/pages/ChatPage/ChatToolInvocation.tsx | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/site/src/pages/ChatPage/ChatToolInvocation.tsx b/site/src/pages/ChatPage/ChatToolInvocation.tsx index e526599d6085e..2ea5c5351258e 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.tsx @@ -5,6 +5,8 @@ import * as TypesGen from "api/typesGenerated"; import CheckCircle from "@mui/icons-material/CheckCircle"; import CircularProgress from "@mui/material/CircularProgress"; import ErrorIcon from "@mui/icons-material/Error"; +import CodeIcon from "@mui/icons-material/Code"; +import ArticleIcon from "@mui/icons-material/Article"; interface ChatToolInvocationProps { toolInvocation: ChatToolInvocations; @@ -14,6 +16,12 @@ export const ChatToolInvocation: FC = ({ toolInvocation, }) => { const theme = useTheme(); + const friendlyName = useMemo(() => { + return toolInvocation.toolName + .replace("coder_", "") + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + }, [toolInvocation.toolName]); let preview: React.ReactNode; switch (toolInvocation.toolName) { @@ -21,18 +29,31 @@ export const ChatToolInvocation: FC = ({ switch (toolInvocation.state) { case "partial-call": case "call": - preview =
Getting Workspace By ID...
; + preview = ( +
+ + Fetching workspace details... +
+ ); break; case "result": preview = ( -
+
{toolInvocation.result.name}
-
{toolInvocation.result.name}
-
{toolInvocation.result.template_display_name}
+
{toolInvocation.result.name}
+
+ {toolInvocation.result.template_display_name} +
); @@ -43,10 +64,20 @@ export const ChatToolInvocation: FC = ({ switch (toolInvocation.state) { case "partial-call": case "call": - preview =
Running Tool...
; + preview = ( +
+ + Executing {friendlyName}... +
+ ); break; case "result": - preview =
{JSON.stringify(toolInvocation.result, null, 2)}
; + preview = ( +
+ + {friendlyName} result received. +
+ ); break; } } @@ -57,35 +88,30 @@ export const ChatToolInvocation: FC = ({ } return ( typeof toolInvocation.result === "object" && + toolInvocation.result !== null && "error" in toolInvocation.result ); }, [toolInvocation]); const statusColor = useMemo(() => { if (toolInvocation.state !== "result") { - return theme.palette.primary.main; + return theme.palette.info.main; } return hasError ? theme.palette.error.main : theme.palette.success.main; - }, [toolInvocation, hasError]); - const friendlyName = useMemo(() => { - return toolInvocation.toolName - .replace("coder_", "") - .replace("_", " ") - .replace(/\b\w/g, (char) => char.toUpperCase()); - }, [toolInvocation.toolName]); + }, [toolInvocation, hasError, theme]); return (
-
+
{toolInvocation.state !== "result" && ( = ({
{friendlyName}
-
{preview}
+
{preview}
); }; @@ -120,7 +149,7 @@ export type ChatToolInvocations = { id: string; }, - TypesGen.Workspace + TypesGen.Workspace & { error?: any } > | ToolInvocation< "coder_create_workspace", @@ -130,7 +159,7 @@ export type ChatToolInvocations = name: string; rich_parameters: Record; }, - TypesGen.Workspace + TypesGen.Workspace & { error?: any } > | ToolInvocation< "coder_list_workspaces", @@ -147,7 +176,7 @@ export type ChatToolInvocations = | "template_icon" | "template_active_version_id" | "outdated" - >[] + >[] & { error?: any } > | ToolInvocation< "coder_list_templates", @@ -159,23 +188,23 @@ export type ChatToolInvocations = | "description" | "active_version_id" | "active_user_count" - >[] + >[] & { error?: any } > | ToolInvocation< "coder_template_version_parameters", { template_version_id: string; }, - TypesGen.TemplateVersionParameter[] + TypesGen.TemplateVersionParameter[] & { error?: any } > - | ToolInvocation<"coder_get_authenticated_user", {}, TypesGen.User> + | ToolInvocation<"coder_get_authenticated_user", {}, TypesGen.User & { error?: any }> | ToolInvocation< "coder_create_workspace_build", { workspace_id: string; transition: "start" | "stop" | "delete"; }, - TypesGen.WorkspaceBuild + TypesGen.WorkspaceBuild & { error?: any } > | ToolInvocation< "coder_create_template_version", @@ -183,28 +212,28 @@ export type ChatToolInvocations = template_id?: string; file_id: string; }, - TypesGen.TemplateVersion + TypesGen.TemplateVersion & { error?: any } > | ToolInvocation< "coder_get_workspace_agent_logs", { workspace_agent_id: string; }, - string[] + string[] & { error?: any } > | ToolInvocation< "coder_get_workspace_build_logs", { workspace_build_id: string; }, - string[] + string[] & { error?: any } > | ToolInvocation< "coder_get_template_version_logs", { template_version_id: string; }, - string[] + string[] & { error?: any } > | ToolInvocation< "coder_update_template_active_version", @@ -212,7 +241,7 @@ export type ChatToolInvocations = template_id: string; template_version_id: string; }, - string + string & { error?: any } > | ToolInvocation< "coder_upload_tar_file", @@ -220,22 +249,23 @@ export type ChatToolInvocations = mime_type: string; files: Record; }, - TypesGen.UploadResponse + TypesGen.UploadResponse & { error?: any } > | ToolInvocation< "coder_create_template", { name: string; }, - TypesGen.Template + TypesGen.Template & { error?: any } > | ToolInvocation< "coder_delete_template", { template_id: string; }, - string - >; + string & { error?: any } + > + | ToolInvocation, any & { error?: any }>; type ToolInvocation = | ({ @@ -249,4 +279,4 @@ type ToolInvocation = | ({ state: "result"; step?: number; - } & ToolResult); + } & ToolResult); From 725d738ba34c58ff31622f89105a875558054e06 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 16 Apr 2025 11:18:48 -0400 Subject: [PATCH 06/18] Add more tools --- .../ChatPage/ChatToolInvocation.stories.tsx | 104 +++++++-- .../src/pages/ChatPage/ChatToolInvocation.tsx | 209 +++++++++++------- 2 files changed, 210 insertions(+), 103 deletions(-) diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx index 8fcb3270a0d42..be01b705b8261 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx @@ -1,6 +1,11 @@ import { Meta, StoryObj } from "@storybook/react"; import { ChatToolInvocation } from "./ChatToolInvocation"; -import { MockWorkspace } from "testHelpers/entities"; +import { + MockStartingWorkspace, + MockStoppedWorkspace, + MockStoppingWorkspace, + MockWorkspace, +} from "testHelpers/entities"; const meta: Meta = { title: "pages/ChatPage/ChatToolInvocation", @@ -11,32 +16,93 @@ export default meta; type Story = StoryObj; export const GetWorkspace: Story = { - args: { - toolInvocation: { - toolName: "coder_get_workspace", - args: { + render: () => + renderInvocations( + "coder_get_workspace", + { id: MockWorkspace.id, }, - result: MockWorkspace, - state: "result", - toolCallId: "some-id", - }, - }, + MockWorkspace, + ), }; export const CreateWorkspace: Story = { - args: { - toolInvocation: { - toolName: "coder_create_workspace", - args: { + render: () => + renderInvocations( + "coder_create_workspace", + { name: MockWorkspace.name, rich_parameters: {}, template_version_id: MockWorkspace.template_active_version_id, user: MockWorkspace.owner_name, }, - result: MockWorkspace, - state: "result", - toolCallId: "some-id", - }, - }, + MockWorkspace, + ), +}; + +export const ListWorkspaces: Story = { + render: () => + renderInvocations( + "coder_list_workspaces", + { + owner: "me", + }, + [ + MockWorkspace, + MockStoppedWorkspace, + MockStoppingWorkspace, + MockStartingWorkspace, + ], + ), +}; + +const renderInvocations = ( + toolName: T, + args: Extract["args"], + result: Extract< + ChatToolInvocation, + { toolName: T; state: "result" } + >["result"], + error?: string, +) => { + return ( + <> + + + + + + ); }; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.tsx b/site/src/pages/ChatPage/ChatToolInvocation.tsx index 2ea5c5351258e..bdcc47eb8c740 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.tsx @@ -1,4 +1,4 @@ -import { FC, useMemo } from "react"; +import React, { FC, useMemo } from "react"; import { useTheme } from "@emotion/react"; import type { ToolCall, ToolResult } from "@ai-sdk/provider-utils"; import * as TypesGen from "api/typesGenerated"; @@ -7,9 +7,11 @@ import CircularProgress from "@mui/material/CircularProgress"; import ErrorIcon from "@mui/icons-material/Error"; import CodeIcon from "@mui/icons-material/Code"; import ArticleIcon from "@mui/icons-material/Article"; +import { Tooltip } from "@mui/material"; +import { InfoIcon } from "lucide-react"; interface ChatToolInvocationProps { - toolInvocation: ChatToolInvocations; + toolInvocation: ChatToolInvocation; } export const ChatToolInvocation: FC = ({ @@ -23,65 +25,6 @@ export const ChatToolInvocation: FC = ({ .replace(/\b\w/g, (char) => char.toUpperCase()); }, [toolInvocation.toolName]); - let preview: React.ReactNode; - switch (toolInvocation.toolName) { - case "coder_get_workspace": - switch (toolInvocation.state) { - case "partial-call": - case "call": - preview = ( -
- - Fetching workspace details... -
- ); - break; - case "result": - preview = ( -
- {toolInvocation.result.template_display_name -
-
{toolInvocation.result.name}
-
- {toolInvocation.result.template_display_name} -
-
-
- ); - break; - } - break; - default: - switch (toolInvocation.state) { - case "partial-call": - case "call": - preview = ( -
- - Executing {friendlyName}... -
- ); - break; - case "result": - preview = ( -
- - {friendlyName} result received. -
- ); - break; - } - } - const hasError = useMemo(() => { if (toolInvocation.state !== "result") { return false; @@ -109,9 +52,12 @@ export const ChatToolInvocation: FC = ({ display: "flex", flexDirection: "column", gap: theme.spacing(0.75), + width: "fit-content", }} > -
+
{toolInvocation.state !== "result" && ( = ({ ) : null}
{friendlyName}
+ + +
-
{preview}
+ {toolInvocation.state === "result" && ( + + )} +
+ ); +}; + +const ChatToolInvocationResultPreview: FC<{ + toolInvocation: Extract; +}> = ({ toolInvocation }) => { + const theme = useTheme(); + + if ( + typeof toolInvocation.result === "object" && + "error" in toolInvocation.result + ) { + return null; + } + + let content: React.ReactNode; + switch (toolInvocation.toolName) { + case "coder_get_workspace": + case "coder_create_workspace": + content = ( +
+ {toolInvocation.result.template_icon && ( + {toolInvocation.result.template_display_name + )} +
+
+ {toolInvocation.result.name} +
+
+ {toolInvocation.result.template_display_name} +
+
+
+ ); + break; + case "coder_list_workspaces": + content = ( +
+ {toolInvocation.result.map((workspace) => ( +
+ {workspace.template_display_name + {workspace.name} +
+ ))} +
+ ); + } + return ( +
+ {content}
); }; -export type ChatToolInvocations = +export type ChatToolInvocation = | ToolInvocation< "coder_get_workspace", { id: string; }, - TypesGen.Workspace & { error?: any } + TypesGen.Workspace > | ToolInvocation< "coder_create_workspace", @@ -159,7 +194,7 @@ export type ChatToolInvocations = name: string; rich_parameters: Record; }, - TypesGen.Workspace & { error?: any } + TypesGen.Workspace > | ToolInvocation< "coder_list_workspaces", @@ -176,7 +211,7 @@ export type ChatToolInvocations = | "template_icon" | "template_active_version_id" | "outdated" - >[] & { error?: any } + >[] > | ToolInvocation< "coder_list_templates", @@ -188,23 +223,23 @@ export type ChatToolInvocations = | "description" | "active_version_id" | "active_user_count" - >[] & { error?: any } + >[] > | ToolInvocation< "coder_template_version_parameters", { template_version_id: string; }, - TypesGen.TemplateVersionParameter[] & { error?: any } + TypesGen.TemplateVersionParameter[] > - | ToolInvocation<"coder_get_authenticated_user", {}, TypesGen.User & { error?: any }> + | ToolInvocation<"coder_get_authenticated_user", {}, TypesGen.User> | ToolInvocation< "coder_create_workspace_build", { workspace_id: string; transition: "start" | "stop" | "delete"; }, - TypesGen.WorkspaceBuild & { error?: any } + TypesGen.WorkspaceBuild > | ToolInvocation< "coder_create_template_version", @@ -212,28 +247,28 @@ export type ChatToolInvocations = template_id?: string; file_id: string; }, - TypesGen.TemplateVersion & { error?: any } + TypesGen.TemplateVersion > | ToolInvocation< "coder_get_workspace_agent_logs", { workspace_agent_id: string; }, - string[] & { error?: any } + string[] > | ToolInvocation< "coder_get_workspace_build_logs", { workspace_build_id: string; }, - string[] & { error?: any } + string[] > | ToolInvocation< "coder_get_template_version_logs", { template_version_id: string; }, - string[] & { error?: any } + string[] > | ToolInvocation< "coder_update_template_active_version", @@ -241,7 +276,7 @@ export type ChatToolInvocations = template_id: string; template_version_id: string; }, - string & { error?: any } + string > | ToolInvocation< "coder_upload_tar_file", @@ -249,23 +284,22 @@ export type ChatToolInvocations = mime_type: string; files: Record; }, - TypesGen.UploadResponse & { error?: any } + TypesGen.UploadResponse > | ToolInvocation< "coder_create_template", { name: string; }, - TypesGen.Template & { error?: any } + TypesGen.Template > | ToolInvocation< "coder_delete_template", { template_id: string; }, - string & { error?: any } - > - | ToolInvocation, any & { error?: any }>; + string + >; type ToolInvocation = | ({ @@ -279,4 +313,11 @@ type ToolInvocation = | ({ state: "result"; step?: number; - } & ToolResult); + } & ToolResult< + N, + A, + | R + | { + error: string; + } + >); From 9068d8b271c8d4c2eb2e8b31fc70312b27c041d8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 18 Apr 2025 21:59:39 -0400 Subject: [PATCH 07/18] Improve chat --- coderd/chat.go | 14 +- codersdk/toolsdk/toolsdk.go | 9 +- site/src/pages/ChatPage/ChatMessages.tsx | 86 +- .../ChatPage/ChatToolInvocation.stories.tsx | 1095 +++++++++++++++++ .../src/pages/ChatPage/ChatToolInvocation.tsx | 597 ++++++++- 5 files changed, 1726 insertions(+), 75 deletions(-) diff --git a/coderd/chat.go b/coderd/chat.go index 66d7a20fce79a..eef5cd0bf6939 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -244,8 +244,13 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { return } - deps := toolsdk.Deps{ - CoderClient: client, + deps, err := toolsdk.NewDeps(client) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create tool dependencies", + Detail: err.Error(), + }) + return } for { @@ -254,10 +259,9 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { Model: req.Model, Messages: messages, Tools: tools, - SystemPrompt: `You are a chat assistant for Coder. You will attempt to resolve the user's -request to the maximum utilization of your tools. + SystemPrompt: `You are a chat assistant for Coder - an open-source platform for creating and managing cloud development environments on any infrastructure. You are expected to be precise, concise, and helpful. -Try your best to not ask the user for help - solve the task with your tools!`, +You are running as an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Do NOT guess or make up an answer.`, }) if err != nil { httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 166bde730efc5..985475d211fa3 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -591,7 +591,7 @@ This resource provides the following fields: - init_script: The script to run on provisioned infrastructure to fetch and start the agent. - token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent. -The agent MUST be installed and started using the init_script. +The agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure. Expose terminal or HTTP applications running in a workspace with: @@ -711,13 +711,20 @@ resource "google_compute_instance" "dev" { auto_delete = false source = google_compute_disk.root.name } + // In order to use google-instance-identity, a service account *must* be provided. service_account { email = data.google_compute_default_service_account.default.email scopes = ["cloud-platform"] } + # ONLY FOR WINDOWS: + # metadata = { + # windows-startup-script-ps1 = coder_agent.main.init_script + # } # The startup script runs as root with no $HOME environment set up, so instead of directly # running the agent init script, create a user (with a homedir, default shell and sudo # permissions) and execute the init script as that user. + # + # The agent MUST be started in here. metadata_startup_script = <; - hhandleSubmit: (e?: React.FormEvent) => void; + handleSubmit: (e?: React.FormEvent) => void; isLoading: boolean; chatID: string; } @@ -300,7 +300,7 @@ const ChatView: FC = ({ messages, input, handleInputChange, - hhandleSubmit, + handleSubmit, isLoading, chatID }) => { @@ -322,7 +322,7 @@ const ChatView: FC = ({ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); - hhandleSubmit(); + handleSubmit(); } }; @@ -355,34 +355,6 @@ const ChatView: FC = ({ {messages.map((message, index) => ( ))} - {isLoading && ( -
- - Thinking... - -
- )}
@@ -400,7 +372,7 @@ const ChatView: FC = ({ > { const { messages, input, - handleInputChange, + handleInputChange: originalHandleInputChange, handleSubmit: originalHandleSubmit, isLoading, - setInput, + setInput, + setMessages, } = useChat({ id: chatID, api: `/api/v2/chats/${chatID}/messages`, @@ -502,22 +475,39 @@ export const ChatMessages: FC = () => { initialInput: transferedState?.message, initialMessages: messagesQuery.data as Message[] | undefined, }); - useEffect(() => { - // console.log(transferedState?.message, input) - if (transferedState?.message && input === transferedState?.message) { - // handleSubmit(); - } - }, [transferedState?.message]) - const handleSubmit = (e?: React.FormEvent) => { - if (e) e.preventDefault(); - if (!input.trim()) return; - originalHandleSubmit(); - setInput(''); - }; + // Update messages from query data when it loads + useEffect(() => { + if (messagesQuery.data && messages.length === 0) { + setMessages(messagesQuery.data as Message[]); + } + }, [messagesQuery.data, messages.length, setMessages]); + + // Wrap handlers in useCallback + const handleInputChange = useCallback(originalHandleInputChange, [originalHandleInputChange]); + + const handleSubmitCallback = useCallback((e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!input.trim()) return; + originalHandleSubmit(); + setInput(''); // Clear input after submit + }, [input, originalHandleSubmit, setInput]); + + // Clear input and potentially submit on initial load with message + useEffect(() => { + if (transferedState?.message && input === transferedState.message) { + // Prevent submitting if messages already exist (e.g., browser back/forward) + if (messages.length === (messagesQuery.data?.length ?? 0)) { + handleSubmitCallback(); // Use the correct callback name + } + // Clear the state to prevent re-submission on subsequent renders/navigation + window.history.replaceState({}, document.title); + } + }, [transferedState?.message, input, handleSubmitCallback, messages.length, messagesQuery.data?.length]); // Use the correct callback name useEffect(() => { if (transferedState?.message) { + // Logic potentially related to transferedState can go here if needed, } }, [transferedState?.message]); @@ -536,7 +526,7 @@ export const ChatMessages: FC = () => { messages={messages} input={input} handleInputChange={handleInputChange} - hhandleSubmit={handleSubmit} + handleSubmit={handleSubmitCallback} isLoading={isLoading} /> ); diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx index be01b705b8261..0e6e9259edb9b 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx @@ -4,7 +4,11 @@ import { MockStartingWorkspace, MockStoppedWorkspace, MockStoppingWorkspace, + MockTemplate, + MockTemplateVersion, + MockUser, MockWorkspace, + MockWorkspaceBuild, } from "testHelpers/entities"; const meta: Meta = { @@ -56,6 +60,206 @@ export const ListWorkspaces: Story = { ), }; +export const ListTemplates: Story = { + render: () => + renderInvocations("coder_list_templates", {}, [ + { + id: MockTemplate.id, + name: MockTemplate.name, + description: MockTemplate.description, + active_version_id: MockTemplate.active_version_id, + active_user_count: MockTemplate.active_user_count, + }, + { + id: "another-template", + name: "Another Template", + description: "A different template for testing purposes.", + active_version_id: "v2.0", + active_user_count: 5, + }, + ]), +}; + +export const TemplateVersionParameters: Story = { + render: () => + renderInvocations( + "coder_template_version_parameters", + { + template_version_id: MockTemplateVersion.id, + }, + [ + { + name: "region", + display_name: "Region", + description: "Select the deployment region.", + description_plaintext: "Select the deployment region.", + type: "string", + mutable: false, + default_value: "us-west-1", + icon: "", + options: [ + { name: "US West", description: "", value: "us-west-1", icon: "" }, + { name: "US East", description: "", value: "us-east-1", icon: "" }, + ], + required: true, + ephemeral: false, + }, + { + name: "cpu_cores", + display_name: "CPU Cores", + description: "Number of CPU cores.", + description_plaintext: "Number of CPU cores.", + type: "number", + mutable: true, + default_value: "4", + icon: "", + options: [], + required: false, + ephemeral: false, + }, + ], + ), +}; + +export const GetAuthenticatedUser: Story = { + render: () => renderInvocations("coder_get_authenticated_user", {}, MockUser), +}; + +export const CreateWorkspaceBuild: Story = { + render: () => + renderInvocations( + "coder_create_workspace_build", + { + workspace_id: MockWorkspace.id, + transition: "start", + }, + MockWorkspaceBuild, + ), +}; + +export const CreateTemplateVersion: Story = { + render: () => + renderInvocations( + "coder_create_template_version", + { + template_id: MockTemplate.id, + file_id: "file-123", + }, + MockTemplateVersion, + ), +}; + +const mockLogs = [ + "[INFO] Starting build process...", + "[DEBUG] Reading configuration file.", + "[WARN] Deprecated setting detected.", + "[INFO] Applying changes...", + "[ERROR] Failed to connect to database.", +]; + +export const GetWorkspaceAgentLogs: Story = { + render: () => + renderInvocations( + "coder_get_workspace_agent_logs", + { + workspace_agent_id: "agent-456", + }, + mockLogs, + ), +}; + +export const GetWorkspaceBuildLogs: Story = { + render: () => + renderInvocations( + "coder_get_workspace_build_logs", + { + workspace_build_id: MockWorkspaceBuild.id, + }, + mockLogs, + ), +}; + +export const GetTemplateVersionLogs: Story = { + render: () => + renderInvocations( + "coder_get_template_version_logs", + { + template_version_id: MockTemplateVersion.id, + }, + mockLogs, + ), +}; + +export const UpdateTemplateActiveVersion: Story = { + render: () => + renderInvocations( + "coder_update_template_active_version", + { + template_id: MockTemplate.id, + template_version_id: MockTemplateVersion.id, + }, + `Successfully updated active version for template ${MockTemplate.name}.`, + ), +}; + +export const UploadTarFile: Story = { + render: () => + renderInvocations( + "coder_upload_tar_file", + { + mime_type: "application/x-tar", + files: { "main.tf": templateTerraform, "Dockerfile": templateDockerfile }, + }, + { + hash: "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + }, + ), +}; + +export const CreateTemplate: Story = { + render: () => + renderInvocations( + "coder_create_template", + { + name: "new-template", + }, + MockTemplate, + ), +}; + +export const DeleteTemplate: Story = { + render: () => + renderInvocations( + "coder_delete_template", + { + template_id: MockTemplate.id, + }, + `Successfully deleted template ${MockTemplate.name}.`, + ), +}; + +export const GetTemplateVersion: Story = { + render: () => + renderInvocations( + "coder_get_template_version", + { + template_version_id: MockTemplateVersion.id, + }, + MockTemplateVersion, + ), +}; + +export const DownloadTarFile: Story = { + render: () => + renderInvocations( + "coder_download_tar_file", + { + file_id: "file-789", + }, + { "main.tf": templateTerraform, "README.md": "# My Template\n" }, + ), +}; + const renderInvocations = ( toolName: T, args: Extract["args"], @@ -106,3 +310,894 @@ const renderInvocations = ( ); }; + +const templateDockerfile = `FROM rust:slim@sha256:9abf10cc84dfad6ace1b0aae3951dc5200f467c593394288c11db1e17bb4d349 AS rust-utils +# Install rust helper programs +# ENV CARGO_NET_GIT_FETCH_WITH_CLI=true +ENV CARGO_INSTALL_ROOT=/tmp/ +RUN cargo install typos-cli watchexec-cli && \ + # Reduce image size. + rm -rf /usr/local/cargo/registry + +FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 AS go + +# Install Go manually, so that we can control the version +ARG GO_VERSION=1.24.1 + +# Boring Go is needed to build FIPS-compliant binaries. +RUN apt-get update && \ + apt-get install --yes curl && \ + curl --silent --show-error --location \ + "https://go.dev/dl/go\${GO_VERSION}.linux-amd64.tar.gz" \ + -o /usr/local/go.tar.gz && \ + rm -rf /var/lib/apt/lists/* + +ENV PATH=$PATH:/usr/local/go/bin +ARG GOPATH="/tmp/" +# Install Go utilities. +RUN apt-get update && \ + apt-get install --yes gcc && \ + mkdir --parents /usr/local/go && \ + tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 && \ + mkdir --parents "$GOPATH" && \ + # moq for Go tests. + go install github.com/matryer/moq@v0.2.3 && \ + # swag for Swagger doc generation + go install github.com/swaggo/swag/cmd/swag@v1.7.4 && \ + # go-swagger tool to generate the go coder api client + go install github.com/go-swagger/go-swagger/cmd/swagger@v0.28.0 && \ + # goimports for updating imports + go install golang.org/x/tools/cmd/goimports@v0.31.0 && \ + # protoc-gen-go is needed to build sysbox from source + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 && \ + # drpc support for v2 + go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 && \ + # migrate for migration support for v2 + go install github.com/golang-migrate/migrate/v4/cmd/migrate@v4.15.1 && \ + # goreleaser for compiling v2 binaries + go install github.com/goreleaser/goreleaser@v1.6.1 && \ + # Install the latest version of gopls for editors that support + # the language server protocol + go install golang.org/x/tools/gopls@v0.18.1 && \ + # gotestsum makes test output more readable + go install gotest.tools/gotestsum@v1.9.0 && \ + # goveralls collects code coverage metrics from tests + # and sends to Coveralls + go install github.com/mattn/goveralls@v0.0.11 && \ + # kind for running Kubernetes-in-Docker, needed for tests + go install sigs.k8s.io/kind@v0.10.0 && \ + # helm-docs generates our Helm README based on a template and the + # charts and values files + go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.5.0 && \ + # sqlc for Go code generation + (CGO_ENABLED=1 go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.27.0) && \ + # gcr-cleaner-cli used by CI to prune unused images + go install github.com/sethvargo/gcr-cleaner/cmd/gcr-cleaner-cli@v0.5.1 && \ + # ruleguard for checking custom rules, without needing to run all of + # golangci-lint. Check the go.mod in the release of golangci-lint that + # we're using for the version of go-critic that it embeds, then check + # the version of ruleguard in go-critic for that tag. + go install github.com/quasilyte/go-ruleguard/cmd/ruleguard@v0.3.13 && \ + # go-releaser for building 'fat binaries' that work cross-platform + go install github.com/goreleaser/goreleaser@v1.6.1 && \ + go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0 && \ + # nfpm is used with \`make build\` to make release packages + go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1 && \ + # yq v4 is used to process yaml files in coder v2. Conflicts with + # yq v3 used in v1. + go install github.com/mikefarah/yq/v4@v4.44.3 && \ + mv /tmp/bin/yq /tmp/bin/yq4 && \ + go install go.uber.org/mock/mockgen@v0.5.0 && \ + # Reduce image size. + apt-get remove --yes gcc && \ + apt-get autoremove --yes && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + rm -rf /usr/local/go && \ + rm -rf /tmp/go/pkg && \ + rm -rf /tmp/go/src + +# alpine:3.18 +FROM gcr.io/coder-dev-1/alpine@sha256:25fad2a32ad1f6f510e528448ae1ec69a28ef81916a004d3629874104f8a7f70 AS proto +WORKDIR /tmp +RUN apk add curl unzip +RUN curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip && \ + unzip protoc.zip && \ + rm protoc.zip + +FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 + +SHELL ["/bin/bash", "-c"] + +# Install packages from apt repositories +ARG DEBIAN_FRONTEND="noninteractive" + +# Updated certificates are necessary to use the teraswitch mirror. +# This must be ran before copying in configuration since the config replaces +# the default mirror with teraswitch. +# Also enable the en_US.UTF-8 locale so that we don't generate multiple locales +# and unminimize to include man pages. +RUN apt-get update && \ + apt-get install --yes ca-certificates locales && \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ + locale-gen && \ + yes | unminimize + +COPY files / + +# We used to copy /etc/sudoers.d/* in from files/ but this causes issues with +# permissions and layer caching. Instead, create the file directly. +RUN mkdir -p /etc/sudoers.d && \ + echo 'coder ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/nopasswd && \ + chmod 750 /etc/sudoers.d/ && \ + chmod 640 /etc/sudoers.d/nopasswd + +RUN apt-get update --quiet && apt-get install --yes \ + ansible \ + apt-transport-https \ + apt-utils \ + asciinema \ + bash \ + bash-completion \ + bat \ + bats \ + bind9-dnsutils \ + build-essential \ + ca-certificates \ + cargo \ + cmake \ + containerd.io \ + crypto-policies \ + curl \ + docker-ce \ + docker-ce-cli \ + docker-compose-plugin \ + exa \ + fd-find \ + file \ + fish \ + gettext-base \ + git \ + gnupg \ + google-cloud-sdk \ + google-cloud-sdk-datastore-emulator \ + graphviz \ + helix \ + htop \ + httpie \ + inetutils-tools \ + iproute2 \ + iputils-ping \ + iputils-tracepath \ + jq \ + kubectl \ + language-pack-en \ + less \ + libgbm-dev \ + libssl-dev \ + lsb-release \ + lsof \ + man \ + meld \ + ncdu \ + neovim \ + net-tools \ + openjdk-11-jdk-headless \ + openssh-server \ + openssl \ + packer \ + pkg-config \ + postgresql-16 \ + python3 \ + python3-pip \ + ripgrep \ + rsync \ + screen \ + shellcheck \ + strace \ + sudo \ + tcptraceroute \ + termshark \ + traceroute \ + unzip \ + vim \ + wget \ + xauth \ + zip \ + zsh \ + zstd && \ + # Delete package cache to avoid consuming space in layer + apt-get clean && \ + # Configure FIPS-compliant policies + update-crypto-policies --set FIPS + +# NOTE: In scripts/Dockerfile.base we specifically install Terraform version 1.11.3. +# Installing the same version here to match. +RUN wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.11.3/terraform_1.11.3_linux_amd64.zip" && \ + unzip /tmp/terraform.zip -d /usr/local/bin && \ + rm -f /tmp/terraform.zip && \ + chmod +x /usr/local/bin/terraform && \ + terraform --version + +# Install the docker buildx component. +RUN DOCKER_BUILDX_VERSION=$(curl -s "https://api.github.com/repos/docker/buildx/releases/latest" | grep '"tag_name":' | sed -E 's/.*"(v[^"]+)".*/\\1/') && \ + mkdir -p /usr/local/lib/docker/cli-plugins && \ + curl -Lo /usr/local/lib/docker/cli-plugins/docker-buildx "https://github.com/docker/buildx/releases/download/\${DOCKER_BUILDX_VERSION}/buildx-\${DOCKER_BUILDX_VERSION}.linux-amd64" && \ + chmod a+x /usr/local/lib/docker/cli-plugins/docker-buildx + +# See https://github.com/cli/cli/issues/6175#issuecomment-1235984381 for proof +# the apt repository is unreliable +RUN GH_CLI_VERSION=$(curl -s "https://api.github.com/repos/cli/cli/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\\1/') && \ + curl -L https://github.com/cli/cli/releases/download/v\${GH_CLI_VERSION}/gh_\${GH_CLI_VERSION}_linux_amd64.deb -o gh.deb && \ + dpkg -i gh.deb && \ + rm gh.deb + +# Install Lazygit +# See https://github.com/jesseduffield/lazygit#ubuntu +RUN LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v*([^"]+)".*/\\1/') && \ + curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_\${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" && \ + tar xf lazygit.tar.gz -C /usr/local/bin lazygit && \ + rm lazygit.tar.gz + +# Install doctl +# See https://docs.digitalocean.com/reference/doctl/how-to/install +RUN DOCTL_VERSION=$(curl -s "https://api.github.com/repos/digitalocean/doctl/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\\1/') && \ + curl -L https://github.com/digitalocean/doctl/releases/download/v\${DOCTL_VERSION}/doctl-\${DOCTL_VERSION}-linux-amd64.tar.gz -o doctl.tar.gz && \ + tar xf doctl.tar.gz -C /usr/local/bin doctl && \ + rm doctl.tar.gz + +ARG NVM_INSTALL_SHA=bdea8c52186c4dd12657e77e7515509cda5bf9fa5a2f0046bce749e62645076d +# Install frontend utilities +ENV NVM_DIR=/usr/local/nvm +ENV NODE_VERSION=20.16.0 +RUN mkdir -p $NVM_DIR +RUN curl -o nvm_install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh && \ + echo "\${NVM_INSTALL_SHA} nvm_install.sh" | sha256sum -c && \ + bash nvm_install.sh && \ + rm nvm_install.sh +RUN source $NVM_DIR/nvm.sh && \ + nvm install $NODE_VERSION && \ + nvm use $NODE_VERSION +ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH +# Allow patch updates for npm and pnpm +RUN npm install -g npm@10.8.1 --integrity=sha512-Dp1C6SvSMYQI7YHq/y2l94uvI+59Eqbu1EpuKQHQ8p16txXRuRit5gH3Lnaagk2aXDIjg/Iru9pd05bnneKgdw== +RUN npm install -g pnpm@9.15.1 --integrity=sha512-GstWXmGT7769p3JwKVBGkVDPErzHZCYudYfnHRncmKQj3/lTblfqRMSb33kP9pToPCe+X6oj1n4MAztYO+S/zw== + +RUN pnpx playwright@1.47.0 install --with-deps chromium + +# Ensure PostgreSQL binaries are in the users $PATH. +RUN update-alternatives --install /usr/local/bin/initdb initdb /usr/lib/postgresql/16/bin/initdb 100 && \ + update-alternatives --install /usr/local/bin/postgres postgres /usr/lib/postgresql/16/bin/postgres 100 + +# Create links for injected dependencies +RUN ln --symbolic /var/tmp/coder/coder-cli/coder /usr/local/bin/coder && \ + ln --symbolic /var/tmp/coder/code-server/bin/code-server /usr/local/bin/code-server + +# Disable the PostgreSQL systemd service. +# Coder uses a custom timescale container to test the database instead. +RUN systemctl disable \ + postgresql + +# Configure systemd services for CVMs +RUN systemctl enable \ + docker \ + ssh && \ + # Workaround for envbuilder cache probing not working unless the filesystem is modified. + touch /tmp/.envbuilder-systemctl-enable-docker-ssh-workaround + +# Install tools with published releases, where that is the +# preferred/recommended installation method. +ARG CLOUD_SQL_PROXY_VERSION=2.2.0 \ + DIVE_VERSION=0.10.0 \ + DOCKER_GCR_VERSION=2.1.8 \ + GOLANGCI_LINT_VERSION=1.64.8 \ + GRYPE_VERSION=0.61.1 \ + HELM_VERSION=3.12.0 \ + KUBE_LINTER_VERSION=0.6.3 \ + KUBECTX_VERSION=0.9.4 \ + STRIPE_VERSION=1.14.5 \ + TERRAGRUNT_VERSION=0.45.11 \ + TRIVY_VERSION=0.41.0 \ + SYFT_VERSION=1.20.0 \ + COSIGN_VERSION=2.4.3 + +# cloud_sql_proxy, for connecting to cloudsql instances +# the upstream go.mod prevents this from being installed with go install +RUN curl --silent --show-error --location --output /usr/local/bin/cloud_sql_proxy "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v\${CLOUD_SQL_PROXY_VERSION}/cloud-sql-proxy.linux.amd64" && \ + chmod a=rx /usr/local/bin/cloud_sql_proxy && \ + # dive for scanning image layer utilization metrics in CI + curl --silent --show-error --location "https://github.com/wagoodman/dive/releases/download/v\${DIVE_VERSION}/dive_\${DIVE_VERSION}_linux_amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- dive && \ + # docker-credential-gcr is a Docker credential helper for pushing/pulling + # images from Google Container Registry and Artifact Registry + curl --silent --show-error --location "https://github.com/GoogleCloudPlatform/docker-credential-gcr/releases/download/v\${DOCKER_GCR_VERSION}/docker-credential-gcr_linux_amd64-\${DOCKER_GCR_VERSION}.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- docker-credential-gcr && \ + # golangci-lint performs static code analysis for our Go code + curl --silent --show-error --location "https://github.com/golangci/golangci-lint/releases/download/v\${GOLANGCI_LINT_VERSION}/golangci-lint-\${GOLANGCI_LINT_VERSION}-linux-amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- --strip-components=1 "golangci-lint-\${GOLANGCI_LINT_VERSION}-linux-amd64/golangci-lint" && \ + # Anchore Grype for scanning container images for security issues + curl --silent --show-error --location "https://github.com/anchore/grype/releases/download/v\${GRYPE_VERSION}/grype_\${GRYPE_VERSION}_linux_amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- grype && \ + # Helm is necessary for deploying Coder + curl --silent --show-error --location "https://get.helm.sh/helm-v\${HELM_VERSION}-linux-amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- --strip-components=1 linux-amd64/helm && \ + # kube-linter for linting Kubernetes objects, including those + # that Helm generates from our charts + curl --silent --show-error --location "https://github.com/stackrox/kube-linter/releases/download/\${KUBE_LINTER_VERSION}/kube-linter-linux" --output /usr/local/bin/kube-linter && \ + # kubens and kubectx for managing Kubernetes namespaces and contexts + curl --silent --show-error --location "https://github.com/ahmetb/kubectx/releases/download/v\${KUBECTX_VERSION}/kubectx_v\${KUBECTX_VERSION}_linux_x86_64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- kubectx && \ + curl --silent --show-error --location "https://github.com/ahmetb/kubectx/releases/download/v\${KUBECTX_VERSION}/kubens_v\${KUBECTX_VERSION}_linux_x86_64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- kubens && \ + # stripe for coder.com billing API + curl --silent --show-error --location "https://github.com/stripe/stripe-cli/releases/download/v\${STRIPE_VERSION}/stripe_\${STRIPE_VERSION}_linux_x86_64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- stripe && \ + # terragrunt for running Terraform and Terragrunt files + curl --silent --show-error --location --output /usr/local/bin/terragrunt "https://github.com/gruntwork-io/terragrunt/releases/download/v\${TERRAGRUNT_VERSION}/terragrunt_linux_amd64" && \ + chmod a=rx /usr/local/bin/terragrunt && \ + # AquaSec Trivy for scanning container images for security issues + curl --silent --show-error --location "https://github.com/aquasecurity/trivy/releases/download/v\${TRIVY_VERSION}/trivy_\${TRIVY_VERSION}_Linux-64bit.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- trivy && \ + # Anchore Syft for SBOM generation + curl --silent --show-error --location "https://github.com/anchore/syft/releases/download/v\${SYFT_VERSION}/syft_\${SYFT_VERSION}_linux_amd64.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/bin --file=- syft && \ + # Sigstore Cosign for artifact signing and attestation + curl --silent --show-error --location --output /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/v\${COSIGN_VERSION}/cosign-linux-amd64" && \ + chmod a=rx /usr/local/bin/cosign + +# We use yq during "make deploy" to manually substitute out fields in +# our helm values.yaml file. See https://github.com/helm/helm/issues/3141 +# +# TODO: update to 4.x, we can't do this now because it included breaking +# changes (yq w doesn't work anymore) +# RUN curl --silent --show-error --location "https://github.com/mikefarah/yq/releases/download/v4.9.0/yq_linux_amd64.tar.gz" | \ +# tar --extract --gzip --directory=/usr/local/bin --file=- ./yq_linux_amd64 && \ +# mv /usr/local/bin/yq_linux_amd64 /usr/local/bin/yq + +RUN curl --silent --show-error --location --output /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/3.3.0/yq_linux_amd64" && \ + chmod a=rx /usr/local/bin/yq + +# Install GoLand. +RUN mkdir --parents /usr/local/goland && \ + curl --silent --show-error --location "https://download.jetbrains.com/go/goland-2021.2.tar.gz" | \ + tar --extract --gzip --directory=/usr/local/goland --file=- --strip-components=1 && \ + ln --symbolic /usr/local/goland/bin/goland.sh /usr/local/bin/goland + +# Install Antlrv4, needed to generate paramlang lexer/parser +RUN curl --silent --show-error --location --output /usr/local/lib/antlr-4.9.2-complete.jar "https://www.antlr.org/download/antlr-4.9.2-complete.jar" +ENV CLASSPATH="/usr/local/lib/antlr-4.9.2-complete.jar:\${PATH}" + +# Add coder user and allow use of docker/sudo +RUN useradd coder \ + --create-home \ + --shell=/bin/bash \ + --groups=docker \ + --uid=1000 \ + --user-group + +# Adjust OpenSSH config +RUN echo "PermitUserEnvironment yes" >>/etc/ssh/sshd_config && \ + echo "X11Forwarding yes" >>/etc/ssh/sshd_config && \ + echo "X11UseLocalhost no" >>/etc/ssh/sshd_config + +# We avoid copying the extracted directory since COPY slows to minutes when there +# are a lot of small files. +COPY --from=go /usr/local/go.tar.gz /usr/local/go.tar.gz +RUN mkdir /usr/local/go && \ + tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 + +ENV PATH=$PATH:/usr/local/go/bin + +RUN update-alternatives --install /usr/local/bin/gofmt gofmt /usr/local/go/bin/gofmt 100 + +COPY --from=go /tmp/bin /usr/local/bin +COPY --from=rust-utils /tmp/bin /usr/local/bin +COPY --from=proto /tmp/bin /usr/local/bin +COPY --from=proto /tmp/include /usr/local/bin/include + +USER coder + +# Ensure go bins are in the 'coder' user's path. Note that no go bins are +# installed in this docker file, as they'd be mounted over by the persistent +# home volume. +ENV PATH="/home/coder/go/bin:\${PATH}" + +# This setting prevents Go from using the public checksum database for +# our module path prefixes. It is required because these are in private +# repositories that require authentication. +# +# For details, see: https://golang.org/ref/mod#private-modules +ENV GOPRIVATE="coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder" + +# Increase memory allocation to NodeJS +ENV NODE_OPTIONS="--max-old-space-size=8192" +` + +const templateTerraform = `terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.2.0-pre0" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0.0" + } + } +} + +locals { + // These are cluster service addresses mapped to Tailscale nodes. Ask Dean or + // Kyle for help. + docker_host = { + "" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" + "us-pittsburgh" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" + // For legacy reasons, this host is labelled \`eu-helsinki\` but it's + // actually in Germany now. + "eu-helsinki" = "tcp://katerose-fsn-cdr-dev.tailscale.svc.cluster.local:2375" + "ap-sydney" = "tcp://wolfgang-syd-cdr-dev.tailscale.svc.cluster.local:2375" + "sa-saopaulo" = "tcp://oberstein-sao-cdr-dev.tailscale.svc.cluster.local:2375" + "za-cpt" = "tcp://schonkopf-cpt-cdr-dev.tailscale.svc.cluster.local:2375" + } + + repo_base_dir = data.coder_parameter.repo_base_dir.value == "~" ? "/home/coder" : replace(data.coder_parameter.repo_base_dir.value, "/^~\\//", "/home/coder/") + repo_dir = replace(try(module.git-clone[0].repo_dir, ""), "/^~\\//", "/home/coder/") + container_name = "coder-\${data.coder_workspace_owner.me.name}-\${lower(data.coder_workspace.me.name)}" +} + +data "coder_parameter" "repo_base_dir" { + type = "string" + name = "Coder Repository Base Directory" + default = "~" + description = "The directory specified will be created (if missing) and [coder/coder](https://github.com/coder/coder) will be automatically cloned into [base directory]/coder 🪄." + mutable = true +} + +data "coder_parameter" "image_type" { + type = "string" + name = "Coder Image" + default = "codercom/oss-dogfood:latest" + description = "The Docker image used to run your workspace. Choose between nix and non-nix images." + option { + icon = "/icon/coder.svg" + name = "Dogfood (Default)" + value = "codercom/oss-dogfood:latest" + } + option { + icon = "/icon/nix.svg" + name = "Dogfood Nix (Experimental)" + value = "codercom/oss-dogfood-nix:latest" + } +} + +data "coder_parameter" "region" { + type = "string" + name = "Region" + icon = "/emojis/1f30e.png" + default = "us-pittsburgh" + option { + icon = "/emojis/1f1fa-1f1f8.png" + name = "Pittsburgh" + value = "us-pittsburgh" + } + option { + icon = "/emojis/1f1e9-1f1ea.png" + name = "Falkenstein" + // For legacy reasons, this host is labelled \`eu-helsinki\` but it's + // actually in Germany now. + value = "eu-helsinki" + } + option { + icon = "/emojis/1f1e6-1f1fa.png" + name = "Sydney" + value = "ap-sydney" + } + option { + icon = "/emojis/1f1e7-1f1f7.png" + name = "São Paulo" + value = "sa-saopaulo" + } + option { + icon = "/emojis/1f1ff-1f1e6.png" + name = "Cape Town" + value = "za-cpt" + } +} + +data "coder_parameter" "res_mon_memory_threshold" { + type = "number" + name = "Memory usage threshold" + default = 80 + description = "The memory usage threshold used in resources monitoring to trigger notifications." + mutable = true + validation { + min = 0 + max = 100 + } +} + +data "coder_parameter" "res_mon_volume_threshold" { + type = "number" + name = "Volume usage threshold" + default = 90 + description = "The volume usage threshold used in resources monitoring to trigger notifications." + mutable = true + validation { + min = 0 + max = 100 + } +} + +data "coder_parameter" "res_mon_volume_path" { + type = "string" + name = "Volume path" + default = "/home/coder" + description = "The path monitored in resources monitoring to trigger notifications." + mutable = true +} + +provider "docker" { + host = lookup(local.docker_host, data.coder_parameter.region.value) +} + +provider "coder" {} + +data "coder_external_auth" "github" { + id = "github" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} +data "coder_workspace_tags" "tags" { + tags = { + "cluster" : "dogfood-v2" + "env" : "gke" + } +} + +module "slackme" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/slackme/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + auth_provider_id = "slack" +} + +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/dotfiles/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id +} + +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/git-clone/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + url = "https://github.com/coder/coder" + base_dir = local.repo_base_dir +} + +module "personalize" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/personalize/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id +} + +module "code-server" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/code-server/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + folder = local.repo_dir + auto_install_extensions = true +} + +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + folder = local.repo_dir + extensions = ["github.copilot"] + auto_install_extensions = true # will install extensions from the repos .vscode/extensions.json file + accept_license = true +} + +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/jetbrains-gateway/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + agent_name = "dev" + folder = local.repo_dir + jetbrains_ides = ["GO", "WS"] + default = "GO" + latest = true +} + +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/filebrowser/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + agent_name = "dev" +} + +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/coder-login/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id +} + +module "cursor" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/cursor/coder" + version = ">= 1.0.0" + agent_id = coder_agent.dev.id + folder = local.repo_dir +} + +module "zed" { + count = data.coder_workspace.me.start_count + source = "./zed" + agent_id = coder_agent.dev.id + folder = local.repo_dir +} + +resource "coder_agent" "dev" { + arch = "amd64" + os = "linux" + dir = local.repo_dir + env = { + OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token, + } + startup_script_behavior = "blocking" + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + metadata { + display_name = "CPU Usage" + key = "cpu_usage" + order = 0 + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "ram_usage" + order = 1 + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "cpu_usage_host" + order = 2 + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage (Host)" + key = "ram_usage_host" + order = 3 + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Swap Usage (Host)" + key = "swap_usage_host" + order = 4 + script = <&1 | awk ' $0 ~ "Word of the Day: [A-z]+" { print $5; exit }' + EOT + interval = 86400 + timeout = 5 + } + + resources_monitoring { + memory { + enabled = true + threshold = data.coder_parameter.res_mon_memory_threshold.value + } + volume { + enabled = true + threshold = data.coder_parameter.res_mon_volume_threshold.value + path = data.coder_parameter.res_mon_volume_path.value + } + } + + startup_script = <<-EOT + #!/usr/bin/env bash + set -eux -o pipefail + + # Allow synchronization between scripts. + trap 'touch /tmp/.coder-startup-script.done' EXIT + + # Start Docker service + sudo service docker start + # Install playwright dependencies + # We want to use the playwright version from site/package.json + # Check if the directory exists At workspace creation as the coder_script runs in parallel so clone might not exist yet. + while ! [[ -f "\${local.repo_dir}/site/package.json" ]]; do + sleep 1 + done + cd "\${local.repo_dir}" && make clean + cd "\${local.repo_dir}/site" && pnpm install + EOT + + shutdown_script = <<-EOT + #!/usr/bin/env bash + set -eux -o pipefail + + # Stop the Docker service to prevent errors during workspace destroy. + sudo service docker stop + EOT +} + +# Add a cost so we get some quota usage in dev.coder.com +resource "coder_metadata" "home_volume" { + resource_id = docker_volume.home_volume.id + daily_cost = 1 +} + +resource "docker_volume" "home_volume" { + name = "coder-\${data.coder_workspace.me.id}-home" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +data "docker_registry_image" "dogfood" { + name = data.coder_parameter.image_type.value +} + +resource "docker_image" "dogfood" { + name = "\${data.coder_parameter.image_type.value}@\${data.docker_registry_image.dogfood.sha256_digest}" + pull_triggers = [ + data.docker_registry_image.dogfood.sha256_digest, + sha1(join("", [for f in fileset(path.module, "files/*") : filesha1(f)])), + filesha1("Dockerfile"), + filesha1("nix.hash"), + ] + keep_locally = true +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = docker_image.dogfood.name + name = local.container_name + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = data.coder_workspace.me.name + # Use the docker gateway if the access URL is 127.0.0.1 + entrypoint = ["sh", "-c", coder_agent.dev.init_script] + # CPU limits are unnecessary since Docker will load balance automatically + memory = data.coder_workspace_owner.me.name == "code-asher" ? 65536 : 32768 + runtime = "sysbox-runc" + # Ensure the workspace is given time to execute shutdown scripts. + destroy_grace_seconds = 60 + stop_timeout = 60 + stop_signal = "SIGINT" + env = [ + "CODER_AGENT_TOKEN=\${coder_agent.dev.token}", + "USE_CAP_NET_ADMIN=true", + "CODER_PROC_PRIO_MGMT=1", + "CODER_PROC_OOM_SCORE=10", + "CODER_PROC_NICE_SCORE=1", + "CODER_AGENT_DEVCONTAINERS_ENABLE=1", + ] + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/home/coder/" + volume_name = docker_volume.home_volume.name + read_only = false + } + capabilities { + add = ["CAP_NET_ADMIN", "CAP_SYS_NICE"] + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} + +resource "coder_metadata" "container_info" { + count = data.coder_workspace.me.start_count + resource_id = docker_container.workspace[0].id + item { + key = "memory" + value = docker_container.workspace[0].memory + } + item { + key = "runtime" + value = docker_container.workspace[0].runtime + } + item { + key = "region" + value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name + } +} +`; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.tsx b/site/src/pages/ChatPage/ChatToolInvocation.tsx index bdcc47eb8c740..2d45c529ad280 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.tsx @@ -1,4 +1,4 @@ -import React, { FC, useMemo } from "react"; +import React, { FC, useMemo, useState, memo } from "react"; import { useTheme } from "@emotion/react"; import type { ToolCall, ToolResult } from "@ai-sdk/provider-utils"; import * as TypesGen from "api/typesGenerated"; @@ -7,8 +7,20 @@ import CircularProgress from "@mui/material/CircularProgress"; import ErrorIcon from "@mui/icons-material/Error"; import CodeIcon from "@mui/icons-material/Code"; import ArticleIcon from "@mui/icons-material/Article"; -import { Tooltip } from "@mui/material"; +import FileUploadIcon from "@mui/icons-material/FileUpload"; +import TerminalIcon from "@mui/icons-material/Terminal"; +import SettingsIcon from "@mui/icons-material/Settings"; +import DeleteIcon from "@mui/icons-material/Delete"; +import PersonIcon from "@mui/icons-material/Person"; +import ListIcon from "@mui/icons-material/List"; +import BuildIcon from "@mui/icons-material/Build"; +import Tooltip from "@mui/material/Tooltip"; +import Avatar from "@mui/material/Avatar"; import { InfoIcon } from "lucide-react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { Tabs, TabsList, TabLink } from "../../components/Tabs/Tabs"; interface ChatToolInvocationProps { toolInvocation: ChatToolInvocation; @@ -41,14 +53,31 @@ export const ChatToolInvocation: FC = ({ } return hasError ? theme.palette.error.main : theme.palette.success.main; }, [toolInvocation, hasError, theme]); + const tooltipContent = useMemo(() => { + return ( + + {JSON.stringify(toolInvocation, null, 2)} + + ); + }, [toolInvocation]); return (
= ({ > {friendlyName}
- +
- {toolInvocation.state === "result" && ( + {toolInvocation.state === "result" ? ( + ) : ( + )}
); }; +const ChatToolInvocationCallPreview: FC<{ + toolInvocation: Extract< + ChatToolInvocation, + { state: "call" | "partial-call" } + >; +}> = memo(({ toolInvocation }) => { + const theme = useTheme(); + + let content: React.ReactNode; + switch (toolInvocation.toolName) { + case "coder_upload_tar_file": + content = ( + + ); + break; + } + + if (!content) { + return null; + } + + return
{content}
; +}); + const ChatToolInvocationResultPreview: FC<{ toolInvocation: Extract; -}> = ({ toolInvocation }) => { +}> = memo(({ toolInvocation }) => { const theme = useTheme(); + if (!toolInvocation.result) { + return null; + } + if ( typeof toolInvocation.result === "object" && "error" in toolInvocation.result @@ -105,6 +167,7 @@ const ChatToolInvocationResultPreview: FC<{ return null; } + let content: React.ReactNode; switch (toolInvocation.toolName) { case "coder_get_workspace": @@ -148,24 +211,377 @@ const ChatToolInvocationResultPreview: FC<{ break; case "coder_list_workspaces": content = ( -
+
{toolInvocation.result.map((workspace) => ( -
- {workspace.template_display_name + {workspace.template_icon && ( + {workspace.template_display_name + )} +
+
+ {workspace.name} +
+
+ {workspace.template_display_name} +
+
+
+ ))} +
+ ); + break; + case "coder_list_templates": { + const templates = toolInvocation.result; + content = ( +
+ {templates.map((template) => ( +
+ +
+
+ {template.name} +
+
+ {template.description} +
+
+
+ ))} + {templates.length === 0 &&
No templates found.
} +
+ ); + break; + } + case "coder_template_version_parameters": { + const params = toolInvocation.result; + content = ( +
+ + {params.length > 0 + ? `${params.length} parameter(s)` + : "No parameters"} +
+ ); + break; + } + case "coder_get_authenticated_user": { + const user = toolInvocation.result; + content = ( +
+ + + +
+
+ {user.username} +
+
+ {user.email} +
+
+
+ ); + break; + } + case "coder_create_workspace_build": { + const build = toolInvocation.result; + content = ( +
+ + Build #{build.build_number} ({build.transition}) status:{" "} + {build.status} +
+ ); + break; + } + case "coder_create_template_version": { + const version = toolInvocation.result; + content = ( +
+ +
+
{version.name}
+ {version.message && ( +
- {workspace.name} + > + {version.message} +
+ )} +
+
+ ); + break; + } + case "coder_get_workspace_agent_logs": + case "coder_get_workspace_build_logs": + case "coder_get_template_version_logs": { + const logs = toolInvocation.result; + if (!logs) { + console.log(toolInvocation); + } + const totalLines = logs.length; + const maxLinesToShow = 5; + const lastLogs = logs.slice(-maxLinesToShow); + const hiddenLines = totalLines - lastLogs.length; + + const totalLinesText = `${totalLines} log line${totalLines !== 1 ? "s" : ""}`; + const hiddenLinesText = + hiddenLines > 0 + ? `... hiding ${hiddenLines} more line${hiddenLines !== 1 ? "s" : ""} ...` + : null; + + const logsToShow = hiddenLinesText + ? [hiddenLinesText, ...lastLogs] + : lastLogs; + + content = ( +
+
+ + Retrieved {totalLinesText}. +
+ {logsToShow.length > 0 && ( + + {logsToShow.join("\n")} + + )} +
+ ); + break; + } + case "coder_update_template_active_version": + content = ( +
+ + {toolInvocation.result} +
+ ); + break; + case "coder_upload_tar_file": + content = ( + + ); + break; + case "coder_create_template": { + const template = toolInvocation.result; + content = ( +
+ {template.display_name +
+
+ {template.name}
- ))} +
+ {template.display_name} +
+
+
+ ); + break; + } + case "coder_delete_template": + content = ( +
+ + {toolInvocation.result} +
+ ); + break; + case "coder_get_template_version": { + const version = toolInvocation.result; + content = ( +
+ +
+
{version.name}
+ {version.message && ( +
+ {version.message} +
+ )} +
); + break; + } + case "coder_download_tar_file": { + const files = toolInvocation.result; + content = ; + break; + } + // Add default case or handle other tools if necessary } return (
); -}; +}); + +// New component to preview files with tabs +const FilePreview: FC<{ files: Record; prefix?: string }> = memo(({ + files, + prefix, +}) => { + const theme = useTheme(); + const [selectedTab, setSelectedTab] = useState(0); + const fileEntries = useMemo(() => Object.entries(files), [files]); + + if (fileEntries.length === 0) { + return null; + } + + const handleTabChange = (index: number) => { + setSelectedTab(index); + }; + + const getLanguage = (filename: string): string => { + if (filename.includes("Dockerfile")) { + return "dockerfile"; + } + const extension = filename.split(".").pop()?.toLowerCase(); + switch (extension) { + case "tf": + return "hcl"; + case "json": + return "json"; + case "yaml": + case "yml": + return "yaml"; + case "js": + case "jsx": + return "javascript"; + case "ts": + case "tsx": + return "typescript"; + case "py": + return "python"; + case "go": + return "go"; + case "rb": + return "ruby"; + case "java": + return "java"; + case "sh": + return "bash"; + case "md": + return "markdown"; + default: + return "plaintext"; + } + }; + + // Get filename and content based on the selectedTab index + const [selectedFilename, selectedContent] = fileEntries[selectedTab] ?? [ + "", + "", + ]; + + return ( +
+ {prefix && ( +
+ + {prefix} +
+ )} + {/* Use custom Tabs component with active prop */} + + + {fileEntries.map(([filename], index) => ( + { + e.preventDefault(); // Prevent any potential default link behavior + handleTabChange(index); + }} + > + {filename} + + ))} + + + + {selectedContent} + +
+ ); +}); export type ChatToolInvocation = | ToolInvocation< @@ -270,6 +811,20 @@ export type ChatToolInvocation = }, string[] > + | ToolInvocation< + "coder_get_template_version", + { + template_version_id: string; + }, + TypesGen.TemplateVersion + > + | ToolInvocation< + "coder_download_tar_file", + { + file_id: string; + }, + Record + > | ToolInvocation< "coder_update_template_active_version", { From 10b534c5ef40f6a8303f8d71b240f3711e0c4a93 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 25 Apr 2025 17:03:37 +0100 Subject: [PATCH 08/18] chore: make gen, fmt, lint --- .../TestProvisioners_Golden/list.golden | 2 +- cli/testdata/coder_provisioner_list.golden | 2 +- cli/testdata/server-config.yaml.golden | 3 +- coderd/ai/ai.go | 3 - coderd/apidoc/docs.go | 192 ++++++++++++- coderd/apidoc/swagger.json | 176 +++++++++++- coderd/chat.go | 31 ++- coderd/database/dbgen/dbgen.go | 2 + coderd/httpmw/chat.go | 11 +- codersdk/chat.go | 12 +- codersdk/deployment.go | 2 +- docs/reference/api/chat.md | 146 ++++++++-- docs/reference/api/schemas.md | 188 ++++++++++++- site/package.json | 6 +- site/src/api/api.ts | 10 +- site/src/api/queries/chats.ts | 4 +- site/src/api/typesGenerated.ts | 2 +- site/src/pages/ChatPage/ChatLanding.tsx | 92 ++++--- site/src/pages/ChatPage/ChatLayout.tsx | 82 +++--- site/src/pages/ChatPage/ChatMessages.tsx | 125 +++++---- .../ChatPage/ChatToolInvocation.stories.tsx | 8 +- .../src/pages/ChatPage/ChatToolInvocation.tsx | 258 +++++++++--------- .../pages/ChatPage/LanguageModelSelector.tsx | 109 ++++---- site/src/router.tsx | 4 +- 24 files changed, 1083 insertions(+), 387 deletions(-) diff --git a/cli/testdata/TestProvisioners_Golden/list.golden b/cli/testdata/TestProvisioners_Golden/list.golden index 35844d8b9c50e..3f50f90746744 100644 --- a/cli/testdata/TestProvisioners_Golden/list.golden +++ b/cli/testdata/TestProvisioners_Golden/list.golden @@ -1,4 +1,4 @@ -ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION +ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION 00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder 00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder 00000000-0000-0000-aaaa-000000000002 ====[timestamp]===== ====[timestamp]===== provisioner-2 v0.0.0 map[owner: scope:organization] built-in offline 00000000-0000-0000-bbbb-000000000003 succeeded Coder diff --git a/cli/testdata/coder_provisioner_list.golden b/cli/testdata/coder_provisioner_list.golden index e34db5605fd81..64941eebf5b89 100644 --- a/cli/testdata/coder_provisioner_list.golden +++ b/cli/testdata/coder_provisioner_list.golden @@ -1,2 +1,2 @@ -CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS ====[timestamp]===== ====[timestamp]===== built-in test v0.0.0-devel idle map[owner: scope:organization] diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index c131e7c7ad119..fc76a6c2ec8a0 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -521,8 +521,7 @@ client: supportLinks: [] # Configure AI providers. # (default: , type: struct[codersdk.AIConfig]) -ai: - providers: [] +ai: {} # External Authentication providers. # (default: , type: struct[[]codersdk.ExternalAuthConfig]) externalAuthProviders: [] diff --git a/coderd/ai/ai.go b/coderd/ai/ai.go index 9c8fbc62e21ef..fefaebc70d4d0 100644 --- a/coderd/ai/ai.go +++ b/coderd/ai/ai.go @@ -70,7 +70,6 @@ func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) config.Models[i] = model.ID } } - break case "anthropic": client := anthropic.NewClient(anthropicoption.WithAPIKey(config.APIKey)) streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { @@ -101,7 +100,6 @@ func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) config.Models[i] = model.ID } } - break case "google": client, err := genai.NewClient(ctx, &genai.ClientConfig{ APIKey: config.APIKey, @@ -143,7 +141,6 @@ func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) config.Models[i] = model.Name } } - break default: return nil, xerrors.Errorf("unsupported model type: %s", config.Type) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4757963d36725..c5d1f466f714a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -383,7 +383,7 @@ const docTemplate = `{ "Chat" ], "summary": "Create a chat", - "operationId": "post-chat", + "operationId": "create-a-chat", "responses": { "201": { "description": "Created", @@ -408,7 +408,16 @@ const docTemplate = `{ "Chat" ], "summary": "Get a chat", - "operationId": "get-chat", + "operationId": "get-a-chat", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], "responses": { "200": { "description": "OK", @@ -434,6 +443,15 @@ const docTemplate = `{ ], "summary": "Get chat messages", "operationId": "get-chat-messages", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], "responses": { "200": { "description": "OK", @@ -445,6 +463,51 @@ const docTemplate = `{ } } } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Create a chat message", + "operationId": "create-a-chat-message", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateChatMessageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": {} + } + } + } } }, "/csp/reports": { @@ -11318,15 +11381,54 @@ const docTemplate = `{ "type": "object", "properties": { "created_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "title": { "type": "string" }, "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.ChatMessage": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": {} + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "array", + "items": { + "type": "integer" + } + }, + "experimental_attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Attachment" + } + }, + "id": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Part" + } + }, + "role": { "type": "string" } } @@ -11364,6 +11466,20 @@ const docTemplate = `{ } } }, + "codersdk.CreateChatMessageRequest": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/codersdk.ChatMessage" + }, + "model": { + "type": "string" + }, + "thinking": { + "type": "boolean" + } + } + }, "codersdk.CreateFirstUserRequest": { "type": "object", "required": [ @@ -11651,7 +11767,73 @@ const docTemplate = `{ } }, "codersdk.CreateTestAuditLogRequest": { - "type": "object" + "type": "object", + "properties": { + "action": { + "enum": [ + "create", + "write", + "delete", + "start", + "stop" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.AuditAction" + } + ] + }, + "additional_fields": { + "type": "array", + "items": { + "type": "integer" + } + }, + "build_reason": { + "enum": [ + "autostart", + "autostop", + "initiator" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.BuildReason" + } + ] + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "request_id": { + "type": "string", + "format": "uuid" + }, + "resource_id": { + "type": "string", + "format": "uuid" + }, + "resource_type": { + "enum": [ + "template", + "template_version", + "user", + "workspace", + "workspace_build", + "git_ssh_key", + "auditable_group" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ResourceType" + } + ] + }, + "time": { + "type": "string", + "format": "date-time" + } + } }, "codersdk.CreateTokenRequest": { "type": "object", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 041d483762066..faa2ceea0abf1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -323,7 +323,7 @@ "produces": ["application/json"], "tags": ["Chat"], "summary": "Create a chat", - "operationId": "post-chat", + "operationId": "create-a-chat", "responses": { "201": { "description": "Created", @@ -344,7 +344,16 @@ "produces": ["application/json"], "tags": ["Chat"], "summary": "Get a chat", - "operationId": "get-chat", + "operationId": "get-a-chat", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], "responses": { "200": { "description": "OK", @@ -366,6 +375,15 @@ "tags": ["Chat"], "summary": "Get chat messages", "operationId": "get-chat-messages", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], "responses": { "200": { "description": "OK", @@ -377,6 +395,45 @@ } } } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "Create a chat message", + "operationId": "create-a-chat-message", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateChatMessageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": {} + } + } + } } }, "/csp/reports": { @@ -10092,15 +10149,54 @@ "type": "object", "properties": { "created_at": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "title": { "type": "string" }, "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.ChatMessage": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": {} + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "array", + "items": { + "type": "integer" + } + }, + "experimental_attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Attachment" + } + }, + "id": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Part" + } + }, + "role": { "type": "string" } } @@ -10135,6 +10231,20 @@ } } }, + "codersdk.CreateChatMessageRequest": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/codersdk.ChatMessage" + }, + "model": { + "type": "string" + }, + "thinking": { + "type": "boolean" + } + } + }, "codersdk.CreateFirstUserRequest": { "type": "object", "required": ["email", "password", "username"], @@ -10403,7 +10513,63 @@ } }, "codersdk.CreateTestAuditLogRequest": { - "type": "object" + "type": "object", + "properties": { + "action": { + "enum": ["create", "write", "delete", "start", "stop"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.AuditAction" + } + ] + }, + "additional_fields": { + "type": "array", + "items": { + "type": "integer" + } + }, + "build_reason": { + "enum": ["autostart", "autostop", "initiator"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.BuildReason" + } + ] + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "request_id": { + "type": "string", + "format": "uuid" + }, + "resource_id": { + "type": "string", + "format": "uuid" + }, + "resource_type": { + "enum": [ + "template", + "template_version", + "user", + "workspace", + "workspace_build", + "git_ssh_key", + "auditable_group" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ResourceType" + } + ] + }, + "time": { + "type": "string", + "format": "date-time" + } + } }, "codersdk.CreateTokenRequest": { "type": "object", diff --git a/coderd/chat.go b/coderd/chat.go index eef5cd0bf6939..875c51ba49840 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -22,7 +22,7 @@ import ( // postChats creates a new chat. // // @Summary Create a chat -// @ID post-chat +// @ID create-a-chat // @Security CoderSessionToken // @Produce json // @Tags Chat @@ -69,6 +69,7 @@ func (api *API) listChats(w http.ResponseWriter, r *http.Request) { Message: "Failed to list chats", Detail: err.Error(), }) + return } httpapi.Write(ctx, w, http.StatusOK, db2sdk.Chats(chats)) @@ -77,13 +78,14 @@ func (api *API) listChats(w http.ResponseWriter, r *http.Request) { // chat returns a chat by ID. // // @Summary Get a chat -// @ID get-chat +// @ID get-a-chat // @Security CoderSessionToken // @Produce json // @Tags Chat +// @Param chat path string true "Chat ID" // @Success 200 {object} codersdk.Chat // @Router /chats/{chat} [get] -func (api *API) chat(w http.ResponseWriter, r *http.Request) { +func (*API) chat(w http.ResponseWriter, r *http.Request) { ctx := r.Context() chat := httpmw.ChatParam(r) httpapi.Write(ctx, w, http.StatusOK, db2sdk.Chat(chat)) @@ -96,6 +98,7 @@ func (api *API) chat(w http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Produce json // @Tags Chat +// @Param chat path string true "Chat ID" // @Success 200 {array} aisdk.Message // @Router /chats/{chat}/messages [get] func (api *API) chatMessages(w http.ResponseWriter, r *http.Request) { @@ -107,6 +110,7 @@ func (api *API) chatMessages(w http.ResponseWriter, r *http.Request) { Message: "Failed to get chat messages", Detail: err.Error(), }) + return } messages := make([]aisdk.Message, len(rawMessages)) for i, message := range rawMessages { @@ -117,6 +121,7 @@ func (api *API) chatMessages(w http.ResponseWriter, r *http.Request) { Message: "Failed to unmarshal chat message", Detail: err.Error(), }) + return } messages[i] = msg } @@ -124,6 +129,18 @@ func (api *API) chatMessages(w http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, w, http.StatusOK, messages) } +// postChatMessages creates a new chat message and streams the response. +// +// @Summary Create a chat message +// @ID create-a-chat-message +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Chat +// @Param chat path string true "Chat ID" +// @Param request body codersdk.CreateChatMessageRequest true "Request body" +// @Success 200 {array} aisdk.DataStreamPart +// @Router /chats/{chat}/messages [post] func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { ctx := r.Context() chat := httpmw.ChatParam(r) @@ -134,6 +151,7 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { Message: "Failed to decode chat message", Detail: err.Error(), }) + return } dbMessages, err := api.Database.GetChatMessagesByChatID(ctx, chat.ID) @@ -195,6 +213,7 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { Message: "Failed to create stream", Detail: err.Error(), }) + return } stream = stream.WithAccumulator(&acc) err = stream.Pipe(io.Discard) @@ -203,10 +222,12 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { Message: "Failed to pipe stream", Detail: err.Error(), }) + return } err = api.Database.UpdateChatByID(ctx, database.UpdateChatByIDParams{ - ID: chat.ID, - Title: acc.Messages()[0].Content, + ID: chat.ID, + Title: acc.Messages()[0].Content, + UpdatedAt: time.Now(), }) if err != nil { httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index a3f982847cbcf..5232ad2b9579c 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -145,8 +145,10 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database func Chat(t testing.TB, db database.Store, seed database.Chat) database.Chat { chat, err := db.InsertChat(genCtx, database.InsertChatParams{ ID: takeFirst(seed.ID, uuid.New()), + OwnerID: takeFirst(seed.OwnerID, uuid.New()), CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), + Title: takeFirst(seed.Title, "Test Chat"), }) require.NoError(t, err, "insert chat") return chat diff --git a/coderd/httpmw/chat.go b/coderd/httpmw/chat.go index b6e25b49f2643..c92fa5038ab22 100644 --- a/coderd/httpmw/chat.go +++ b/coderd/httpmw/chat.go @@ -15,7 +15,11 @@ import ( type chatContextKey struct{} func ChatParam(r *http.Request) database.Chat { - return r.Context().Value(chatContextKey{}).(database.Chat) + chat, ok := r.Context().Value(chatContextKey{}).(database.Chat) + if !ok { + panic("developer error: chat param middleware not provided") + } + return chat } func ExtractChatParam(db database.Store) func(http.Handler) http.Handler { @@ -41,11 +45,6 @@ func ExtractChatParam(db database.Store) func(http.Handler) http.Handler { httpapi.ResourceNotFound(rw) return } - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get chat.", - }) - } if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get chat.", diff --git a/codersdk/chat.go b/codersdk/chat.go index b099e11273605..ab9ed46faeded 100644 --- a/codersdk/chat.go +++ b/codersdk/chat.go @@ -27,9 +27,9 @@ func (c *Client) CreateChat(ctx context.Context) (Chat, error) { } type Chat struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` Title string `json:"title"` } @@ -86,10 +86,14 @@ type CreateChatMessageRequest struct { // it will be overwritten. func (c *Client) CreateChatMessage(ctx context.Context, id uuid.UUID, req CreateChatMessageRequest) (<-chan aisdk.DataStreamPart, error) { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/chats/%s/messages", id), req) + defer func() { + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }() if err != nil { return nil, xerrors.Errorf("execute request: %w", err) } - defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, ReadBodyAsError(res) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 8c529091d42f0..ff70839f0c104 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3103,7 +3103,7 @@ type AIProviderConfig struct { } type AIConfig struct { - Providers []AIProviderConfig `json:"providers" yaml:"providers"` + Providers []AIProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"` } type SupportConfig struct { diff --git a/docs/reference/api/chat.md b/docs/reference/api/chat.md index 9500432762f98..a4236a0e4bc0c 100644 --- a/docs/reference/api/chat.md +++ b/docs/reference/api/chat.md @@ -20,10 +20,10 @@ curl -X GET http://coder-server:8080/api/v2/chats \ ```json [ { - "created_at": "string", - "id": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "title": "string", - "updated_at": "string" + "updated_at": "2019-08-24T14:15:22Z" } ] ``` @@ -38,13 +38,13 @@ curl -X GET http://coder-server:8080/api/v2/chats \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|----------------|--------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» created_at` | string | false | | | -| `» id` | string | false | | | -| `» title` | string | false | | | -| `» updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------|-------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» id` | string(uuid) | false | | | +| `» title` | string | false | | | +| `» updated_at` | string(date-time) | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -67,10 +67,10 @@ curl -X POST http://coder-server:8080/api/v2/chats \ ```json { - "created_at": "string", - "id": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "title": "string", - "updated_at": "string" + "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -95,16 +95,22 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat} \ `GET /chats/{chat}` +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------| +| `chat` | path | string | true | Chat ID | + ### Example responses > 200 Response ```json { - "created_at": "string", - "id": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "title": "string", - "updated_at": "string" + "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -129,6 +135,12 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat}/messages \ `GET /chats/{chat}/messages` +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------| +| `chat` | path | string | true | Chat ID | + ### Example responses > 200 Response @@ -252,3 +264,105 @@ Status Code **200** | `type` | `step-start` | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Create a chat message + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /chats/{chat}/messages` + +> Body parameter + +```json +{ + "message": { + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": "string", + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" + }, + "model": "string", + "thinking": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|----------------------------------------------------------------------------------|----------|--------------| +| `chat` | path | string | true | Chat ID | +| `body` | body | [codersdk.CreateChatMessageRequest](schemas.md#codersdkcreatechatmessagerequest) | true | Request body | + +### Example responses + +> 200 Response + +```json +[ + null +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of undefined | + +

Response Schema

+ +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d6332f3326bda..958e8d53d58c4 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1324,10 +1324,10 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { - "created_at": "string", - "id": "string", + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "title": "string", - "updated_at": "string" + "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -1340,6 +1340,75 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `title` | string | false | | | | `updated_at` | string | false | | | +## codersdk.ChatMessage + +```json +{ + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": "string", + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|-----------------------------------------------|----------|--------------|-------------| +| `annotations` | array of undefined | false | | | +| `content` | string | false | | | +| `createdAt` | array of integer | false | | | +| `experimental_attachments` | array of [aisdk.Attachment](#aisdkattachment) | false | | | +| `id` | string | false | | | +| `parts` | array of [aisdk.Part](#aisdkpart) | false | | | +| `role` | string | false | | | + ## codersdk.ConnectionLatency ```json @@ -1372,6 +1441,75 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `password` | string | true | | | | `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. | +## codersdk.CreateChatMessageRequest + +```json +{ + "message": { + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": "string", + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" + }, + "model": "string", + "thinking": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|----------------------------------------------|----------|--------------|-------------| +| `message` | [codersdk.ChatMessage](#codersdkchatmessage) | false | | | +| `model` | string | false | | | +| `thinking` | boolean | false | | | + ## codersdk.CreateFirstUserRequest ```json @@ -1636,12 +1774,52 @@ This is required on creation to enable a user-flow of validating a template work ## codersdk.CreateTestAuditLogRequest ```json -{} +{ + "action": "create", + "additional_fields": [ + 0 + ], + "build_reason": "autostart", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "request_id": "266ea41d-adf5-480b-af50-15b940c2b846", + "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", + "resource_type": "template", + "time": "2019-08-24T14:15:22Z" +} ``` ### Properties -None +| Name | Type | Required | Restrictions | Description | +|---------------------|------------------------------------------------|----------|--------------|-------------| +| `action` | [codersdk.AuditAction](#codersdkauditaction) | false | | | +| `additional_fields` | array of integer | false | | | +| `build_reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | +| `organization_id` | string | false | | | +| `request_id` | string | false | | | +| `resource_id` | string | false | | | +| `resource_type` | [codersdk.ResourceType](#codersdkresourcetype) | false | | | +| `time` | string | false | | | + +#### Enumerated Values + +| Property | Value | +|-----------------|--------------------| +| `action` | `create` | +| `action` | `write` | +| `action` | `delete` | +| `action` | `start` | +| `action` | `stop` | +| `build_reason` | `autostart` | +| `build_reason` | `autostop` | +| `build_reason` | `initiator` | +| `resource_type` | `template` | +| `resource_type` | `template_version` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_build` | +| `resource_type` | `git_ssh_key` | +| `resource_type` | `auditable_group` | ## codersdk.CreateTokenRequest diff --git a/site/package.json b/site/package.json index 29597a705cc0c..e363fe146f432 100644 --- a/site/package.json +++ b/site/package.json @@ -196,11 +196,7 @@ "vite-plugin-checker": "0.8.0", "vite-plugin-turbosnap": "1.0.3" }, - "browserslist": [ - "chrome 110", - "firefox 111", - "safari 16.0" - ], + "browserslist": ["chrome 110", "firefox 111", "safari 16.0"], "resolutions": { "optionator": "0.9.3", "semver": "7.6.2" diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 498a1aa65322f..688ba0432e22b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2500,17 +2500,19 @@ class ApiMethods { createChat = async () => { const res = await this.axios.post("/api/v2/chats"); return res.data; - } + }; getChats = async () => { const res = await this.axios.get("/api/v2/chats"); return res.data; - } + }; getChatMessages = async (chatId: string) => { - const res = await this.axios.get(`/api/v2/chats/${chatId}/messages`); + const res = await this.axios.get( + `/api/v2/chats/${chatId}/messages`, + ); return res.data; - } + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index c486214978cc3..196bf4c603597 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1,5 +1,5 @@ -import { QueryClient } from "react-query" -import { API } from "api/api" +import { API } from "api/api"; +import type { QueryClient } from "react-query"; export const createChat = (queryClient: QueryClient) => { return { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a418effe03414..4f8301cbe1716 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8,7 +8,7 @@ export interface ACLAvailable { // From codersdk/deployment.go export interface AIConfig { - readonly providers: readonly AIProviderConfig[]; + readonly providers?: readonly AIProviderConfig[]; } // From codersdk/deployment.go diff --git a/site/src/pages/ChatPage/ChatLanding.tsx b/site/src/pages/ChatPage/ChatLanding.tsx index 9badc419bde6c..8022d9ddf464a 100644 --- a/site/src/pages/ChatPage/ChatLanding.tsx +++ b/site/src/pages/ChatPage/ChatLanding.tsx @@ -5,18 +5,18 @@ import IconButton from "@mui/material/IconButton"; import Paper from "@mui/material/Paper"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; +import { createChat } from "api/queries/chats"; +import type { Chat } from "api/typesGenerated"; import { Margins } from "components/Margins/Margins"; import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { FC, FormEvent, useState } from "react"; +import { type FC, type FormEvent, useState } from "react"; +import { useMutation, useQueryClient } from "react-query"; import { useNavigate } from "react-router-dom"; import { LanguageModelSelector } from "./LanguageModelSelector"; -import { createChat } from "api/queries/chats"; -import { useMutation, useQueryClient } from "react-query"; -import { Chat } from "api/typesGenerated"; export interface ChatLandingLocationState { - chat: Chat - message: string + chat: Chat; + message: string; } export const ChatLanding: FC = () => { @@ -40,12 +40,13 @@ export const ChatLanding: FC = () => { setInput(""); // Clear input after submit (optional) createChatMutation.mutateAsync().then((chat) => { - navigate(`/chat/${chat.id}`, { state: { - chat, - message: input - } }); - }) - + navigate(`/chat/${chat.id}`, { + state: { + chat, + message: input, + }, + }); + }); }; // Placeholder suggestion handler @@ -75,8 +76,8 @@ export const ChatLanding: FC = () => { alignItems: "center", gap: theme.spacing(1), padding: theme.spacing(1), - width: '100%', - maxWidth: '700px', + width: "100%", + maxWidth: "700px", marginBottom: theme.spacing(4), }} > @@ -108,32 +109,37 @@ export const ChatLanding: FC = () => {
{/* Input Form and Suggestions - Always Visible */} -
- - - - +
+ + + + { }} autoFocus /> - + diff --git a/site/src/pages/ChatPage/ChatLayout.tsx b/site/src/pages/ChatPage/ChatLayout.tsx index a6ac690c2595c..223fb25e14077 100644 --- a/site/src/pages/ChatPage/ChatLayout.tsx +++ b/site/src/pages/ChatPage/ChatLayout.tsx @@ -1,26 +1,26 @@ -import { - createContext, - FC, - PropsWithChildren, - useContext, - useEffect, - useState, -} from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { Link, Outlet, useNavigate, useParams } from "react-router-dom"; +import { useTheme } from "@emotion/react"; +import AddIcon from "@mui/icons-material/Add"; +import Button from "@mui/material/Button"; import List from "@mui/material/List"; import ListItem from "@mui/material/ListItem"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemText from "@mui/material/ListItemText"; import Paper from "@mui/material/Paper"; -import { useTheme } from "@emotion/react"; import { createChat, getChats } from "api/queries/chats"; import { deploymentLanguageModels } from "api/queries/deployment"; -import { Chat, LanguageModelConfig } from "api/typesGenerated"; +import { Chat, type LanguageModelConfig } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; -import Button from "@mui/material/Button"; -import AddIcon from '@mui/icons-material/Add'; +import { + type FC, + type PropsWithChildren, + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link, Outlet, useNavigate, useParams } from "react-router-dom"; export interface ChatContext { selectedModel: string; @@ -98,15 +98,17 @@ export const ChatLayout: FC = () => { navigate("/chat"); }; - console.log(chats) + console.log(chats); return ( // Outermost container: controls height and prevents page scroll -
+
{/* Sidebar Container (using Paper for background/border) */} { }} > {/* Replaced Typography with div + styling */} -
+
Chats
- - - - - - - - - - -
-
- - ); -}; - -export default ChatLanding; diff --git a/site/src/pages/ChatPage/ChatLayout.tsx b/site/src/pages/ChatPage/ChatLayout.tsx deleted file mode 100644 index 223fb25e14077..0000000000000 --- a/site/src/pages/ChatPage/ChatLayout.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useTheme } from "@emotion/react"; -import AddIcon from "@mui/icons-material/Add"; -import Button from "@mui/material/Button"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemButton from "@mui/material/ListItemButton"; -import ListItemText from "@mui/material/ListItemText"; -import Paper from "@mui/material/Paper"; -import { createChat, getChats } from "api/queries/chats"; -import { deploymentLanguageModels } from "api/queries/deployment"; -import { Chat, type LanguageModelConfig } from "api/typesGenerated"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Loader } from "components/Loader/Loader"; -import { - type FC, - type PropsWithChildren, - createContext, - useContext, - useEffect, - useState, -} from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { Link, Outlet, useNavigate, useParams } from "react-router-dom"; - -export interface ChatContext { - selectedModel: string; - modelConfig: LanguageModelConfig; - - setSelectedModel: (model: string) => void; -} - -export const useChatContext = (): ChatContext => { - const context = useContext(ChatContext); - if (!context) { - throw new Error("useChatContext must be used within a ChatProvider"); - } - return context; -}; - -export const ChatContext = createContext(undefined); - -const SELECTED_MODEL_KEY = "coder_chat_selected_model"; - -export const ChatProvider: FC = ({ children }) => { - const [selectedModel, setSelectedModel] = useState(() => { - const savedModel = localStorage.getItem(SELECTED_MODEL_KEY); - return savedModel || ""; - }); - const modelConfigQuery = useQuery(deploymentLanguageModels()); - useEffect(() => { - if (!modelConfigQuery.data) { - return; - } - if (selectedModel === "") { - const firstModel = modelConfigQuery.data.models[0]?.id; // Handle empty models array - if (firstModel) { - setSelectedModel(firstModel); - localStorage.setItem(SELECTED_MODEL_KEY, firstModel); - } - } - }, [modelConfigQuery.data, selectedModel]); - - if (modelConfigQuery.error) { - return ; - } - - if (!modelConfigQuery.data) { - return ; - } - - const handleSetSelectedModel = (model: string) => { - setSelectedModel(model); - localStorage.setItem(SELECTED_MODEL_KEY, model); - }; - - return ( - - {children} - - ); -}; - -export const ChatLayout: FC = () => { - const queryClient = useQueryClient(); - const { data: chats, isLoading: chatsLoading } = useQuery(getChats()); - const createChatMutation = useMutation(createChat(queryClient)); - const theme = useTheme(); - const navigate = useNavigate(); - const { chatID } = useParams<{ chatID?: string }>(); - - const handleNewChat = () => { - navigate("/chat"); - }; - - console.log(chats); - - return ( - // Outermost container: controls height and prevents page scroll -
- {/* Sidebar Container (using Paper for background/border) */} - - {/* Sidebar Header */} -
- {/* Replaced Typography with div + styling */} -
- Chats -
- -
- {/* Sidebar Scrollable List Area */} -
- {chatsLoading ? ( - - ) : chats && chats.length > 0 ? ( - - {chats.map((chat) => ( - - - - - - ))} - - ) : ( - // Replaced Typography with div + styling -
- No chats yet. Start a new one! -
- )} -
-
- - {/* Main Content Area Container */} -
- - {/* Outlet renders ChatMessages, which should have its own internal scroll */} - - -
-
- ); -}; - -export default ChatLayout; diff --git a/site/src/pages/ChatPage/ChatMessages.tsx b/site/src/pages/ChatPage/ChatMessages.tsx deleted file mode 100644 index 3610d478fb7ef..0000000000000 --- a/site/src/pages/ChatPage/ChatMessages.tsx +++ /dev/null @@ -1,566 +0,0 @@ -import { type Message, useChat } from "@ai-sdk/react"; -import { type Theme, keyframes, useTheme } from "@emotion/react"; -import SendIcon from "@mui/icons-material/Send"; -import IconButton from "@mui/material/IconButton"; -import Paper, { PaperProps } from "@mui/material/Paper"; -import TextField from "@mui/material/TextField"; -import { getChatMessages, getChats } from "api/queries/chats"; -import type { ChatMessage, CreateChatMessageRequest } from "api/typesGenerated"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Loader } from "components/Loader/Loader"; -import { - type FC, - type KeyboardEvent, - memo, - useCallback, - useEffect, - useRef, -} from "react"; -import ReactMarkdown from "react-markdown"; -import { useQuery, useQueryClient } from "react-query"; -import { useLocation, useParams } from "react-router-dom"; -import rehypeRaw from "rehype-raw"; -import remarkGfm from "remark-gfm"; -import type { ChatLandingLocationState } from "./ChatLanding"; -import { useChatContext } from "./ChatLayout"; -import { ChatToolInvocation } from "./ChatToolInvocation"; -import { LanguageModelSelector } from "./LanguageModelSelector"; - -const fadeIn = keyframes` - from { - opacity: 0; - transform: translateY(5px); - } - to { - opacity: 1; - transform: translateY(0); - } -`; - -const pulseAnimation = keyframes` - 0% { opacity: 0.6; } - 50% { opacity: 1; } - 100% { opacity: 0.6; } -`; - -const renderToolInvocation = (toolInvocation: any, theme: Theme) => ( -
-
- 🛠️ Tool Call: {toolInvocation.toolName} -
-
-
- Arguments: -
- {JSON.stringify(toolInvocation.args, null, 2)} -
-
- {toolInvocation.result && ( -
- Result: -
- {JSON.stringify(toolInvocation.result, null, 2)} -
-
- )} -
-
-); - -const renderReasoning = (reasoning: string, theme: Theme) => ( -
-
- 💭 Reasoning: -
-
- {reasoning} -
-
-); - -interface MessageBubbleProps { - message: Message; -} - -const MessageBubble: FC = memo(({ message }) => { - const theme = useTheme(); - const isUser = message.role === "user"; - - return ( -
- code)": { - backgroundColor: isUser - ? theme.palette.grey[700] - : theme.palette.action.hover, - color: isUser ? theme.palette.grey[50] : theme.palette.text.primary, - padding: theme.spacing(0.25, 0.75), - borderRadius: "4px", - fontSize: "0.875em", - fontFamily: "monospace", - }, - "& pre": { - backgroundColor: isUser - ? theme.palette.common.black - : theme.palette.grey[100], - color: isUser - ? theme.palette.grey[100] - : theme.palette.text.primary, - padding: theme.spacing(1.5), - borderRadius: "8px", - overflowX: "auto", - margin: theme.spacing(1.5, 0), - width: "100%", - "& code": { - backgroundColor: "transparent", - padding: 0, - fontSize: "0.875em", - fontFamily: "monospace", - color: "inherit", - }, - }, - "& a": { - color: isUser - ? theme.palette.grey[100] - : theme.palette.primary.main, - textDecoration: "underline", - fontWeight: 500, - "&:hover": { - textDecoration: "none", - color: isUser - ? theme.palette.grey[300] - : theme.palette.primary.dark, - }, - }, - }} - > - {message.role === "assistant" && message.parts ? ( -
- {message.parts.map((part, partIndex) => { - switch (part.type) { - case "text": - return ( - - {part.text} - - ); - case "tool-invocation": - return ( -
- -
- ); - case "reasoning": - return ( -
- {renderReasoning(part.reasoning, theme)} -
- ); - default: - return null; - } - })} -
- ) : ( - - {message.content} - - )} -
-
- ); -}); - -interface ChatViewProps { - messages: Message[]; - input: string; - handleInputChange: React.ChangeEventHandler< - HTMLInputElement | HTMLTextAreaElement - >; - handleSubmit: (e?: React.FormEvent) => void; - isLoading: boolean; - chatID: string; -} - -const ChatView: FC = ({ - messages, - input, - handleInputChange, - handleSubmit, - isLoading, - chatID, -}) => { - const theme = useTheme(); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - const timer = setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ - behavior: "smooth", - block: "end", - }); - }, 50); - return () => clearTimeout(timer); - }, [messages, isLoading]); - - useEffect(() => { - inputRef.current?.focus(); - }, [chatID]); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleSubmit(); - } - }; - - return ( -
-
-
- {messages.map((message, index) => ( - - ))} -
-
-
- -
- -
- -
- - - - -
-
-
- ); -}; - -export const ChatMessages: FC = () => { - const { chatID } = useParams(); - if (!chatID) { - throw new Error("Chat ID is required in URL path /chat/:chatID"); - } - - const { state } = useLocation(); - const transferedState = state as ChatLandingLocationState | undefined; - - const messagesQuery = useQuery(getChatMessages(chatID)); - - const chatContext = useChatContext(); - - const { - messages, - input, - handleInputChange: originalHandleInputChange, - handleSubmit: originalHandleSubmit, - isLoading, - setInput, - setMessages, - } = useChat({ - id: chatID, - api: `/api/v2/chats/${chatID}/messages`, - experimental_prepareRequestBody: (options): CreateChatMessageRequest => { - const userMessages = options.messages.filter( - (message) => message.role === "user", - ); - const mostRecentUserMessage = userMessages.at(-1); - return { - model: chatContext.selectedModel, - message: mostRecentUserMessage, - thinking: false, - }; - }, - initialInput: transferedState?.message, - initialMessages: messagesQuery.data as Message[] | undefined, - }); - - // Update messages from query data when it loads - useEffect(() => { - if (messagesQuery.data && messages.length === 0) { - setMessages(messagesQuery.data as Message[]); - } - }, [messagesQuery.data, messages.length, setMessages]); - - // Wrap handlers in useCallback - const handleInputChange = useCallback(originalHandleInputChange, [ - originalHandleInputChange, - ]); - - const handleSubmitCallback = useCallback( - (e?: React.FormEvent) => { - if (e) e.preventDefault(); - if (!input.trim()) return; - originalHandleSubmit(); - setInput(""); // Clear input after submit - }, - [input, originalHandleSubmit, setInput], - ); - - // Clear input and potentially submit on initial load with message - useEffect(() => { - if (transferedState?.message && input === transferedState.message) { - // Prevent submitting if messages already exist (e.g., browser back/forward) - if (messages.length === (messagesQuery.data?.length ?? 0)) { - handleSubmitCallback(); // Use the correct callback name - } - // Clear the state to prevent re-submission on subsequent renders/navigation - window.history.replaceState({}, document.title); - } - }, [ - transferedState?.message, - input, - handleSubmitCallback, - messages.length, - messagesQuery.data?.length, - ]); // Use the correct callback name - - useEffect(() => { - if (transferedState?.message) { - // Logic potentially related to transferedState can go here if needed, - } - }, [transferedState?.message]); - - if (messagesQuery.error) { - return ; - } - - if (messagesQuery.isLoading && messages.length === 0) { - return ; - } - - return ( - - ); -}; - -export default ChatMessages; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx deleted file mode 100644 index d951b6d715bb5..0000000000000 --- a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx +++ /dev/null @@ -1,1203 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { - MockStartingWorkspace, - MockStoppedWorkspace, - MockStoppingWorkspace, - MockTemplate, - MockTemplateVersion, - MockUser, - MockWorkspace, - MockWorkspaceBuild, -} from "testHelpers/entities"; -import { ChatToolInvocation } from "./ChatToolInvocation"; - -const meta: Meta = { - title: "pages/ChatPage/ChatToolInvocation", - component: ChatToolInvocation, -}; - -export default meta; -type Story = StoryObj; - -export const GetWorkspace: Story = { - render: () => - renderInvocations( - "coder_get_workspace", - { - id: MockWorkspace.id, - }, - MockWorkspace, - ), -}; - -export const CreateWorkspace: Story = { - render: () => - renderInvocations( - "coder_create_workspace", - { - name: MockWorkspace.name, - rich_parameters: {}, - template_version_id: MockWorkspace.template_active_version_id, - user: MockWorkspace.owner_name, - }, - MockWorkspace, - ), -}; - -export const ListWorkspaces: Story = { - render: () => - renderInvocations( - "coder_list_workspaces", - { - owner: "me", - }, - [ - MockWorkspace, - MockStoppedWorkspace, - MockStoppingWorkspace, - MockStartingWorkspace, - ], - ), -}; - -export const ListTemplates: Story = { - render: () => - renderInvocations("coder_list_templates", {}, [ - { - id: MockTemplate.id, - name: MockTemplate.name, - description: MockTemplate.description, - active_version_id: MockTemplate.active_version_id, - active_user_count: MockTemplate.active_user_count, - }, - { - id: "another-template", - name: "Another Template", - description: "A different template for testing purposes.", - active_version_id: "v2.0", - active_user_count: 5, - }, - ]), -}; - -export const TemplateVersionParameters: Story = { - render: () => - renderInvocations( - "coder_template_version_parameters", - { - template_version_id: MockTemplateVersion.id, - }, - [ - { - name: "region", - display_name: "Region", - description: "Select the deployment region.", - description_plaintext: "Select the deployment region.", - type: "string", - mutable: false, - default_value: "us-west-1", - icon: "", - options: [ - { name: "US West", description: "", value: "us-west-1", icon: "" }, - { name: "US East", description: "", value: "us-east-1", icon: "" }, - ], - required: true, - ephemeral: false, - }, - { - name: "cpu_cores", - display_name: "CPU Cores", - description: "Number of CPU cores.", - description_plaintext: "Number of CPU cores.", - type: "number", - mutable: true, - default_value: "4", - icon: "", - options: [], - required: false, - ephemeral: false, - }, - ], - ), -}; - -export const GetAuthenticatedUser: Story = { - render: () => renderInvocations("coder_get_authenticated_user", {}, MockUser), -}; - -export const CreateWorkspaceBuild: Story = { - render: () => - renderInvocations( - "coder_create_workspace_build", - { - workspace_id: MockWorkspace.id, - transition: "start", - }, - MockWorkspaceBuild, - ), -}; - -export const CreateTemplateVersion: Story = { - render: () => - renderInvocations( - "coder_create_template_version", - { - template_id: MockTemplate.id, - file_id: "file-123", - }, - MockTemplateVersion, - ), -}; - -const mockLogs = [ - "[INFO] Starting build process...", - "[DEBUG] Reading configuration file.", - "[WARN] Deprecated setting detected.", - "[INFO] Applying changes...", - "[ERROR] Failed to connect to database.", -]; - -export const GetWorkspaceAgentLogs: Story = { - render: () => - renderInvocations( - "coder_get_workspace_agent_logs", - { - workspace_agent_id: "agent-456", - }, - mockLogs, - ), -}; - -export const GetWorkspaceBuildLogs: Story = { - render: () => - renderInvocations( - "coder_get_workspace_build_logs", - { - workspace_build_id: MockWorkspaceBuild.id, - }, - mockLogs, - ), -}; - -export const GetTemplateVersionLogs: Story = { - render: () => - renderInvocations( - "coder_get_template_version_logs", - { - template_version_id: MockTemplateVersion.id, - }, - mockLogs, - ), -}; - -export const UpdateTemplateActiveVersion: Story = { - render: () => - renderInvocations( - "coder_update_template_active_version", - { - template_id: MockTemplate.id, - template_version_id: MockTemplateVersion.id, - }, - `Successfully updated active version for template ${MockTemplate.name}.`, - ), -}; - -export const UploadTarFile: Story = { - render: () => - renderInvocations( - "coder_upload_tar_file", - { - mime_type: "application/x-tar", - files: { "main.tf": templateTerraform, Dockerfile: templateDockerfile }, - }, - { - hash: "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", - }, - ), -}; - -export const CreateTemplate: Story = { - render: () => - renderInvocations( - "coder_create_template", - { - name: "new-template", - }, - MockTemplate, - ), -}; - -export const DeleteTemplate: Story = { - render: () => - renderInvocations( - "coder_delete_template", - { - template_id: MockTemplate.id, - }, - `Successfully deleted template ${MockTemplate.name}.`, - ), -}; - -export const GetTemplateVersion: Story = { - render: () => - renderInvocations( - "coder_get_template_version", - { - template_version_id: MockTemplateVersion.id, - }, - MockTemplateVersion, - ), -}; - -export const DownloadTarFile: Story = { - render: () => - renderInvocations( - "coder_download_tar_file", - { - file_id: "file-789", - }, - { "main.tf": templateTerraform, "README.md": "# My Template\n" }, - ), -}; - -const renderInvocations = ( - toolName: T, - args: Extract["args"], - result: Extract< - ChatToolInvocation, - { toolName: T; state: "result" } - >["result"], - error?: string, -) => { - return ( - <> - - - - - - ); -}; - -const templateDockerfile = `FROM rust:slim@sha256:9abf10cc84dfad6ace1b0aae3951dc5200f467c593394288c11db1e17bb4d349 AS rust-utils -# Install rust helper programs -# ENV CARGO_NET_GIT_FETCH_WITH_CLI=true -ENV CARGO_INSTALL_ROOT=/tmp/ -RUN cargo install typos-cli watchexec-cli && \ - # Reduce image size. - rm -rf /usr/local/cargo/registry - -FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 AS go - -# Install Go manually, so that we can control the version -ARG GO_VERSION=1.24.1 - -# Boring Go is needed to build FIPS-compliant binaries. -RUN apt-get update && \ - apt-get install --yes curl && \ - curl --silent --show-error --location \ - "https://go.dev/dl/go\${GO_VERSION}.linux-amd64.tar.gz" \ - -o /usr/local/go.tar.gz && \ - rm -rf /var/lib/apt/lists/* - -ENV PATH=$PATH:/usr/local/go/bin -ARG GOPATH="/tmp/" -# Install Go utilities. -RUN apt-get update && \ - apt-get install --yes gcc && \ - mkdir --parents /usr/local/go && \ - tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 && \ - mkdir --parents "$GOPATH" && \ - # moq for Go tests. - go install github.com/matryer/moq@v0.2.3 && \ - # swag for Swagger doc generation - go install github.com/swaggo/swag/cmd/swag@v1.7.4 && \ - # go-swagger tool to generate the go coder api client - go install github.com/go-swagger/go-swagger/cmd/swagger@v0.28.0 && \ - # goimports for updating imports - go install golang.org/x/tools/cmd/goimports@v0.31.0 && \ - # protoc-gen-go is needed to build sysbox from source - go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 && \ - # drpc support for v2 - go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 && \ - # migrate for migration support for v2 - go install github.com/golang-migrate/migrate/v4/cmd/migrate@v4.15.1 && \ - # goreleaser for compiling v2 binaries - go install github.com/goreleaser/goreleaser@v1.6.1 && \ - # Install the latest version of gopls for editors that support - # the language server protocol - go install golang.org/x/tools/gopls@v0.18.1 && \ - # gotestsum makes test output more readable - go install gotest.tools/gotestsum@v1.9.0 && \ - # goveralls collects code coverage metrics from tests - # and sends to Coveralls - go install github.com/mattn/goveralls@v0.0.11 && \ - # kind for running Kubernetes-in-Docker, needed for tests - go install sigs.k8s.io/kind@v0.10.0 && \ - # helm-docs generates our Helm README based on a template and the - # charts and values files - go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.5.0 && \ - # sqlc for Go code generation - (CGO_ENABLED=1 go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.27.0) && \ - # gcr-cleaner-cli used by CI to prune unused images - go install github.com/sethvargo/gcr-cleaner/cmd/gcr-cleaner-cli@v0.5.1 && \ - # ruleguard for checking custom rules, without needing to run all of - # golangci-lint. Check the go.mod in the release of golangci-lint that - # we're using for the version of go-critic that it embeds, then check - # the version of ruleguard in go-critic for that tag. - go install github.com/quasilyte/go-ruleguard/cmd/ruleguard@v0.3.13 && \ - # go-releaser for building 'fat binaries' that work cross-platform - go install github.com/goreleaser/goreleaser@v1.6.1 && \ - go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0 && \ - # nfpm is used with \`make build\` to make release packages - go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1 && \ - # yq v4 is used to process yaml files in coder v2. Conflicts with - # yq v3 used in v1. - go install github.com/mikefarah/yq/v4@v4.44.3 && \ - mv /tmp/bin/yq /tmp/bin/yq4 && \ - go install go.uber.org/mock/mockgen@v0.5.0 && \ - # Reduce image size. - apt-get remove --yes gcc && \ - apt-get autoremove --yes && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* && \ - rm -rf /usr/local/go && \ - rm -rf /tmp/go/pkg && \ - rm -rf /tmp/go/src - -# alpine:3.18 -FROM gcr.io/coder-dev-1/alpine@sha256:25fad2a32ad1f6f510e528448ae1ec69a28ef81916a004d3629874104f8a7f70 AS proto -WORKDIR /tmp -RUN apk add curl unzip -RUN curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip && \ - unzip protoc.zip && \ - rm protoc.zip - -FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 - -SHELL ["/bin/bash", "-c"] - -# Install packages from apt repositories -ARG DEBIAN_FRONTEND="noninteractive" - -# Updated certificates are necessary to use the teraswitch mirror. -# This must be ran before copying in configuration since the config replaces -# the default mirror with teraswitch. -# Also enable the en_US.UTF-8 locale so that we don't generate multiple locales -# and unminimize to include man pages. -RUN apt-get update && \ - apt-get install --yes ca-certificates locales && \ - echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ - locale-gen && \ - yes | unminimize - -COPY files / - -# We used to copy /etc/sudoers.d/* in from files/ but this causes issues with -# permissions and layer caching. Instead, create the file directly. -RUN mkdir -p /etc/sudoers.d && \ - echo 'coder ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/nopasswd && \ - chmod 750 /etc/sudoers.d/ && \ - chmod 640 /etc/sudoers.d/nopasswd - -RUN apt-get update --quiet && apt-get install --yes \ - ansible \ - apt-transport-https \ - apt-utils \ - asciinema \ - bash \ - bash-completion \ - bat \ - bats \ - bind9-dnsutils \ - build-essential \ - ca-certificates \ - cargo \ - cmake \ - containerd.io \ - crypto-policies \ - curl \ - docker-ce \ - docker-ce-cli \ - docker-compose-plugin \ - exa \ - fd-find \ - file \ - fish \ - gettext-base \ - git \ - gnupg \ - google-cloud-sdk \ - google-cloud-sdk-datastore-emulator \ - graphviz \ - helix \ - htop \ - httpie \ - inetutils-tools \ - iproute2 \ - iputils-ping \ - iputils-tracepath \ - jq \ - kubectl \ - language-pack-en \ - less \ - libgbm-dev \ - libssl-dev \ - lsb-release \ - lsof \ - man \ - meld \ - ncdu \ - neovim \ - net-tools \ - openjdk-11-jdk-headless \ - openssh-server \ - openssl \ - packer \ - pkg-config \ - postgresql-16 \ - python3 \ - python3-pip \ - ripgrep \ - rsync \ - screen \ - shellcheck \ - strace \ - sudo \ - tcptraceroute \ - termshark \ - traceroute \ - unzip \ - vim \ - wget \ - xauth \ - zip \ - zsh \ - zstd && \ - # Delete package cache to avoid consuming space in layer - apt-get clean && \ - # Configure FIPS-compliant policies - update-crypto-policies --set FIPS - -# NOTE: In scripts/Dockerfile.base we specifically install Terraform version 1.11.3. -# Installing the same version here to match. -RUN wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.11.3/terraform_1.11.3_linux_amd64.zip" && \ - unzip /tmp/terraform.zip -d /usr/local/bin && \ - rm -f /tmp/terraform.zip && \ - chmod +x /usr/local/bin/terraform && \ - terraform --version - -# Install the docker buildx component. -RUN DOCKER_BUILDX_VERSION=$(curl -s "https://api.github.com/repos/docker/buildx/releases/latest" | grep '"tag_name":' | sed -E 's/.*"(v[^"]+)".*/\\1/') && \ - mkdir -p /usr/local/lib/docker/cli-plugins && \ - curl -Lo /usr/local/lib/docker/cli-plugins/docker-buildx "https://github.com/docker/buildx/releases/download/\${DOCKER_BUILDX_VERSION}/buildx-\${DOCKER_BUILDX_VERSION}.linux-amd64" && \ - chmod a+x /usr/local/lib/docker/cli-plugins/docker-buildx - -# See https://github.com/cli/cli/issues/6175#issuecomment-1235984381 for proof -# the apt repository is unreliable -RUN GH_CLI_VERSION=$(curl -s "https://api.github.com/repos/cli/cli/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\\1/') && \ - curl -L https://github.com/cli/cli/releases/download/v\${GH_CLI_VERSION}/gh_\${GH_CLI_VERSION}_linux_amd64.deb -o gh.deb && \ - dpkg -i gh.deb && \ - rm gh.deb - -# Install Lazygit -# See https://github.com/jesseduffield/lazygit#ubuntu -RUN LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v*([^"]+)".*/\\1/') && \ - curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_\${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" && \ - tar xf lazygit.tar.gz -C /usr/local/bin lazygit && \ - rm lazygit.tar.gz - -# Install doctl -# See https://docs.digitalocean.com/reference/doctl/how-to/install -RUN DOCTL_VERSION=$(curl -s "https://api.github.com/repos/digitalocean/doctl/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\\1/') && \ - curl -L https://github.com/digitalocean/doctl/releases/download/v\${DOCTL_VERSION}/doctl-\${DOCTL_VERSION}-linux-amd64.tar.gz -o doctl.tar.gz && \ - tar xf doctl.tar.gz -C /usr/local/bin doctl && \ - rm doctl.tar.gz - -ARG NVM_INSTALL_SHA=bdea8c52186c4dd12657e77e7515509cda5bf9fa5a2f0046bce749e62645076d -# Install frontend utilities -ENV NVM_DIR=/usr/local/nvm -ENV NODE_VERSION=20.16.0 -RUN mkdir -p $NVM_DIR -RUN curl -o nvm_install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh && \ - echo "\${NVM_INSTALL_SHA} nvm_install.sh" | sha256sum -c && \ - bash nvm_install.sh && \ - rm nvm_install.sh -RUN source $NVM_DIR/nvm.sh && \ - nvm install $NODE_VERSION && \ - nvm use $NODE_VERSION -ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH -# Allow patch updates for npm and pnpm -RUN npm install -g npm@10.8.1 --integrity=sha512-Dp1C6SvSMYQI7YHq/y2l94uvI+59Eqbu1EpuKQHQ8p16txXRuRit5gH3Lnaagk2aXDIjg/Iru9pd05bnneKgdw== -RUN npm install -g pnpm@9.15.1 --integrity=sha512-GstWXmGT7769p3JwKVBGkVDPErzHZCYudYfnHRncmKQj3/lTblfqRMSb33kP9pToPCe+X6oj1n4MAztYO+S/zw== - -RUN pnpx playwright@1.47.0 install --with-deps chromium - -# Ensure PostgreSQL binaries are in the users $PATH. -RUN update-alternatives --install /usr/local/bin/initdb initdb /usr/lib/postgresql/16/bin/initdb 100 && \ - update-alternatives --install /usr/local/bin/postgres postgres /usr/lib/postgresql/16/bin/postgres 100 - -# Create links for injected dependencies -RUN ln --symbolic /var/tmp/coder/coder-cli/coder /usr/local/bin/coder && \ - ln --symbolic /var/tmp/coder/code-server/bin/code-server /usr/local/bin/code-server - -# Disable the PostgreSQL systemd service. -# Coder uses a custom timescale container to test the database instead. -RUN systemctl disable \ - postgresql - -# Configure systemd services for CVMs -RUN systemctl enable \ - docker \ - ssh && \ - # Workaround for envbuilder cache probing not working unless the filesystem is modified. - touch /tmp/.envbuilder-systemctl-enable-docker-ssh-workaround - -# Install tools with published releases, where that is the -# preferred/recommended installation method. -ARG CLOUD_SQL_PROXY_VERSION=2.2.0 \ - DIVE_VERSION=0.10.0 \ - DOCKER_GCR_VERSION=2.1.8 \ - GOLANGCI_LINT_VERSION=1.64.8 \ - GRYPE_VERSION=0.61.1 \ - HELM_VERSION=3.12.0 \ - KUBE_LINTER_VERSION=0.6.3 \ - KUBECTX_VERSION=0.9.4 \ - STRIPE_VERSION=1.14.5 \ - TERRAGRUNT_VERSION=0.45.11 \ - TRIVY_VERSION=0.41.0 \ - SYFT_VERSION=1.20.0 \ - COSIGN_VERSION=2.4.3 - -# cloud_sql_proxy, for connecting to cloudsql instances -# the upstream go.mod prevents this from being installed with go install -RUN curl --silent --show-error --location --output /usr/local/bin/cloud_sql_proxy "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v\${CLOUD_SQL_PROXY_VERSION}/cloud-sql-proxy.linux.amd64" && \ - chmod a=rx /usr/local/bin/cloud_sql_proxy && \ - # dive for scanning image layer utilization metrics in CI - curl --silent --show-error --location "https://github.com/wagoodman/dive/releases/download/v\${DIVE_VERSION}/dive_\${DIVE_VERSION}_linux_amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- dive && \ - # docker-credential-gcr is a Docker credential helper for pushing/pulling - # images from Google Container Registry and Artifact Registry - curl --silent --show-error --location "https://github.com/GoogleCloudPlatform/docker-credential-gcr/releases/download/v\${DOCKER_GCR_VERSION}/docker-credential-gcr_linux_amd64-\${DOCKER_GCR_VERSION}.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- docker-credential-gcr && \ - # golangci-lint performs static code analysis for our Go code - curl --silent --show-error --location "https://github.com/golangci/golangci-lint/releases/download/v\${GOLANGCI_LINT_VERSION}/golangci-lint-\${GOLANGCI_LINT_VERSION}-linux-amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- --strip-components=1 "golangci-lint-\${GOLANGCI_LINT_VERSION}-linux-amd64/golangci-lint" && \ - # Anchore Grype for scanning container images for security issues - curl --silent --show-error --location "https://github.com/anchore/grype/releases/download/v\${GRYPE_VERSION}/grype_\${GRYPE_VERSION}_linux_amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- grype && \ - # Helm is necessary for deploying Coder - curl --silent --show-error --location "https://get.helm.sh/helm-v\${HELM_VERSION}-linux-amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- --strip-components=1 linux-amd64/helm && \ - # kube-linter for linting Kubernetes objects, including those - # that Helm generates from our charts - curl --silent --show-error --location "https://github.com/stackrox/kube-linter/releases/download/\${KUBE_LINTER_VERSION}/kube-linter-linux" --output /usr/local/bin/kube-linter && \ - # kubens and kubectx for managing Kubernetes namespaces and contexts - curl --silent --show-error --location "https://github.com/ahmetb/kubectx/releases/download/v\${KUBECTX_VERSION}/kubectx_v\${KUBECTX_VERSION}_linux_x86_64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- kubectx && \ - curl --silent --show-error --location "https://github.com/ahmetb/kubectx/releases/download/v\${KUBECTX_VERSION}/kubens_v\${KUBECTX_VERSION}_linux_x86_64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- kubens && \ - # stripe for coder.com billing API - curl --silent --show-error --location "https://github.com/stripe/stripe-cli/releases/download/v\${STRIPE_VERSION}/stripe_\${STRIPE_VERSION}_linux_x86_64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- stripe && \ - # terragrunt for running Terraform and Terragrunt files - curl --silent --show-error --location --output /usr/local/bin/terragrunt "https://github.com/gruntwork-io/terragrunt/releases/download/v\${TERRAGRUNT_VERSION}/terragrunt_linux_amd64" && \ - chmod a=rx /usr/local/bin/terragrunt && \ - # AquaSec Trivy for scanning container images for security issues - curl --silent --show-error --location "https://github.com/aquasecurity/trivy/releases/download/v\${TRIVY_VERSION}/trivy_\${TRIVY_VERSION}_Linux-64bit.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- trivy && \ - # Anchore Syft for SBOM generation - curl --silent --show-error --location "https://github.com/anchore/syft/releases/download/v\${SYFT_VERSION}/syft_\${SYFT_VERSION}_linux_amd64.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/bin --file=- syft && \ - # Sigstore Cosign for artifact signing and attestation - curl --silent --show-error --location --output /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/v\${COSIGN_VERSION}/cosign-linux-amd64" && \ - chmod a=rx /usr/local/bin/cosign - -# We use yq during "make deploy" to manually substitute out fields in -# our helm values.yaml file. See https://github.com/helm/helm/issues/3141 -# -# TODO: update to 4.x, we can't do this now because it included breaking -# changes (yq w doesn't work anymore) -# RUN curl --silent --show-error --location "https://github.com/mikefarah/yq/releases/download/v4.9.0/yq_linux_amd64.tar.gz" | \ -# tar --extract --gzip --directory=/usr/local/bin --file=- ./yq_linux_amd64 && \ -# mv /usr/local/bin/yq_linux_amd64 /usr/local/bin/yq - -RUN curl --silent --show-error --location --output /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/3.3.0/yq_linux_amd64" && \ - chmod a=rx /usr/local/bin/yq - -# Install GoLand. -RUN mkdir --parents /usr/local/goland && \ - curl --silent --show-error --location "https://download.jetbrains.com/go/goland-2021.2.tar.gz" | \ - tar --extract --gzip --directory=/usr/local/goland --file=- --strip-components=1 && \ - ln --symbolic /usr/local/goland/bin/goland.sh /usr/local/bin/goland - -# Install Antlrv4, needed to generate paramlang lexer/parser -RUN curl --silent --show-error --location --output /usr/local/lib/antlr-4.9.2-complete.jar "https://www.antlr.org/download/antlr-4.9.2-complete.jar" -ENV CLASSPATH="/usr/local/lib/antlr-4.9.2-complete.jar:\${PATH}" - -# Add coder user and allow use of docker/sudo -RUN useradd coder \ - --create-home \ - --shell=/bin/bash \ - --groups=docker \ - --uid=1000 \ - --user-group - -# Adjust OpenSSH config -RUN echo "PermitUserEnvironment yes" >>/etc/ssh/sshd_config && \ - echo "X11Forwarding yes" >>/etc/ssh/sshd_config && \ - echo "X11UseLocalhost no" >>/etc/ssh/sshd_config - -# We avoid copying the extracted directory since COPY slows to minutes when there -# are a lot of small files. -COPY --from=go /usr/local/go.tar.gz /usr/local/go.tar.gz -RUN mkdir /usr/local/go && \ - tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 - -ENV PATH=$PATH:/usr/local/go/bin - -RUN update-alternatives --install /usr/local/bin/gofmt gofmt /usr/local/go/bin/gofmt 100 - -COPY --from=go /tmp/bin /usr/local/bin -COPY --from=rust-utils /tmp/bin /usr/local/bin -COPY --from=proto /tmp/bin /usr/local/bin -COPY --from=proto /tmp/include /usr/local/bin/include - -USER coder - -# Ensure go bins are in the 'coder' user's path. Note that no go bins are -# installed in this docker file, as they'd be mounted over by the persistent -# home volume. -ENV PATH="/home/coder/go/bin:\${PATH}" - -# This setting prevents Go from using the public checksum database for -# our module path prefixes. It is required because these are in private -# repositories that require authentication. -# -# For details, see: https://golang.org/ref/mod#private-modules -ENV GOPRIVATE="coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder" - -# Increase memory allocation to NodeJS -ENV NODE_OPTIONS="--max-old-space-size=8192" -`; - -const templateTerraform = `terraform { - required_providers { - coder = { - source = "coder/coder" - version = "2.2.0-pre0" - } - docker = { - source = "kreuzwerker/docker" - version = "~> 3.0.0" - } - } -} - -locals { - // These are cluster service addresses mapped to Tailscale nodes. Ask Dean or - // Kyle for help. - docker_host = { - "" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" - "us-pittsburgh" = "tcp://dogfood-ts-cdr-dev.tailscale.svc.cluster.local:2375" - // For legacy reasons, this host is labelled \`eu-helsinki\` but it's - // actually in Germany now. - "eu-helsinki" = "tcp://katerose-fsn-cdr-dev.tailscale.svc.cluster.local:2375" - "ap-sydney" = "tcp://wolfgang-syd-cdr-dev.tailscale.svc.cluster.local:2375" - "sa-saopaulo" = "tcp://oberstein-sao-cdr-dev.tailscale.svc.cluster.local:2375" - "za-cpt" = "tcp://schonkopf-cpt-cdr-dev.tailscale.svc.cluster.local:2375" - } - - repo_base_dir = data.coder_parameter.repo_base_dir.value == "~" ? "/home/coder" : replace(data.coder_parameter.repo_base_dir.value, "/^~\\//", "/home/coder/") - repo_dir = replace(try(module.git-clone[0].repo_dir, ""), "/^~\\//", "/home/coder/") - container_name = "coder-\${data.coder_workspace_owner.me.name}-\${lower(data.coder_workspace.me.name)}" -} - -data "coder_parameter" "repo_base_dir" { - type = "string" - name = "Coder Repository Base Directory" - default = "~" - description = "The directory specified will be created (if missing) and [coder/coder](https://github.com/coder/coder) will be automatically cloned into [base directory]/coder 🪄." - mutable = true -} - -data "coder_parameter" "image_type" { - type = "string" - name = "Coder Image" - default = "codercom/oss-dogfood:latest" - description = "The Docker image used to run your workspace. Choose between nix and non-nix images." - option { - icon = "/icon/coder.svg" - name = "Dogfood (Default)" - value = "codercom/oss-dogfood:latest" - } - option { - icon = "/icon/nix.svg" - name = "Dogfood Nix (Experimental)" - value = "codercom/oss-dogfood-nix:latest" - } -} - -data "coder_parameter" "region" { - type = "string" - name = "Region" - icon = "/emojis/1f30e.png" - default = "us-pittsburgh" - option { - icon = "/emojis/1f1fa-1f1f8.png" - name = "Pittsburgh" - value = "us-pittsburgh" - } - option { - icon = "/emojis/1f1e9-1f1ea.png" - name = "Falkenstein" - // For legacy reasons, this host is labelled \`eu-helsinki\` but it's - // actually in Germany now. - value = "eu-helsinki" - } - option { - icon = "/emojis/1f1e6-1f1fa.png" - name = "Sydney" - value = "ap-sydney" - } - option { - icon = "/emojis/1f1e7-1f1f7.png" - name = "São Paulo" - value = "sa-saopaulo" - } - option { - icon = "/emojis/1f1ff-1f1e6.png" - name = "Cape Town" - value = "za-cpt" - } -} - -data "coder_parameter" "res_mon_memory_threshold" { - type = "number" - name = "Memory usage threshold" - default = 80 - description = "The memory usage threshold used in resources monitoring to trigger notifications." - mutable = true - validation { - min = 0 - max = 100 - } -} - -data "coder_parameter" "res_mon_volume_threshold" { - type = "number" - name = "Volume usage threshold" - default = 90 - description = "The volume usage threshold used in resources monitoring to trigger notifications." - mutable = true - validation { - min = 0 - max = 100 - } -} - -data "coder_parameter" "res_mon_volume_path" { - type = "string" - name = "Volume path" - default = "/home/coder" - description = "The path monitored in resources monitoring to trigger notifications." - mutable = true -} - -provider "docker" { - host = lookup(local.docker_host, data.coder_parameter.region.value) -} - -provider "coder" {} - -data "coder_external_auth" "github" { - id = "github" -} - -data "coder_workspace" "me" {} -data "coder_workspace_owner" "me" {} -data "coder_workspace_tags" "tags" { - tags = { - "cluster" : "dogfood-v2" - "env" : "gke" - } -} - -module "slackme" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/slackme/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - auth_provider_id = "slack" -} - -module "dotfiles" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/dotfiles/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id -} - -module "git-clone" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/git-clone/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - url = "https://github.com/coder/coder" - base_dir = local.repo_base_dir -} - -module "personalize" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/personalize/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id -} - -module "code-server" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/code-server/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - folder = local.repo_dir - auto_install_extensions = true -} - -module "vscode-web" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/modules/vscode-web/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - folder = local.repo_dir - extensions = ["github.copilot"] - auto_install_extensions = true # will install extensions from the repos .vscode/extensions.json file - accept_license = true -} - -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/jetbrains-gateway/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - agent_name = "dev" - folder = local.repo_dir - jetbrains_ides = ["GO", "WS"] - default = "GO" - latest = true -} - -module "filebrowser" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/filebrowser/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - agent_name = "dev" -} - -module "coder-login" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/coder-login/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id -} - -module "cursor" { - count = data.coder_workspace.me.start_count - source = "dev.registry.coder.com/modules/cursor/coder" - version = ">= 1.0.0" - agent_id = coder_agent.dev.id - folder = local.repo_dir -} - -module "zed" { - count = data.coder_workspace.me.start_count - source = "./zed" - agent_id = coder_agent.dev.id - folder = local.repo_dir -} - -resource "coder_agent" "dev" { - arch = "amd64" - os = "linux" - dir = local.repo_dir - env = { - OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token, - } - startup_script_behavior = "blocking" - - # The following metadata blocks are optional. They are used to display - # information about your workspace in the dashboard. You can remove them - # if you don't want to display any information. - metadata { - display_name = "CPU Usage" - key = "cpu_usage" - order = 0 - script = "coder stat cpu" - interval = 10 - timeout = 1 - } - - metadata { - display_name = "RAM Usage" - key = "ram_usage" - order = 1 - script = "coder stat mem" - interval = 10 - timeout = 1 - } - - metadata { - display_name = "CPU Usage (Host)" - key = "cpu_usage_host" - order = 2 - script = "coder stat cpu --host" - interval = 10 - timeout = 1 - } - - metadata { - display_name = "RAM Usage (Host)" - key = "ram_usage_host" - order = 3 - script = "coder stat mem --host" - interval = 10 - timeout = 1 - } - - metadata { - display_name = "Swap Usage (Host)" - key = "swap_usage_host" - order = 4 - script = <&1 | awk ' $0 ~ "Word of the Day: [A-z]+" { print $5; exit }' - EOT - interval = 86400 - timeout = 5 - } - - resources_monitoring { - memory { - enabled = true - threshold = data.coder_parameter.res_mon_memory_threshold.value - } - volume { - enabled = true - threshold = data.coder_parameter.res_mon_volume_threshold.value - path = data.coder_parameter.res_mon_volume_path.value - } - } - - startup_script = <<-EOT - #!/usr/bin/env bash - set -eux -o pipefail - - # Allow synchronization between scripts. - trap 'touch /tmp/.coder-startup-script.done' EXIT - - # Start Docker service - sudo service docker start - # Install playwright dependencies - # We want to use the playwright version from site/package.json - # Check if the directory exists At workspace creation as the coder_script runs in parallel so clone might not exist yet. - while ! [[ -f "\${local.repo_dir}/site/package.json" ]]; do - sleep 1 - done - cd "\${local.repo_dir}" && make clean - cd "\${local.repo_dir}/site" && pnpm install - EOT - - shutdown_script = <<-EOT - #!/usr/bin/env bash - set -eux -o pipefail - - # Stop the Docker service to prevent errors during workspace destroy. - sudo service docker stop - EOT -} - -# Add a cost so we get some quota usage in dev.coder.com -resource "coder_metadata" "home_volume" { - resource_id = docker_volume.home_volume.id - daily_cost = 1 -} - -resource "docker_volume" "home_volume" { - name = "coder-\${data.coder_workspace.me.id}-home" - # Protect the volume from being deleted due to changes in attributes. - lifecycle { - ignore_changes = all - } - # Add labels in Docker to keep track of orphan resources. - labels { - label = "coder.owner" - value = data.coder_workspace_owner.me.name - } - labels { - label = "coder.owner_id" - value = data.coder_workspace_owner.me.id - } - labels { - label = "coder.workspace_id" - value = data.coder_workspace.me.id - } - # This field becomes outdated if the workspace is renamed but can - # be useful for debugging or cleaning out dangling volumes. - labels { - label = "coder.workspace_name_at_creation" - value = data.coder_workspace.me.name - } -} - -data "docker_registry_image" "dogfood" { - name = data.coder_parameter.image_type.value -} - -resource "docker_image" "dogfood" { - name = "\${data.coder_parameter.image_type.value}@\${data.docker_registry_image.dogfood.sha256_digest}" - pull_triggers = [ - data.docker_registry_image.dogfood.sha256_digest, - sha1(join("", [for f in fileset(path.module, "files/*") : filesha1(f)])), - filesha1("Dockerfile"), - filesha1("nix.hash"), - ] - keep_locally = true -} - -resource "docker_container" "workspace" { - count = data.coder_workspace.me.start_count - image = docker_image.dogfood.name - name = local.container_name - # Hostname makes the shell more user friendly: coder@my-workspace:~$ - hostname = data.coder_workspace.me.name - # Use the docker gateway if the access URL is 127.0.0.1 - entrypoint = ["sh", "-c", coder_agent.dev.init_script] - # CPU limits are unnecessary since Docker will load balance automatically - memory = data.coder_workspace_owner.me.name == "code-asher" ? 65536 : 32768 - runtime = "sysbox-runc" - # Ensure the workspace is given time to execute shutdown scripts. - destroy_grace_seconds = 60 - stop_timeout = 60 - stop_signal = "SIGINT" - env = [ - "CODER_AGENT_TOKEN=\${coder_agent.dev.token}", - "USE_CAP_NET_ADMIN=true", - "CODER_PROC_PRIO_MGMT=1", - "CODER_PROC_OOM_SCORE=10", - "CODER_PROC_NICE_SCORE=1", - "CODER_AGENT_DEVCONTAINERS_ENABLE=1", - ] - host { - host = "host.docker.internal" - ip = "host-gateway" - } - volumes { - container_path = "/home/coder/" - volume_name = docker_volume.home_volume.name - read_only = false - } - capabilities { - add = ["CAP_NET_ADMIN", "CAP_SYS_NICE"] - } - # Add labels in Docker to keep track of orphan resources. - labels { - label = "coder.owner" - value = data.coder_workspace_owner.me.name - } - labels { - label = "coder.owner_id" - value = data.coder_workspace_owner.me.id - } - labels { - label = "coder.workspace_id" - value = data.coder_workspace.me.id - } - labels { - label = "coder.workspace_name" - value = data.coder_workspace.me.name - } -} - -resource "coder_metadata" "container_info" { - count = data.coder_workspace.me.start_count - resource_id = docker_container.workspace[0].id - item { - key = "memory" - value = docker_container.workspace[0].memory - } - item { - key = "runtime" - value = docker_container.workspace[0].runtime - } - item { - key = "region" - value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name - } -} -`; diff --git a/site/src/pages/ChatPage/ChatToolInvocation.tsx b/site/src/pages/ChatPage/ChatToolInvocation.tsx deleted file mode 100644 index 534fd5a9d80d2..0000000000000 --- a/site/src/pages/ChatPage/ChatToolInvocation.tsx +++ /dev/null @@ -1,876 +0,0 @@ -import type { ToolCall, ToolResult } from "@ai-sdk/provider-utils"; -import { useTheme } from "@emotion/react"; -import ArticleIcon from "@mui/icons-material/Article"; -import BuildIcon from "@mui/icons-material/Build"; -import CheckCircle from "@mui/icons-material/CheckCircle"; -import CodeIcon from "@mui/icons-material/Code"; -import DeleteIcon from "@mui/icons-material/Delete"; -import ErrorIcon from "@mui/icons-material/Error"; -import FileUploadIcon from "@mui/icons-material/FileUpload"; -import ListIcon from "@mui/icons-material/List"; -import PersonIcon from "@mui/icons-material/Person"; -import SettingsIcon from "@mui/icons-material/Settings"; -import TerminalIcon from "@mui/icons-material/Terminal"; -import Avatar from "@mui/material/Avatar"; -import CircularProgress from "@mui/material/CircularProgress"; -import Tooltip from "@mui/material/Tooltip"; -import type * as TypesGen from "api/typesGenerated"; -import { InfoIcon } from "lucide-react"; -import type React from "react"; -import { type FC, memo, useMemo, useState } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism"; -import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; -import { TabLink, Tabs, TabsList } from "../../components/Tabs/Tabs"; - -interface ChatToolInvocationProps { - toolInvocation: ChatToolInvocation; -} - -export const ChatToolInvocation: FC = ({ - toolInvocation, -}) => { - const theme = useTheme(); - const friendlyName = useMemo(() => { - return toolInvocation.toolName - .replace("coder_", "") - .replace(/_/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()); - }, [toolInvocation.toolName]); - - const hasError = useMemo(() => { - if (toolInvocation.state !== "result") { - return false; - } - return ( - typeof toolInvocation.result === "object" && - toolInvocation.result !== null && - "error" in toolInvocation.result - ); - }, [toolInvocation]); - const statusColor = useMemo(() => { - if (toolInvocation.state !== "result") { - return theme.palette.info.main; - } - return hasError ? theme.palette.error.main : theme.palette.success.main; - }, [toolInvocation, hasError, theme]); - const tooltipContent = useMemo(() => { - return ( - - {JSON.stringify(toolInvocation, null, 2)} - - ); - }, [toolInvocation]); - - return ( -
-
- {toolInvocation.state !== "result" && ( - - )} - {toolInvocation.state === "result" ? ( - hasError ? ( - - ) : ( - - ) - ) : null} -
- {friendlyName} -
- - - -
- {toolInvocation.state === "result" ? ( - - ) : ( - - )} -
- ); -}; - -const ChatToolInvocationCallPreview: FC<{ - toolInvocation: Extract< - ChatToolInvocation, - { state: "call" | "partial-call" } - >; -}> = memo(({ toolInvocation }) => { - const theme = useTheme(); - - let content: React.ReactNode; - switch (toolInvocation.toolName) { - case "coder_upload_tar_file": - content = ( - - ); - break; - } - - if (!content) { - return null; - } - - return
{content}
; -}); - -const ChatToolInvocationResultPreview: FC<{ - toolInvocation: Extract; -}> = memo(({ toolInvocation }) => { - const theme = useTheme(); - - if (!toolInvocation.result) { - return null; - } - - if ( - typeof toolInvocation.result === "object" && - "error" in toolInvocation.result - ) { - return null; - } - - let content: React.ReactNode; - switch (toolInvocation.toolName) { - case "coder_get_workspace": - case "coder_create_workspace": - content = ( -
- {toolInvocation.result.template_icon && ( - {toolInvocation.result.template_display_name - )} -
-
- {toolInvocation.result.name} -
-
- {toolInvocation.result.template_display_name} -
-
-
- ); - break; - case "coder_list_workspaces": - content = ( -
- {toolInvocation.result.map((workspace) => ( -
- {workspace.template_icon && ( - {workspace.template_display_name - )} -
-
- {workspace.name} -
-
- {workspace.template_display_name} -
-
-
- ))} -
- ); - break; - case "coder_list_templates": { - const templates = toolInvocation.result; - content = ( -
- {templates.map((template) => ( -
- -
-
- {template.name} -
-
- {template.description} -
-
-
- ))} - {templates.length === 0 &&
No templates found.
} -
- ); - break; - } - case "coder_template_version_parameters": { - const params = toolInvocation.result; - content = ( -
- - {params.length > 0 - ? `${params.length} parameter(s)` - : "No parameters"} -
- ); - break; - } - case "coder_get_authenticated_user": { - const user = toolInvocation.result; - content = ( -
- - - -
-
- {user.username} -
-
- {user.email} -
-
-
- ); - break; - } - case "coder_create_workspace_build": { - const build = toolInvocation.result; - content = ( -
- - Build #{build.build_number} ({build.transition}) status:{" "} - {build.status} -
- ); - break; - } - case "coder_create_template_version": { - const version = toolInvocation.result; - content = ( -
- -
-
{version.name}
- {version.message && ( -
- {version.message} -
- )} -
-
- ); - break; - } - case "coder_get_workspace_agent_logs": - case "coder_get_workspace_build_logs": - case "coder_get_template_version_logs": { - const logs = toolInvocation.result; - if (!logs) { - console.log(toolInvocation); - } - const totalLines = logs.length; - const maxLinesToShow = 5; - const lastLogs = logs.slice(-maxLinesToShow); - const hiddenLines = totalLines - lastLogs.length; - - const totalLinesText = `${totalLines} log line${totalLines !== 1 ? "s" : ""}`; - const hiddenLinesText = - hiddenLines > 0 - ? `... hiding ${hiddenLines} more line${hiddenLines !== 1 ? "s" : ""} ...` - : null; - - const logsToShow = hiddenLinesText - ? [hiddenLinesText, ...lastLogs] - : lastLogs; - - content = ( -
-
- - Retrieved {totalLinesText}. -
- {logsToShow.length > 0 && ( - - {logsToShow.join("\n")} - - )} -
- ); - break; - } - case "coder_update_template_active_version": - content = ( -
- - {toolInvocation.result} -
- ); - break; - case "coder_upload_tar_file": - content = ( - - ); - break; - case "coder_create_template": { - const template = toolInvocation.result; - content = ( -
- {template.display_name -
-
- {template.name} -
-
- {template.display_name} -
-
-
- ); - break; - } - case "coder_delete_template": - content = ( -
- - {toolInvocation.result} -
- ); - break; - case "coder_get_template_version": { - const version = toolInvocation.result; - content = ( -
- -
-
{version.name}
- {version.message && ( -
- {version.message} -
- )} -
-
- ); - break; - } - case "coder_download_tar_file": { - const files = toolInvocation.result; - content = ; - break; - } - // Add default case or handle other tools if necessary - } - return ( -
- {content} -
- ); -}); - -// New component to preview files with tabs -const FilePreview: FC<{ files: Record; prefix?: string }> = - memo(({ files, prefix }) => { - const theme = useTheme(); - const [selectedTab, setSelectedTab] = useState(0); - const fileEntries = useMemo(() => Object.entries(files), [files]); - - if (fileEntries.length === 0) { - return null; - } - - const handleTabChange = (index: number) => { - setSelectedTab(index); - }; - - const getLanguage = (filename: string): string => { - if (filename.includes("Dockerfile")) { - return "dockerfile"; - } - const extension = filename.split(".").pop()?.toLowerCase(); - switch (extension) { - case "tf": - return "hcl"; - case "json": - return "json"; - case "yaml": - case "yml": - return "yaml"; - case "js": - case "jsx": - return "javascript"; - case "ts": - case "tsx": - return "typescript"; - case "py": - return "python"; - case "go": - return "go"; - case "rb": - return "ruby"; - case "java": - return "java"; - case "sh": - return "bash"; - case "md": - return "markdown"; - default: - return "plaintext"; - } - }; - - // Get filename and content based on the selectedTab index - const [selectedFilename, selectedContent] = fileEntries[selectedTab] ?? [ - "", - "", - ]; - - return ( -
- {prefix && ( -
- - {prefix} -
- )} - {/* Use custom Tabs component with active prop */} - - - {fileEntries.map(([filename], index) => ( - { - e.preventDefault(); // Prevent any potential default link behavior - handleTabChange(index); - }} - > - {filename} - - ))} - - - - {selectedContent} - -
- ); - }); - -export type ChatToolInvocation = - | ToolInvocation< - "coder_get_workspace", - { - id: string; - }, - TypesGen.Workspace - > - | ToolInvocation< - "coder_create_workspace", - { - user: string; - template_version_id: string; - name: string; - rich_parameters: Record; - }, - TypesGen.Workspace - > - | ToolInvocation< - "coder_list_workspaces", - { - owner: string; - }, - Pick< - TypesGen.Workspace, - | "id" - | "name" - | "template_id" - | "template_name" - | "template_display_name" - | "template_icon" - | "template_active_version_id" - | "outdated" - >[] - > - | ToolInvocation< - "coder_list_templates", - {}, - Pick< - TypesGen.Template, - | "id" - | "name" - | "description" - | "active_version_id" - | "active_user_count" - >[] - > - | ToolInvocation< - "coder_template_version_parameters", - { - template_version_id: string; - }, - TypesGen.TemplateVersionParameter[] - > - | ToolInvocation<"coder_get_authenticated_user", {}, TypesGen.User> - | ToolInvocation< - "coder_create_workspace_build", - { - workspace_id: string; - transition: "start" | "stop" | "delete"; - }, - TypesGen.WorkspaceBuild - > - | ToolInvocation< - "coder_create_template_version", - { - template_id?: string; - file_id: string; - }, - TypesGen.TemplateVersion - > - | ToolInvocation< - "coder_get_workspace_agent_logs", - { - workspace_agent_id: string; - }, - string[] - > - | ToolInvocation< - "coder_get_workspace_build_logs", - { - workspace_build_id: string; - }, - string[] - > - | ToolInvocation< - "coder_get_template_version_logs", - { - template_version_id: string; - }, - string[] - > - | ToolInvocation< - "coder_get_template_version", - { - template_version_id: string; - }, - TypesGen.TemplateVersion - > - | ToolInvocation< - "coder_download_tar_file", - { - file_id: string; - }, - Record - > - | ToolInvocation< - "coder_update_template_active_version", - { - template_id: string; - template_version_id: string; - }, - string - > - | ToolInvocation< - "coder_upload_tar_file", - { - mime_type: string; - files: Record; - }, - TypesGen.UploadResponse - > - | ToolInvocation< - "coder_create_template", - { - name: string; - }, - TypesGen.Template - > - | ToolInvocation< - "coder_delete_template", - { - template_id: string; - }, - string - >; - -type ToolInvocation = - | ({ - state: "partial-call"; - step?: number; - } & ToolCall) - | ({ - state: "call"; - step?: number; - } & ToolCall) - | ({ - state: "result"; - step?: number; - } & ToolResult< - N, - A, - | R - | { - error: string; - } - >); diff --git a/site/src/pages/ChatPage/LanguageModelSelector.tsx b/site/src/pages/ChatPage/LanguageModelSelector.tsx deleted file mode 100644 index 85818638dc0c8..0000000000000 --- a/site/src/pages/ChatPage/LanguageModelSelector.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useTheme } from "@emotion/react"; -import FormControl from "@mui/material/FormControl"; -import InputLabel from "@mui/material/InputLabel"; -import MenuItem from "@mui/material/MenuItem"; -import Select from "@mui/material/Select"; -import { deploymentLanguageModels } from "api/queries/deployment"; -import type { LanguageModel } from "api/typesGenerated"; // Assuming types live here based on project structure -import { Loader } from "components/Loader/Loader"; -import { ChangeEvent, type FC } from "react"; -import { useQuery } from "react-query"; -import { useChatContext } from "./ChatLayout"; - -export const LanguageModelSelector: FC = () => { - const theme = useTheme(); - const { setSelectedModel, modelConfig, selectedModel } = useChatContext(); - const { - data: languageModelConfig, - isLoading, - error, - } = useQuery(deploymentLanguageModels()); - - if (isLoading) { - return ; - } - - if (error || !languageModelConfig) { - console.error("Failed to load language models:", error); - return ( -
Error loading models.
- ); - } - - const models = languageModelConfig.models ?? []; - - if (models.length === 0) { - return ( -
- No language models available. -
- ); - } - - return ( - - Model - - - ); -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 534d4037d02b3..76e9adfd00b09 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -1,6 +1,4 @@ import { GlobalErrorBoundary } from "components/ErrorBoundary/GlobalErrorBoundary"; -import { ChatLayout } from "pages/ChatPage/ChatLayout"; -import { ChatMessages } from "pages/ChatPage/ChatMessages"; import { TemplateRedirectController } from "pages/TemplatePage/TemplateRedirectController"; import { Suspense, lazy } from "react"; import { @@ -33,7 +31,6 @@ const NotFoundPage = lazy(() => import("./pages/404Page/404Page")); const DeploymentSettingsLayout = lazy( () => import("./modules/management/DeploymentSettingsLayout"), ); -const ChatLanding = lazy(() => import("./pages/ChatPage/ChatLanding")); const DeploymentConfigProvider = lazy( () => import("./modules/management/DeploymentConfigProvider"), ); @@ -425,11 +422,6 @@ export const router = createBrowserRouter( } /> - }> - } /> - } /> - - }> } /> From 7a5c2d24cf18892f7f75144d46f7cdbe5acf35a7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Apr 2025 13:14:55 +0100 Subject: [PATCH 10/18] coderd/rbac: update chat rbac --- coderd/database/dbauthz/dbauthz.go | 35 ++++++------ coderd/database/dbauthz/dbauthz_test.go | 73 +++++++++++++++++++++++++ coderd/database/dbgen/dbgen.go | 13 +++++ coderd/database/dbmem/dbmem.go | 2 + coderd/database/modelmethods.go | 5 ++ coderd/rbac/roles.go | 2 + coderd/rbac/roles_test.go | 31 +++++++++++ 7 files changed, 143 insertions(+), 18 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f55c4d3d96a12..2ed230dd7a8f3 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1270,10 +1270,7 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u } func (q *querier) DeleteChat(ctx context.Context, id uuid.UUID) error { - if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceChat.WithID(id)); err != nil { - return err - } - return q.db.DeleteChat(ctx, id) + return deleteQ(q.log, q.auth, q.db.GetChatByID, q.db.DeleteChat)(ctx, id) } func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { @@ -1694,24 +1691,19 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI } func (q *querier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithID(id)); err != nil { - return database.Chat{}, err - } - return q.db.GetChatByID(ctx, id) + return fetch(q.log, q.auth, q.db.GetChatByID)(ctx, id) } func (q *querier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithID(chatID)); err != nil { + c, err := q.GetChatByID(ctx, chatID) + if err != nil { return nil, err } - return q.db.GetChatMessagesByChatID(ctx, chatID) + return q.db.GetChatMessagesByChatID(ctx, c.ID) } func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil { - return nil, err - } - return q.db.GetChatsByOwnerID(ctx, ownerID) + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByOwnerID)(ctx, ownerID) } func (q *querier) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { @@ -3348,7 +3340,14 @@ func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) } func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { - return insert(q.log, q.auth, rbac.ResourceChat.WithID(arg.ChatID), q.db.InsertChatMessages)(ctx, arg) + c, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return nil, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, c); err != nil { + return nil, err + } + return q.db.InsertChatMessages(ctx, arg) } func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { @@ -4000,10 +3999,10 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe } func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat.WithID(arg.ID)); err != nil { - return err + fetch := func(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) { + return q.db.GetChatByID(ctx, arg.ID) } - return q.db.UpdateChatByID(ctx, arg) + return update(q.log, q.auth, fetch, q.db.UpdateChatByID)(ctx, arg) } func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e562bbd1f7160..8d426c0077b8f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5307,3 +5307,76 @@ func (s *MethodTestSuite) TestResourcesProvisionerdserver() { }).Asserts(rbac.ResourceWorkspaceAgentDevcontainers, policy.ActionCreate) })) } + +func (s *MethodTestSuite) TestChat() { + createChat := func(t *testing.T, db database.Store) (database.User, database.Chat, database.ChatMessage) { + t.Helper() + + usr := dbgen.User(t, db, database.User{}) + chat := dbgen.Chat(s.T(), db, database.Chat{ + OwnerID: usr.ID, + }) + msg := dbgen.ChatMessage(s.T(), db, database.ChatMessage{ + ChatID: chat.ID, + }) + + return usr, chat, msg + } + + s.Run("DeleteChat", s.Subtest(func(db database.Store, check *expects) { + _, c, _ := createChat(s.T(), db) + check.Args(c.ID).Asserts(c, policy.ActionDelete) + })) + + s.Run("GetChatByID", s.Subtest(func(db database.Store, check *expects) { + _, c, _ := createChat(s.T(), db) + check.Args(c.ID).Asserts(c, policy.ActionRead).Returns(c) + })) + + s.Run("GetChatMessagesByChatID", s.Subtest(func(db database.Store, check *expects) { + _, c, m := createChat(s.T(), db) + check.Args(c.ID).Asserts(c, policy.ActionRead).Returns([]database.ChatMessage{m}) + })) + + s.Run("GetChatsByOwnerID", s.Subtest(func(db database.Store, check *expects) { + u1, u1c1, _ := createChat(s.T(), db) + u1c2 := dbgen.Chat(s.T(), db, database.Chat{ + OwnerID: u1.ID, + }) + _, _, _ = createChat(s.T(), db) // other user's chat + check.Args(u1.ID).Asserts(u1c2, policy.ActionRead, u1c1, policy.ActionRead).Returns([]database.Chat{u1c1, u1c2}) + })) + + s.Run("InsertChat", s.Subtest(func(db database.Store, check *expects) { + usr := dbgen.User(s.T(), db, database.User{}) + check.Args(database.InsertChatParams{ + OwnerID: usr.ID, + Title: "test chat", + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + }).Asserts(rbac.ResourceChat.WithOwner(usr.ID.String()), policy.ActionCreate) + })) + + s.Run("InsertChatMessages", s.Subtest(func(db database.Store, check *expects) { + usr := dbgen.User(s.T(), db, database.User{}) + chat := dbgen.Chat(s.T(), db, database.Chat{ + OwnerID: usr.ID, + }) + check.Args(database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedAt: dbtime.Now(), + Model: "test-model", + Provider: "test-provider", + Content: []byte(`[]`), + }).Asserts(chat, policy.ActionUpdate) + })) + + s.Run("UpdateChatByID", s.Subtest(func(db database.Store, check *expects) { + _, c, _ := createChat(s.T(), db) + check.Args(database.UpdateChatByIDParams{ + ID: c.ID, + Title: "new title", + UpdatedAt: dbtime.Now(), + }).Asserts(c, policy.ActionUpdate) + })) +} diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 5232ad2b9579c..4998db7a0f6cf 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -154,6 +154,19 @@ func Chat(t testing.TB, db database.Store, seed database.Chat) database.Chat { return chat } +func ChatMessage(t testing.TB, db database.Store, seed database.ChatMessage) database.ChatMessage { + msg, err := db.InsertChatMessages(genCtx, database.InsertChatMessagesParams{ + CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), + ChatID: takeFirst(seed.ChatID, uuid.New()), + Model: takeFirst(seed.Model, "train"), + Provider: takeFirst(seed.Provider, "thomas"), + Content: takeFirstSlice(seed.Content, []byte(`[{"text": "Choo choo!"}]`)), + }) + require.NoError(t, err, "insert chat message") + require.Len(t, msg, 1, "insert one chat message did not return exactly one message") + return msg[0] +} + func WorkspaceAgentPortShare(t testing.TB, db database.Store, orig database.WorkspaceAgentPortShare) database.WorkspaceAgentPortShare { ps, err := db.UpsertWorkspaceAgentPortShare(genCtx, database.UpsertWorkspaceAgentPortShareParams{ WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index f140b0aa6a74f..6bdc71ec458b5 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8487,6 +8487,8 @@ func (q *FakeQuerier) InsertChatMessages(ctx context.Context, arg database.Inser Content: content, }) } + + q.chatMessages = append(q.chatMessages, messages...) return messages, nil } diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 896fdd4af17e9..b3f6deed9eff0 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -568,3 +568,8 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce( return m.DebouncedUntil, false } + +func (c Chat) RBACObject() rbac.Object { + return rbac.ResourceChat.WithID(c.ID). + WithOwner(c.OwnerID.String()) +} diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 6b99cb4e871a2..56124faee44e2 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -299,6 +299,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceOrganizationMember.Type: {policy.ActionRead}, // Users can create provisioner daemons scoped to themselves. ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + // Users can create, read, update, and delete their own agentic chat messages. + ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, })..., ), }.withCachedRegoValue() diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 1080903637ac5..e90c89914fdec 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -831,6 +831,37 @@ func TestRolePermissions(t *testing.T) { }, }, }, + // Members may read their own chats. + { + Name: "CreateReadUpdateDeleteMyChats", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceChat.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {memberMe, orgMemberMe, owner}, + false: { + userAdmin, orgUserAdmin, templateAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + orgAdmin, otherOrgAdmin, + }, + }, + }, + // Only owners can create, read, update, and delete other users' chats. + { + Name: "CreateReadUpdateDeleteOtherUserChats", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceChat.WithOwner(uuid.NewString()), // some other user + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: { + memberMe, orgMemberMe, + userAdmin, orgUserAdmin, templateAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + orgAdmin, otherOrgAdmin, + }, + }, + }, } // We expect every permission to be tested above. From d3f1cf46f267388d686cb351b731bfa80923c6ae Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Apr 2025 15:18:58 +0100 Subject: [PATCH 11/18] coderd/database: only generate ID at database side, order messages by created_at instead of id --- coderd/chat.go | 2 -- coderd/database/dbauthz/dbauthz_test.go | 5 +++-- coderd/database/dbgen/dbgen.go | 1 - coderd/database/dbmem/dbmem.go | 11 ++++++++++- coderd/database/queries.sql.go | 8 +++----- coderd/database/queries/chat.sql | 6 +++--- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/coderd/chat.go b/coderd/chat.go index 875c51ba49840..8438edc11bf00 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -6,7 +6,6 @@ import ( "net/http" "time" - "github.com/google/uuid" "github.com/kylecarbs/aisdk-go" "github.com/coder/coder/v2/coderd/ai" @@ -33,7 +32,6 @@ func (api *API) postChats(w http.ResponseWriter, r *http.Request) { ctx := r.Context() chat, err := api.Database.InsertChat(ctx, database.InsertChatParams{ - ID: uuid.New(), OwnerID: apiKey.UserID, CreatedAt: time.Now(), UpdatedAt: time.Now(), diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8d426c0077b8f..6dc9a32f03943 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5341,10 +5341,11 @@ func (s *MethodTestSuite) TestChat() { s.Run("GetChatsByOwnerID", s.Subtest(func(db database.Store, check *expects) { u1, u1c1, _ := createChat(s.T(), db) u1c2 := dbgen.Chat(s.T(), db, database.Chat{ - OwnerID: u1.ID, + OwnerID: u1.ID, + CreatedAt: u1c1.CreatedAt.Add(time.Hour), }) _, _, _ = createChat(s.T(), db) // other user's chat - check.Args(u1.ID).Asserts(u1c2, policy.ActionRead, u1c1, policy.ActionRead).Returns([]database.Chat{u1c1, u1c2}) + check.Args(u1.ID).Asserts(u1c2, policy.ActionRead, u1c1, policy.ActionRead).Returns([]database.Chat{u1c2, u1c1}) })) s.Run("InsertChat", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 4998db7a0f6cf..55c2fe4cf6965 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -144,7 +144,6 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database func Chat(t testing.TB, db database.Store, seed database.Chat) database.Chat { chat, err := db.InsertChat(genCtx, database.InsertChatParams{ - ID: takeFirst(seed.ID, uuid.New()), OwnerID: takeFirst(seed.OwnerID, uuid.New()), CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 6bdc71ec458b5..6bae4455a89ef 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2916,6 +2916,9 @@ func (q *FakeQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) chats = append(chats, chat) } } + sort.Slice(chats, func(i, j int) bool { + return chats[i].CreatedAt.After(chats[j].CreatedAt) + }) return chats, nil } @@ -8447,7 +8450,13 @@ func (q *FakeQuerier) InsertChat(ctx context.Context, arg database.InsertChatPar q.mutex.Lock() defer q.mutex.Unlock() - chat := database.Chat(arg) + chat := database.Chat{ + ID: uuid.New(), + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OwnerID: arg.OwnerID, + Title: arg.Title, + } q.chats = append(q.chats, chat) return chat, nil diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e1a73c8e051f5..cd5b297c85e07 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -796,7 +796,7 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many SELECT id, chat_id, created_at, model, provider, content FROM chat_messages WHERE chat_id = $1 -ORDER BY id ASC +ORDER BY created_at ASC ` func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) { @@ -865,13 +865,12 @@ func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ( } const insertChat = `-- name: InsertChat :one -INSERT INTO chats (id, owner_id, created_at, updated_at, title) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO chats (owner_id, created_at, updated_at, title) +VALUES ($1, $2, $3, $4) RETURNING id, owner_id, created_at, updated_at, title ` type InsertChatParams struct { - ID uuid.UUID `db:"id" json:"id"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` @@ -880,7 +879,6 @@ type InsertChatParams struct { func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) { row := q.db.QueryRowContext(ctx, insertChat, - arg.ID, arg.OwnerID, arg.CreatedAt, arg.UpdatedAt, diff --git a/coderd/database/queries/chat.sql b/coderd/database/queries/chat.sql index 6dc76184ad163..68f662d8a886b 100644 --- a/coderd/database/queries/chat.sql +++ b/coderd/database/queries/chat.sql @@ -1,6 +1,6 @@ -- name: InsertChat :one -INSERT INTO chats (id, owner_id, created_at, updated_at, title) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO chats (owner_id, created_at, updated_at, title) +VALUES ($1, $2, $3, $4) RETURNING *; -- name: UpdateChatByID :exec @@ -30,7 +30,7 @@ RETURNING chat_messages.*; -- name: GetChatMessagesByChatID :many SELECT * FROM chat_messages WHERE chat_id = $1 -ORDER BY id ASC; +ORDER BY created_at ASC; -- name: DeleteChat :exec DELETE FROM chats WHERE id = $1; From d9aab390e31d1139bf172deac0f0162a202e1a93 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Apr 2025 16:25:00 +0100 Subject: [PATCH 12/18] fix(coderd/database): fix down migration and add fixture --- .../database/migrations/000318_chat.down.sql | 34 ------------------- .../database/migrations/000319_chat.down.sql | 3 ++ ...{000318_chat.up.sql => 000319_chat.up.sql} | 0 .../testdata/fixtures/000319_chat.up.sql | 6 ++++ 4 files changed, 9 insertions(+), 34 deletions(-) delete mode 100644 coderd/database/migrations/000318_chat.down.sql create mode 100644 coderd/database/migrations/000319_chat.down.sql rename coderd/database/migrations/{000318_chat.up.sql => 000319_chat.up.sql} (100%) create mode 100644 coderd/database/migrations/testdata/fixtures/000319_chat.up.sql diff --git a/coderd/database/migrations/000318_chat.down.sql b/coderd/database/migrations/000318_chat.down.sql deleted file mode 100644 index f816da77e231d..0000000000000 --- a/coderd/database/migrations/000318_chat.down.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Remove the created_at column -ALTER TABLE chat_messages -DROP COLUMN IF EXISTS created_at; - --- Add back message_type and message_index (assuming previous state was TEXT and SERIAL) -ALTER TABLE chat_messages -ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT '', -- Provide a default or handle NULLs -ADD COLUMN IF NOT EXISTS message_index SERIAL; - --- Change content back to TEXT (data loss may occur if JSONB data is not representable as TEXT) -ALTER TABLE chat_messages -ALTER COLUMN content TYPE TEXT USING content::TEXT; - --- Attempt to revert id back to UUID with default --- WARNING: This is complex and potentially destructive. It might fail if data exists. --- It drops the existing primary key, sequence, and default, then attempts to set a new one. -ALTER TABLE chat_messages DROP CONSTRAINT IF EXISTS chat_messages_pkey; -DROP SEQUENCE IF EXISTS chat_messages_id_seq; -ALTER TABLE chat_messages ALTER COLUMN id DROP DEFAULT; -ALTER TABLE chat_messages ALTER COLUMN id TYPE UUID USING (gen_random_uuid()); -- Attempt conversion, may fail -ALTER TABLE chat_messages ALTER COLUMN id SET DEFAULT gen_random_uuid(); -ALTER TABLE chat_messages ADD PRIMARY KEY (id); - --- Revert changes to chat_conversations (removing deleted_at) -ALTER TABLE chat_conversations -DROP COLUMN IF EXISTS deleted_at; -ALTER TABLE chat_conversations DROP CONSTRAINT IF EXISTS chat_conversations_user_id_fkey; -ALTER TABLE chat_conversations ADD CONSTRAINT chat_conversations_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); -ALTER TABLE chat_conversations ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP; -ALTER TABLE chat_conversations ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; - --- Revert changes to chat_messages (removing ON DELETE CASCADE) -ALTER TABLE chat_messages DROP CONSTRAINT IF EXISTS chat_messages_conversation_id_fkey; -ALTER TABLE chat_messages ADD CONSTRAINT chat_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id); diff --git a/coderd/database/migrations/000319_chat.down.sql b/coderd/database/migrations/000319_chat.down.sql new file mode 100644 index 0000000000000..9bab993f500f5 --- /dev/null +++ b/coderd/database/migrations/000319_chat.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS chat_messages; + +DROP TABLE IF EXISTS chats; diff --git a/coderd/database/migrations/000318_chat.up.sql b/coderd/database/migrations/000319_chat.up.sql similarity index 100% rename from coderd/database/migrations/000318_chat.up.sql rename to coderd/database/migrations/000319_chat.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000319_chat.up.sql b/coderd/database/migrations/testdata/fixtures/000319_chat.up.sql new file mode 100644 index 0000000000000..123a62c4eb722 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000319_chat.up.sql @@ -0,0 +1,6 @@ +INSERT INTO chats (id, owner_id, created_at, updated_at, title) VALUES +('00000000-0000-0000-0000-000000000001', '0ed9befc-4911-4ccf-a8e2-559bf72daa94', '2023-10-01 12:00:00+00', '2023-10-01 12:00:00+00', 'Test Chat 1'); + +INSERT INTO chat_messages (id, chat_id, created_at, model, provider, content) VALUES +(1, '00000000-0000-0000-0000-000000000001', '2023-10-01 12:00:00+00', 'annie-oakley', 'cowboy-coder', '{"role":"user","content":"Hello"}'), +(2, '00000000-0000-0000-0000-000000000001', '2023-10-01 12:01:00+00', 'annie-oakley', 'cowboy-coder', '{"role":"assistant","content":"Howdy pardner! What can I do ya for?"}'); From 8071f9c0b368edb3646962ec06905d073b1d18b6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 1 May 2025 14:00:02 +0100 Subject: [PATCH 13/18] chore: add experiment agentic-chat, add tests for chat API routes --- coderd/apidoc/docs.go | 7 +- coderd/apidoc/swagger.json | 7 +- coderd/chat.go | 18 ++--- coderd/chat_test.go | 125 +++++++++++++++++++++++++++++++++ coderd/coderd.go | 6 +- codersdk/chat.go | 3 + codersdk/deployment.go | 1 + docs/reference/api/schemas.md | 1 + site/src/api/typesGenerated.ts | 1 + 9 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 coderd/chat_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c5d1f466f714a..f9e7f1341011d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12552,9 +12552,11 @@ const docTemplate = `{ "workspace-usage", "web-push", "dynamic-parameters", - "workspace-prebuilds" + "workspace-prebuilds", + "agentic-chat" ], "x-enum-comments": { + "ExperimentAgenticChat": "Enables the new agentic AI chat feature.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.", "ExperimentExample": "This isn't used for anything.", @@ -12570,7 +12572,8 @@ const docTemplate = `{ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentDynamicParameters", - "ExperimentWorkspacePrebuilds" + "ExperimentWorkspacePrebuilds", + "ExperimentAgenticChat" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index faa2ceea0abf1..35a09acf6ac1c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11266,9 +11266,11 @@ "workspace-usage", "web-push", "dynamic-parameters", - "workspace-prebuilds" + "workspace-prebuilds", + "agentic-chat" ], "x-enum-comments": { + "ExperimentAgenticChat": "Enables the new agentic AI chat feature.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.", "ExperimentExample": "This isn't used for anything.", @@ -11284,7 +11286,8 @@ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentDynamicParameters", - "ExperimentWorkspacePrebuilds" + "ExperimentWorkspacePrebuilds", + "ExperimentAgenticChat" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/chat.go b/coderd/chat.go index 8438edc11bf00..08fd59461061a 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -161,9 +161,10 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { return } - messages := make([]aisdk.Message, 0, len(dbMessages)) - for i, message := range dbMessages { - err = json.Unmarshal(message.Content, &messages[i]) + messages := make([]codersdk.ChatMessage, 0) + for _, dbMsg := range dbMessages { + var msg codersdk.ChatMessage + err = json.Unmarshal(dbMsg.Content, &msg) if err != nil { httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to unmarshal chat message", @@ -171,19 +172,20 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { }) return } + messages = append(messages, msg) } messages = append(messages, req.Message) client := codersdk.New(api.AccessURL) client.SetSessionToken(httpmw.APITokenFromRequest(r)) - tools := make([]aisdk.Tool, len(toolsdk.All)) + tools := make([]aisdk.Tool, 0) handlers := map[string]toolsdk.GenericHandlerFunc{} - for i, tool := range toolsdk.All { - if tool.Tool.Schema.Required == nil { - tool.Tool.Schema.Required = []string{} + for _, tool := range toolsdk.All { + if tool.Name == "coder_report_task" { + continue // This tool requires an agent to run. } - tools[i] = tool.Tool + tools = append(tools, tool.Tool) handlers[tool.Tool.Name] = tool.Handler } diff --git a/coderd/chat_test.go b/coderd/chat_test.go new file mode 100644 index 0000000000000..71e7b99ab3720 --- /dev/null +++ b/coderd/chat_test.go @@ -0,0 +1,125 @@ +package coderd_test + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestChat(t *testing.T) { + t.Parallel() + + t.Run("ExperimentAgenticChatDisabled", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Hit the endpoint to get the chat. It should return a 404. + ctx := testutil.Context(t, testutil.WaitShort) + _, err := memberClient.ListChats(ctx) + require.Error(t, err, "list chats should fail") + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr, "request should fail with an SDK error") + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + t.Run("ChatCRUD", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentAgenticChat)} + dv.AI.Value = codersdk.AIConfig{ + Providers: []codersdk.AIProviderConfig{ + { + Type: "fake", + APIKey: "", + BaseURL: "http://localhost", + Models: []string{"fake-model"}, + }, + }, + } + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Seed the database with some data. + dbChat := dbgen.Chat(t, db, database.Chat{ + OwnerID: memberUser.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + UpdatedAt: dbtime.Now().Add(-time.Hour), + Title: "This is a test chat", + }) + _ = dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: dbChat.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + Content: []byte(`[{"content": "Hello world"}]`), + Model: "fake model", + Provider: "fake", + }) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Listing chats should return the chat we just inserted. + chats, err := memberClient.ListChats(ctx) + require.NoError(t, err, "list chats should succeed") + require.Len(t, chats, 1, "response should have one chat") + require.Equal(t, dbChat.ID, chats[0].ID, "unexpected chat ID") + require.Equal(t, dbChat.Title, chats[0].Title, "unexpected chat title") + require.Equal(t, dbChat.CreatedAt.UTC(), chats[0].CreatedAt.UTC(), "unexpected chat created at") + require.Equal(t, dbChat.UpdatedAt.UTC(), chats[0].UpdatedAt.UTC(), "unexpected chat updated at") + + // Fetching a single chat by ID should return the same chat. + chat, err := memberClient.Chat(ctx, dbChat.ID) + require.NoError(t, err, "get chat should succeed") + require.Equal(t, chats[0], chat, "get chat should return the same chat") + + // Listing chat messages should return the message we just inserted. + messages, err := memberClient.ChatMessages(ctx, dbChat.ID) + require.NoError(t, err, "list chat messages should succeed") + require.Len(t, messages, 1, "response should have one message") + require.Equal(t, "Hello world", messages[0].Content, "response should have the correct message content") + + // Creating a new chat will fail because the model does not exist. + // TODO: Test the message streaming functionality with a mock model. + // Inserting a chat message will fail due to the model not existing. + _, err = memberClient.CreateChatMessage(ctx, dbChat.ID, codersdk.CreateChatMessageRequest{ + Model: "echo", + Message: codersdk.ChatMessage{ + Role: "user", + Content: "Hello world", + }, + Thinking: false, + }) + require.Error(t, err, "create chat message should fail") + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr, "create chat should fail with an SDK error") + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode(), "create chat should fail with a 400 when model does not exist") + + // Creating a new chat message with malformed content should fail. + res, err := memberClient.Request(ctx, http.MethodPost, "/api/v2/chats/"+dbChat.ID.String()+"/messages", strings.NewReader(`{malformed json}`)) + require.NoError(t, err) + defer res.Body.Close() + apiErr := codersdk.ReadBodyAsError(res) + require.Contains(t, apiErr.Error(), "Failed to decode chat message") + + _, err = memberClient.CreateChat(ctx) + require.NoError(t, err, "create chat should succeed") + chats, err = memberClient.ListChats(ctx) + require.NoError(t, err, "list chats should succeed") + require.Len(t, chats, 2, "response should have two chats") + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index 0a215e5de49ac..123e58feb642a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1001,8 +1001,12 @@ func New(options *Options) *API { r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) + // Chats are an experimental feature r.Route("/chats", func(r chi.Router) { - r.Use(apiKeyMiddleware) + r.Use( + apiKeyMiddleware, + httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentAgenticChat), + ) r.Get("/", api.listChats) r.Post("/", api.postChats) r.Route("/{chat}", func(r chi.Router) { diff --git a/codersdk/chat.go b/codersdk/chat.go index ab9ed46faeded..2093adaff95e8 100644 --- a/codersdk/chat.go +++ b/codersdk/chat.go @@ -40,6 +40,9 @@ func (c *Client) ListChats(ctx context.Context) ([]Chat, error) { return nil, xerrors.Errorf("execute request: %w", err) } defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } var chats []Chat return chats, json.NewDecoder(res.Body).Decode(&chats) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ff70839f0c104..0741bf9e3844a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3328,6 +3328,7 @@ const ( ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace. ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature. + ExperimentAgenticChat Experiment = "agentic-chat" // Enables the new agentic AI chat feature. ) // ExperimentsSafe should include all experiments that are safe for diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 958e8d53d58c4..0e9d3f4778901 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3336,6 +3336,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `web-push` | | `dynamic-parameters` | | `workspace-prebuilds` | +| `agentic-chat` | ## codersdk.ExternalAuth diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4f8301cbe1716..b1fcb296de4e8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -812,6 +812,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = + | "agentic-chat" | "auto-fill-parameters" | "dynamic-parameters" | "example" From 40c3d5e7e8b2b14a8a0dfbe46b0854769568435b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 2 May 2025 13:00:20 +0100 Subject: [PATCH 14/18] chore: update kylecarbs/aisdk-go to v0.0.8 --- coderd/apidoc/docs.go | 5 ++++- coderd/apidoc/swagger.json | 5 ++++- coderd/chat.go | 2 +- docs/reference/api/chat.md | 10 +++++++--- docs/reference/api/schemas.md | 18 +++++++++++++----- go.mod | 2 +- go.sum | 4 ++-- 7 files changed, 32 insertions(+), 14 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f9e7f1341011d..fb5ae20e448c8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10543,7 +10543,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "string" + "type": "array", + "items": { + "type": "integer" + } }, "details": { "type": "array", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 35a09acf6ac1c..8420c9ea0f812 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9354,7 +9354,10 @@ "type": "object", "properties": { "data": { - "type": "string" + "type": "array", + "items": { + "type": "integer" + } }, "details": { "type": "array", diff --git a/coderd/chat.go b/coderd/chat.go index 08fd59461061a..0b2a3f8349610 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -291,7 +291,7 @@ You are running as an agent - please keep going until the user's query is comple }) return } - stream = stream.WithToolCalling(func(toolCall aisdk.ToolCall) any { + stream = stream.WithToolCalling(func(toolCall aisdk.ToolCall) aisdk.ToolCallResult { tool, ok := handlers[toolCall.Name] if !ok { return nil diff --git a/docs/reference/api/chat.md b/docs/reference/api/chat.md index a4236a0e4bc0c..4b5ad8c23adae 100644 --- a/docs/reference/api/chat.md +++ b/docs/reference/api/chat.md @@ -165,7 +165,9 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat}/messages \ "id": "string", "parts": [ { - "data": "string", + "data": [ + 0 + ], "details": [ { "data": "string", @@ -224,7 +226,7 @@ Status Code **200** | `»» url` | string | false | | | | `» id` | string | false | | | | `» parts` | array | false | | | -| `»» data` | string | false | | | +| `»» data` | array | false | | | | `»» details` | array | false | | | | `»»» data` | string | false | | | | `»»» signature` | string | false | | | @@ -301,7 +303,9 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \ "id": "string", "parts": [ { - "data": "string", + "data": [ + 0 + ], "details": [ { "data": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 0e9d3f4778901..6ca005b4ec69c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -221,7 +221,9 @@ "id": "string", "parts": [ { - "data": "string", + "data": [ + 0 + ], "details": [ { "data": "string", @@ -273,7 +275,9 @@ ```json { - "data": "string", + "data": [ + 0 + ], "details": [ { "data": "string", @@ -310,7 +314,7 @@ | Name | Type | Required | Restrictions | Description | |------------------|---------------------------------------------------------|----------|--------------|-------------------------| -| `data` | string | false | | | +| `data` | array of integer | false | | | | `details` | array of [aisdk.ReasoningDetail](#aisdkreasoningdetail) | false | | | | `mimeType` | string | false | | Type: "file" | | `reasoning` | string | false | | Type: "reasoning" | @@ -1361,7 +1365,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "id": "string", "parts": [ { - "data": "string", + "data": [ + 0 + ], "details": [ { "data": "string", @@ -1463,7 +1469,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "id": "string", "parts": [ { - "data": "string", + "data": [ + 0 + ], "details": [ { "data": "string", diff --git a/go.mod b/go.mod index a32f08998359c..ce41f23e02e05 100644 --- a/go.mod +++ b/go.mod @@ -490,7 +490,7 @@ require ( github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 github.com/coder/preview v0.0.1 github.com/fsnotify/fsnotify v1.9.0 - github.com/kylecarbs/aisdk-go v0.0.5 + github.com/kylecarbs/aisdk-go v0.0.8 github.com/mark3labs/mcp-go v0.23.1 github.com/openai/openai-go v0.1.0-beta.6 google.golang.org/genai v0.7.0 diff --git a/go.sum b/go.sum index fc05152d34122..09bd945ec4898 100644 --- a/go.sum +++ b/go.sum @@ -1467,8 +1467,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylecarbs/aisdk-go v0.0.5 h1:e4HE/SMBUUZn7AS/luiIYbEtHbbtUBzJS95R6qHDYVE= -github.com/kylecarbs/aisdk-go v0.0.5/go.mod h1:3nAhClwRNo6ZfU44GrBZ8O2fCCrxJdaHb9JIz+P3LR8= +github.com/kylecarbs/aisdk-go v0.0.8 h1:hnKVbLM6U8XqX3t5I26J8k5saXdra595bGt1HP0PvKA= +github.com/kylecarbs/aisdk-go v0.0.8/go.mod h1:3nAhClwRNo6ZfU44GrBZ8O2fCCrxJdaHb9JIz+P3LR8= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= From 3cf8f60dd9ab1635f635b625ee28dceadb53dcd6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 2 May 2025 13:01:41 +0100 Subject: [PATCH 15/18] fix(coderd/ai): support overriding base URL for OpenAI provider, required for OpenRouter --- cli/server.go | 2 +- coderd/ai/ai.go | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/server.go b/cli/server.go index 873de5aae8d54..48ec8492f0a55 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2680,7 +2680,7 @@ func ReadAIProvidersFromEnv(environ []string) ([]codersdk.AIProviderConfig, erro case "BASE_URL": provider.BaseURL = v.Value case "MODELS": - provider.Models = strings.Split(v.Value, " ") + provider.Models = strings.Split(v.Value, ",") } providers[providerNum] = provider } diff --git a/coderd/ai/ai.go b/coderd/ai/ai.go index fefaebc70d4d0..97c825ae44c06 100644 --- a/coderd/ai/ai.go +++ b/coderd/ai/ai.go @@ -40,7 +40,13 @@ func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) switch config.Type { case "openai": - client := openai.NewClient(openaioption.WithAPIKey(config.APIKey)) + opts := []openaioption.RequestOption{ + openaioption.WithAPIKey(config.APIKey), + } + if config.BaseURL != "" { + opts = append(opts, openaioption.WithBaseURL(config.BaseURL)) + } + client := openai.NewClient(opts...) streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { openaiMessages, err := aisdk.MessagesToOpenAI(options.Messages) if err != nil { From ccefabe575f4d329f081d11949920b7c1140cd15 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 2 May 2025 14:16:56 +0100 Subject: [PATCH 16/18] fix(coderd): avoid serializing null message content in postChatMessages --- coderd/chat.go | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/coderd/chat.go b/coderd/chat.go index 0b2a3f8349610..6c4011cdea1ac 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -316,30 +316,35 @@ You are running as an agent - please keep going until the user's query is comple return } - raw, err := json.Marshal(acc.Messages()) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to marshal chat message", - Detail: err.Error(), - }) - return - } - messages = append(messages, acc.Messages()...) + // acc.Messages() may sometimes return nil. Serializing this + // will cause a pq error: "cannot extract elements from a scalar". + newMessages := append([]aisdk.Message{}, acc.Messages()...) + if len(newMessages) > 0 { + raw, err := json.Marshal(newMessages) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal chat message", + Detail: err.Error(), + }) + return + } + messages = append(messages, newMessages...) - // Insert these messages into the database! - _, err = api.Database.InsertChatMessages(ctx, database.InsertChatMessagesParams{ - ChatID: chat.ID, - CreatedAt: dbtime.Now(), - Model: req.Model, - Provider: provider.Provider, - Content: raw, - }) - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to insert chat messages", - Detail: err.Error(), + // Insert these messages into the database! + _, err = api.Database.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedAt: dbtime.Now(), + Model: req.Model, + Provider: provider.Provider, + Content: raw, }) - return + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert chat messages", + Detail: err.Error(), + }) + return + } } if acc.FinishReason() == aisdk.FinishReasonToolCalls { From 05fa48dfb7d5508d4fb2503bdcb5f59d5ffbd6f0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 2 May 2025 17:11:43 +0100 Subject: [PATCH 17/18] fix(coderd): chat: avoid panic if no messages are accumulated --- coderd/chat.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/coderd/chat.go b/coderd/chat.go index 6c4011cdea1ac..17130aed90413 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/toolsdk" ) @@ -224,10 +225,19 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { }) return } + var newTitle string + accMessages := acc.Messages() + // If for some reason the stream didn't return any messages, use the + // original message as the title. + if len(accMessages) == 0 { + newTitle = strings.Truncate(messages[0].Content, 40) + } else { + newTitle = strings.Truncate(accMessages[0].Content, 40) + } err = api.Database.UpdateChatByID(ctx, database.UpdateChatByIDParams{ ID: chat.ID, - Title: acc.Messages()[0].Content, - UpdatedAt: time.Now(), + Title: newTitle, + UpdatedAt: dbtime.Now(), }) if err != nil { httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ From 8481ad5148ea208d46aad5683e8e636ded86ae82 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 2 May 2025 17:12:06 +0100 Subject: [PATCH 18/18] fix(coderd): chat: do not pass tools to model for generating chat title --- coderd/chat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/chat.go b/coderd/chat.go index 17130aed90413..b10211075cfe6 100644 --- a/coderd/chat.go +++ b/coderd/chat.go @@ -207,7 +207,7 @@ func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { - It should be maximum of 40 characters. - Do not use quotes, colons, special characters, or emojis.`, Messages: messages, - Tools: tools, + Tools: []aisdk.Tool{}, // This initial stream doesn't use tools. }) if err != nil { httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{