From 8c9a278075ed24ce90d5fc059cf26986e76b0ebb Mon Sep 17 00:00:00 2001 From: Marvin Charles Date: Thu, 16 Jul 2026 14:28:11 -0700 Subject: [PATCH 01/13] feat: add Proto AI chat --- client/src/protoFleet/api/clients.ts | 3 + .../api/generated/chat/v1/chat_pb.ts | 575 +++++++ .../components/AppLayout/AppLayout.test.tsx | 33 + .../components/AppLayout/AppLayout.tsx | 10 + client/src/protoFleet/config/navItems.test.ts | 12 + client/src/protoFleet/config/navItems.ts | 7 + .../aiChat/AgentActivityStatus.test.tsx | 31 + .../features/aiChat/AgentActivityStatus.tsx | 39 + .../protoFleet/features/aiChat/ChatFab.tsx | 34 + .../features/aiChat/ChatHeaderButton.tsx | 20 + .../protoFleet/features/aiChat/ChatInput.tsx | 63 + .../aiChat/ChatMessageContent.test.tsx | 38 + .../features/aiChat/ChatMessageContent.tsx | 177 +++ .../features/aiChat/ChatPanel.test.tsx | 131 ++ .../protoFleet/features/aiChat/ChatPanel.tsx | 280 ++++ .../src/protoFleet/features/aiChat/index.ts | 5 + .../src/protoFleet/features/aiChat/types.ts | 37 + .../features/aiChat/useChatStore.ts | 117 ++ .../features/settings/agents/Agents.tsx | 11 + .../agents/LLMProviderSettings.test.tsx | 172 ++ .../settings/agents/LLMProviderSettings.tsx | 429 +++++ .../features/settings/agents/index.ts | 1 + client/src/protoFleet/routePrefetch.test.ts | 6 +- client/src/protoFleet/routePrefetch.ts | 2 + client/src/protoFleet/router.tsx | 8 + proto/chat/v1/chat.proto | 160 ++ server/cmd/fleetd/main.go | 24 +- server/generated/grpc/chat/v1/chat.pb.go | 1415 +++++++++++++++++ .../chat/v1/chatv1connect/chat.connect.go | 186 +++ server/generated/sqlc/llm_config.sql.go | 133 ++ server/generated/sqlc/models.go | 17 + server/internal/domain/chat/agent.go | 195 +++ server/internal/domain/chat/agent_test.go | 72 + server/internal/domain/chat/config.go | 399 +++++ server/internal/domain/chat/config_test.go | 285 ++++ server/internal/domain/chat/egress.go | 99 ++ server/internal/domain/chat/egress_test.go | 57 + server/internal/domain/chat/provider.go | 506 ++++++ server/internal/domain/chat/provider_test.go | 214 +++ .../domain/stores/sqlstores/llm_config.go | 59 + server/internal/handlers/chat/handler.go | 189 +++ server/internal/handlers/chat/tools.go | 173 ++ .../internal/handlers/interceptors/config.go | 13 + .../handlers/interceptors/config_test.go | 26 + .../handlers/middleware/rpc_permissions.go | 9 + .../middleware/rpc_permissions_test.go | 2 + .../000123_create_llm_config.down.sql | 1 + .../000123_create_llm_config.up.sql | 17 + server/sqlc/queries/llm_config.sql | 60 + 49 files changed, 6548 insertions(+), 4 deletions(-) create mode 100644 client/src/protoFleet/api/generated/chat/v1/chat_pb.ts create mode 100644 client/src/protoFleet/features/aiChat/AgentActivityStatus.test.tsx create mode 100644 client/src/protoFleet/features/aiChat/AgentActivityStatus.tsx create mode 100644 client/src/protoFleet/features/aiChat/ChatFab.tsx create mode 100644 client/src/protoFleet/features/aiChat/ChatHeaderButton.tsx create mode 100644 client/src/protoFleet/features/aiChat/ChatInput.tsx create mode 100644 client/src/protoFleet/features/aiChat/ChatMessageContent.test.tsx create mode 100644 client/src/protoFleet/features/aiChat/ChatMessageContent.tsx create mode 100644 client/src/protoFleet/features/aiChat/ChatPanel.test.tsx create mode 100644 client/src/protoFleet/features/aiChat/ChatPanel.tsx create mode 100644 client/src/protoFleet/features/aiChat/index.ts create mode 100644 client/src/protoFleet/features/aiChat/types.ts create mode 100644 client/src/protoFleet/features/aiChat/useChatStore.ts create mode 100644 client/src/protoFleet/features/settings/agents/Agents.tsx create mode 100644 client/src/protoFleet/features/settings/agents/LLMProviderSettings.test.tsx create mode 100644 client/src/protoFleet/features/settings/agents/LLMProviderSettings.tsx create mode 100644 client/src/protoFleet/features/settings/agents/index.ts create mode 100644 proto/chat/v1/chat.proto create mode 100644 server/generated/grpc/chat/v1/chat.pb.go create mode 100644 server/generated/grpc/chat/v1/chatv1connect/chat.connect.go create mode 100644 server/generated/sqlc/llm_config.sql.go create mode 100644 server/internal/domain/chat/agent.go create mode 100644 server/internal/domain/chat/agent_test.go create mode 100644 server/internal/domain/chat/config.go create mode 100644 server/internal/domain/chat/config_test.go create mode 100644 server/internal/domain/chat/egress.go create mode 100644 server/internal/domain/chat/egress_test.go create mode 100644 server/internal/domain/chat/provider.go create mode 100644 server/internal/domain/chat/provider_test.go create mode 100644 server/internal/domain/stores/sqlstores/llm_config.go create mode 100644 server/internal/handlers/chat/handler.go create mode 100644 server/internal/handlers/chat/tools.go create mode 100644 server/migrations/000123_create_llm_config.down.sql create mode 100644 server/migrations/000123_create_llm_config.up.sql create mode 100644 server/sqlc/queries/llm_config.sql diff --git a/client/src/protoFleet/api/clients.ts b/client/src/protoFleet/api/clients.ts index 6da8b36c12..638dd01459 100644 --- a/client/src/protoFleet/api/clients.ts +++ b/client/src/protoFleet/api/clients.ts @@ -11,6 +11,7 @@ import { ApiKeyService } from "@/protoFleet/api/generated/apikey/v1/apikey_pb"; import { AuthService } from "@/protoFleet/api/generated/auth/v1/auth_pb"; import { AuthzService } from "@/protoFleet/api/generated/authz/v1/authz_pb"; import { BuildingService } from "@/protoFleet/api/generated/buildings/v1/buildings_pb"; +import { ChatService } from "@/protoFleet/api/generated/chat/v1/chat_pb"; import { CurtailmentService } from "@/protoFleet/api/generated/curtailment/v1/curtailment_pb"; import { DeviceSetService } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; import { ErrorQueryService } from "@/protoFleet/api/generated/errors/v1/errors_pb"; @@ -51,6 +52,7 @@ const foremanImportClient = createClient(ForemanImportService, transport); const infrastructureClient = createClient(InfrastructureService, transport); const sitesClient = createClient(SiteService, transport); const buildingsClient = createClient(BuildingService, transport); +const chatClient = createClient(ChatService, transport); const alertChannelClient = createClient(AlertChannelService, transport); const alertRuleClient = createClient(AlertRuleService, transport); const alertMaintenanceWindowClient = createClient(AlertMaintenanceWindowService, transport); @@ -66,6 +68,7 @@ export { authClient, authzClient, buildingsClient, + chatClient, curtailmentClient, deviceSetClient, errorQueryClient, diff --git a/client/src/protoFleet/api/generated/chat/v1/chat_pb.ts b/client/src/protoFleet/api/generated/chat/v1/chat_pb.ts new file mode 100644 index 0000000000..11d2b85b53 --- /dev/null +++ b/client/src/protoFleet/api/generated/chat/v1/chat_pb.ts @@ -0,0 +1,575 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated from file chat/v1/chat.proto (package chat.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file chat/v1/chat.proto. + */ +export const file_chat_v1_chat: GenFile = + /*@__PURE__*/ + fileDesc( + "ChJjaGF0L3YxL2NoYXQucHJvdG8SB2NoYXQudjEi3QEKCUxMTUNvbmZpZxImCgdoYXJuZXNzGAEgASgOMhUuY2hhdC52MS5BZ2VudEhhcm5lc3MSJgoIcHJvdmlkZXIYAiABKA4yFC5jaGF0LnYxLkxMTVByb3ZpZGVyEhMKC2hhc19hcGlfa2V5GAMgASgIEhAKCGJhc2VfdXJsGAQgASgJEg0KBW1vZGVsGAUgASgJEhYKDmdvb3NlX2Jhc2VfdXJsGAcgASgJEhgKEGhhc19nb29zZV9zZWNyZXQYCCABKAgSEgoKY29uZmlndXJlZBgJIAEoCEoECAYQByIVChNHZXRMTE1Db25maWdSZXF1ZXN0IjoKFEdldExMTUNvbmZpZ1Jlc3BvbnNlEiIKBmNvbmZpZxgBIAEoCzISLmNoYXQudjEuTExNQ29uZmlnIp4BChVEaXNjb3Zlck1vZGVsc1JlcXVlc3QSMgoIcHJvdmlkZXIYASABKA4yFC5jaGF0LnYxLkxMTVByb3ZpZGVyQgq6SAeCAQQQASAAEhkKB2FwaV9rZXkYAiABKAlCCLpIBXIDGIBAEhoKCGJhc2VfdXJsGAMgASgJQgi6SAVyAxiAEBIaChJ1c2Vfc3RvcmVkX2FwaV9rZXkYBCABKAgiMgoOQXZhaWxhYmxlTW9kZWwSCgoCaWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJIkEKFkRpc2NvdmVyTW9kZWxzUmVzcG9uc2USJwoGbW9kZWxzGAEgAygLMhcuY2hhdC52MS5BdmFpbGFibGVNb2RlbCLNAgoWVXBkYXRlTExNQ29uZmlnUmVxdWVzdBIyCgdoYXJuZXNzGAEgASgOMhUuY2hhdC52MS5BZ2VudEhhcm5lc3NCCrpIB4IBBBABIAASMgoIcHJvdmlkZXIYAiABKA4yFC5jaGF0LnYxLkxMTVByb3ZpZGVyQgq6SAeCAQQQASAAEhkKB2FwaV9rZXkYAyABKAlCCLpIBXIDGIBAEhoKCGJhc2VfdXJsGAQgASgJQgi6SAVyAxiAEBIZCgVtb2RlbBgFIAEoCUIKukgHcgUQARj/ARIgCg5nb29zZV9iYXNlX3VybBgHIAEoCUIIukgFcgMYgBASHgoMZ29vc2Vfc2VjcmV0GAggASgJQgi6SAVyAxiAQBIVCg1jbGVhcl9hcGlfa2V5GAkgASgIEhoKEmNsZWFyX2dvb3NlX3NlY3JldBgKIAEoCEoECAYQByI9ChdVcGRhdGVMTE1Db25maWdSZXNwb25zZRIiCgZjb25maWcYASABKAsyEi5jaGF0LnYxLkxMTUNvbmZpZyJVCghDaGF0VHVybhIrCgRyb2xlGAEgASgOMhEuY2hhdC52MS5DaGF0Um9sZUIKukgHggEEEAEgABIcCgdjb250ZW50GAIgASgJQgu6SAhyBhABGICAAiKDAQoSU2VuZE1lc3NhZ2VSZXF1ZXN0EiEKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCUIIukgFcgMYgAESHAoHY29udGVudBgCIAEoCUILukgIcgYQARiAgAISLAoHaGlzdG9yeRgDIAMoCzIRLmNoYXQudjEuQ2hhdFR1cm5CCLpIBZIBAhAyIhwKCVRleHREZWx0YRIPCgdjb250ZW50GAEgASgJIjUKCFRvb2xDYWxsEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSDwoHc3VtbWFyeRgDIAEoCSJICgpUb29sUmVzdWx0EgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSDwoHc3VjY2VzcxgDIAEoCBIPCgdzdW1tYXJ5GAQgASgJIh0KBERvbmUSFQoNZmluaXNoX3JlYXNvbhgBIAEoCSLPAQoTU2VuZE1lc3NhZ2VSZXNwb25zZRISCgptZXNzYWdlX2lkGAEgASgJEigKCnRleHRfZGVsdGEYAiABKAsyEi5jaGF0LnYxLlRleHREZWx0YUgAEiYKCXRvb2xfY2FsbBgDIAEoCzIRLmNoYXQudjEuVG9vbENhbGxIABIqCgt0b29sX3Jlc3VsdBgEIAEoCzITLmNoYXQudjEuVG9vbFJlc3VsdEgAEh0KBGRvbmUYBSABKAsyDS5jaGF0LnYxLkRvbmVIAEIHCgVldmVudCpgCgxBZ2VudEhhcm5lc3MSHQoZQUdFTlRfSEFSTkVTU19VTlNQRUNJRklFRBAAEhgKFEFHRU5UX0hBUk5FU1NfTkFUSVZFEAESFwoTQUdFTlRfSEFSTkVTU19HT09TRRACKpIBCgtMTE1Qcm92aWRlchIcChhMTE1fUFJPVklERVJfVU5TUEVDSUZJRUQQABIXChNMTE1fUFJPVklERVJfT1BFTkFJEAESGgoWTExNX1BST1ZJREVSX0FOVEhST1BJQxACEhcKE0xMTV9QUk9WSURFUl9PTExBTUEQAxIXChNMTE1fUFJPVklERVJfQ1VTVE9NEAQqUgoIQ2hhdFJvbGUSGQoVQ0hBVF9ST0xFX1VOU1BFQ0lGSUVEEAASEgoOQ0hBVF9ST0xFX1VTRVIQARIXChNDSEFUX1JPTEVfQVNTSVNUQU5UEAIyzwIKC0NoYXRTZXJ2aWNlEksKDEdldExMTUNvbmZpZxIcLmNoYXQudjEuR2V0TExNQ29uZmlnUmVxdWVzdBodLmNoYXQudjEuR2V0TExNQ29uZmlnUmVzcG9uc2USUQoORGlzY292ZXJNb2RlbHMSHi5jaGF0LnYxLkRpc2NvdmVyTW9kZWxzUmVxdWVzdBofLmNoYXQudjEuRGlzY292ZXJNb2RlbHNSZXNwb25zZRJUCg9VcGRhdGVMTE1Db25maWcSHy5jaGF0LnYxLlVwZGF0ZUxMTUNvbmZpZ1JlcXVlc3QaIC5jaGF0LnYxLlVwZGF0ZUxMTUNvbmZpZ1Jlc3BvbnNlEkoKC1NlbmRNZXNzYWdlEhsuY2hhdC52MS5TZW5kTWVzc2FnZVJlcXVlc3QaHC5jaGF0LnYxLlNlbmRNZXNzYWdlUmVzcG9uc2UwAUKYAQoLY29tLmNoYXQudjFCCUNoYXRQcm90b1ABWkFnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9jaGF0L3YxO2NoYXR2MaICA0NYWKoCB0NoYXQuVjHKAgdDaGF0XFYx4gITQ2hhdFxWMVxHUEJNZXRhZGF0YeoCCENoYXQ6OlYxYgZwcm90bzM", + [file_buf_validate_validate], + ); + +/** + * @generated from message chat.v1.LLMConfig + */ +export type LLMConfig = Message<"chat.v1.LLMConfig"> & { + /** + * @generated from field: chat.v1.AgentHarness harness = 1; + */ + harness: AgentHarness; + + /** + * @generated from field: chat.v1.LLMProvider provider = 2; + */ + provider: LLMProvider; + + /** + * @generated from field: bool has_api_key = 3; + */ + hasApiKey: boolean; + + /** + * @generated from field: string base_url = 4; + */ + baseUrl: string; + + /** + * @generated from field: string model = 5; + */ + model: string; + + /** + * @generated from field: string goose_base_url = 7; + */ + gooseBaseUrl: string; + + /** + * @generated from field: bool has_goose_secret = 8; + */ + hasGooseSecret: boolean; + + /** + * @generated from field: bool configured = 9; + */ + configured: boolean; +}; + +/** + * Describes the message chat.v1.LLMConfig. + * Use `create(LLMConfigSchema)` to create a new message. + */ +export const LLMConfigSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v1_chat, 0); + +/** + * @generated from message chat.v1.GetLLMConfigRequest + */ +export type GetLLMConfigRequest = Message<"chat.v1.GetLLMConfigRequest"> & {}; + +/** + * Describes the message chat.v1.GetLLMConfigRequest. + * Use `create(GetLLMConfigRequestSchema)` to create a new message. + */ +export const GetLLMConfigRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 1); + +/** + * @generated from message chat.v1.GetLLMConfigResponse + */ +export type GetLLMConfigResponse = Message<"chat.v1.GetLLMConfigResponse"> & { + /** + * @generated from field: chat.v1.LLMConfig config = 1; + */ + config?: LLMConfig | undefined; +}; + +/** + * Describes the message chat.v1.GetLLMConfigResponse. + * Use `create(GetLLMConfigResponseSchema)` to create a new message. + */ +export const GetLLMConfigResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 2); + +/** + * @generated from message chat.v1.DiscoverModelsRequest + */ +export type DiscoverModelsRequest = Message<"chat.v1.DiscoverModelsRequest"> & { + /** + * @generated from field: chat.v1.LLMProvider provider = 1; + */ + provider: LLMProvider; + + /** + * Write-only credential used for this discovery request. It is not saved. + * + * @generated from field: string api_key = 2; + */ + apiKey: string; + + /** + * @generated from field: string base_url = 3; + */ + baseUrl: string; + + /** + * Uses the encrypted credential already saved for this provider. This cannot + * be combined with api_key. + * + * @generated from field: bool use_stored_api_key = 4; + */ + useStoredApiKey: boolean; +}; + +/** + * Describes the message chat.v1.DiscoverModelsRequest. + * Use `create(DiscoverModelsRequestSchema)` to create a new message. + */ +export const DiscoverModelsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 3); + +/** + * @generated from message chat.v1.AvailableModel + */ +export type AvailableModel = Message<"chat.v1.AvailableModel"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string display_name = 2; + */ + displayName: string; +}; + +/** + * Describes the message chat.v1.AvailableModel. + * Use `create(AvailableModelSchema)` to create a new message. + */ +export const AvailableModelSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v1_chat, 4); + +/** + * @generated from message chat.v1.DiscoverModelsResponse + */ +export type DiscoverModelsResponse = Message<"chat.v1.DiscoverModelsResponse"> & { + /** + * @generated from field: repeated chat.v1.AvailableModel models = 1; + */ + models: AvailableModel[]; +}; + +/** + * Describes the message chat.v1.DiscoverModelsResponse. + * Use `create(DiscoverModelsResponseSchema)` to create a new message. + */ +export const DiscoverModelsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 5); + +/** + * @generated from message chat.v1.UpdateLLMConfigRequest + */ +export type UpdateLLMConfigRequest = Message<"chat.v1.UpdateLLMConfigRequest"> & { + /** + * @generated from field: chat.v1.AgentHarness harness = 1; + */ + harness: AgentHarness; + + /** + * @generated from field: chat.v1.LLMProvider provider = 2; + */ + provider: LLMProvider; + + /** + * Write-only. An empty value preserves the existing key unless + * clear_api_key is true. + * + * @generated from field: string api_key = 3; + */ + apiKey: string; + + /** + * @generated from field: string base_url = 4; + */ + baseUrl: string; + + /** + * @generated from field: string model = 5; + */ + model: string; + + /** + * @generated from field: string goose_base_url = 7; + */ + gooseBaseUrl: string; + + /** + * Write-only shared secret for a remote Goose ACP endpoint. + * + * @generated from field: string goose_secret = 8; + */ + gooseSecret: string; + + /** + * @generated from field: bool clear_api_key = 9; + */ + clearApiKey: boolean; + + /** + * @generated from field: bool clear_goose_secret = 10; + */ + clearGooseSecret: boolean; +}; + +/** + * Describes the message chat.v1.UpdateLLMConfigRequest. + * Use `create(UpdateLLMConfigRequestSchema)` to create a new message. + */ +export const UpdateLLMConfigRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 6); + +/** + * @generated from message chat.v1.UpdateLLMConfigResponse + */ +export type UpdateLLMConfigResponse = Message<"chat.v1.UpdateLLMConfigResponse"> & { + /** + * @generated from field: chat.v1.LLMConfig config = 1; + */ + config?: LLMConfig | undefined; +}; + +/** + * Describes the message chat.v1.UpdateLLMConfigResponse. + * Use `create(UpdateLLMConfigResponseSchema)` to create a new message. + */ +export const UpdateLLMConfigResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 7); + +/** + * @generated from message chat.v1.ChatTurn + */ +export type ChatTurn = Message<"chat.v1.ChatTurn"> & { + /** + * @generated from field: chat.v1.ChatRole role = 1; + */ + role: ChatRole; + + /** + * @generated from field: string content = 2; + */ + content: string; +}; + +/** + * Describes the message chat.v1.ChatTurn. + * Use `create(ChatTurnSchema)` to create a new message. + */ +export const ChatTurnSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v1_chat, 8); + +/** + * @generated from message chat.v1.SendMessageRequest + */ +export type SendMessageRequest = Message<"chat.v1.SendMessageRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string content = 2; + */ + content: string; + + /** + * Conversations remain client-side. Supplying prior turns keeps the server + * stateless while preserving multi-turn context. + * + * @generated from field: repeated chat.v1.ChatTurn history = 3; + */ + history: ChatTurn[]; +}; + +/** + * Describes the message chat.v1.SendMessageRequest. + * Use `create(SendMessageRequestSchema)` to create a new message. + */ +export const SendMessageRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v1_chat, 9); + +/** + * @generated from message chat.v1.TextDelta + */ +export type TextDelta = Message<"chat.v1.TextDelta"> & { + /** + * @generated from field: string content = 1; + */ + content: string; +}; + +/** + * Describes the message chat.v1.TextDelta. + * Use `create(TextDeltaSchema)` to create a new message. + */ +export const TextDeltaSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v1_chat, 10); + +/** + * @generated from message chat.v1.ToolCall + */ +export type ToolCall = Message<"chat.v1.ToolCall"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: string summary = 3; + */ + summary: string; +}; + +/** + * Describes the message chat.v1.ToolCall. + * Use `create(ToolCallSchema)` to create a new message. + */ +export const ToolCallSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v1_chat, 11); + +/** + * @generated from message chat.v1.ToolResult + */ +export type ToolResult = Message<"chat.v1.ToolResult"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: bool success = 3; + */ + success: boolean; + + /** + * @generated from field: string summary = 4; + */ + summary: string; +}; + +/** + * Describes the message chat.v1.ToolResult. + * Use `create(ToolResultSchema)` to create a new message. + */ +export const ToolResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v1_chat, 12); + +/** + * @generated from message chat.v1.Done + */ +export type Done = Message<"chat.v1.Done"> & { + /** + * @generated from field: string finish_reason = 1; + */ + finishReason: string; +}; + +/** + * Describes the message chat.v1.Done. + * Use `create(DoneSchema)` to create a new message. + */ +export const DoneSchema: GenMessage = /*@__PURE__*/ messageDesc(file_chat_v1_chat, 13); + +/** + * @generated from message chat.v1.SendMessageResponse + */ +export type SendMessageResponse = Message<"chat.v1.SendMessageResponse"> & { + /** + * @generated from field: string message_id = 1; + */ + messageId: string; + + /** + * @generated from oneof chat.v1.SendMessageResponse.event + */ + event: + | { + /** + * @generated from field: chat.v1.TextDelta text_delta = 2; + */ + value: TextDelta; + case: "textDelta"; + } + | { + /** + * @generated from field: chat.v1.ToolCall tool_call = 3; + */ + value: ToolCall; + case: "toolCall"; + } + | { + /** + * @generated from field: chat.v1.ToolResult tool_result = 4; + */ + value: ToolResult; + case: "toolResult"; + } + | { + /** + * @generated from field: chat.v1.Done done = 5; + */ + value: Done; + case: "done"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message chat.v1.SendMessageResponse. + * Use `create(SendMessageResponseSchema)` to create a new message. + */ +export const SendMessageResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 14); + +/** + * @generated from enum chat.v1.AgentHarness + */ +export enum AgentHarness { + /** + * @generated from enum value: AGENT_HARNESS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AGENT_HARNESS_NATIVE = 1; + */ + NATIVE = 1, + + /** + * @generated from enum value: AGENT_HARNESS_GOOSE = 2; + */ + GOOSE = 2, +} + +/** + * Describes the enum chat.v1.AgentHarness. + */ +export const AgentHarnessSchema: GenEnum = /*@__PURE__*/ enumDesc(file_chat_v1_chat, 0); + +/** + * @generated from enum chat.v1.LLMProvider + */ +export enum LLMProvider { + /** + * @generated from enum value: LLM_PROVIDER_UNSPECIFIED = 0; + */ + LLM_PROVIDER_UNSPECIFIED = 0, + + /** + * @generated from enum value: LLM_PROVIDER_OPENAI = 1; + */ + LLM_PROVIDER_OPENAI = 1, + + /** + * @generated from enum value: LLM_PROVIDER_ANTHROPIC = 2; + */ + LLM_PROVIDER_ANTHROPIC = 2, + + /** + * @generated from enum value: LLM_PROVIDER_OLLAMA = 3; + */ + LLM_PROVIDER_OLLAMA = 3, + + /** + * @generated from enum value: LLM_PROVIDER_CUSTOM = 4; + */ + LLM_PROVIDER_CUSTOM = 4, +} + +/** + * Describes the enum chat.v1.LLMProvider. + */ +export const LLMProviderSchema: GenEnum = /*@__PURE__*/ enumDesc(file_chat_v1_chat, 1); + +/** + * @generated from enum chat.v1.ChatRole + */ +export enum ChatRole { + /** + * @generated from enum value: CHAT_ROLE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CHAT_ROLE_USER = 1; + */ + USER = 1, + + /** + * @generated from enum value: CHAT_ROLE_ASSISTANT = 2; + */ + ASSISTANT = 2, +} + +/** + * Describes the enum chat.v1.ChatRole. + */ +export const ChatRoleSchema: GenEnum = /*@__PURE__*/ enumDesc(file_chat_v1_chat, 2); + +/** + * ChatService owns the organization-scoped BYOLLM configuration and the + * streaming agent conversation boundary used by the Proto Fleet web client. + * + * @generated from service chat.v1.ChatService + */ +export const ChatService: GenService<{ + /** + * @generated from rpc chat.v1.ChatService.GetLLMConfig + */ + getLLMConfig: { + methodKind: "unary"; + input: typeof GetLLMConfigRequestSchema; + output: typeof GetLLMConfigResponseSchema; + }; + /** + * @generated from rpc chat.v1.ChatService.DiscoverModels + */ + discoverModels: { + methodKind: "unary"; + input: typeof DiscoverModelsRequestSchema; + output: typeof DiscoverModelsResponseSchema; + }; + /** + * @generated from rpc chat.v1.ChatService.UpdateLLMConfig + */ + updateLLMConfig: { + methodKind: "unary"; + input: typeof UpdateLLMConfigRequestSchema; + output: typeof UpdateLLMConfigResponseSchema; + }; + /** + * @generated from rpc chat.v1.ChatService.SendMessage + */ + sendMessage: { + methodKind: "server_streaming"; + input: typeof SendMessageRequestSchema; + output: typeof SendMessageResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_chat_v1_chat, 0); diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx index 3c093bbf30..1dbae4398c 100644 --- a/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx +++ b/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx @@ -34,6 +34,11 @@ vi.mock("@/protoFleet/components/PageHeader", () => ({ default: () =>
Page header
, })); +vi.mock("@/protoFleet/features/aiChat", () => ({ + ChatFab: () =>
, + ChatPanel: () =>
, +})); + vi.mock("@/protoFleet/components/PageHeader/useSchedulePillData", () => ({ useSchedulePillData: () => mockUseSchedulePillData(), })); @@ -251,4 +256,32 @@ describe("AppLayout", () => { expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12)]"); }); + + it("shows AI chat when the operator can read the fleet", () => { + render( + + +
Body content
+
+
, + ); + + expect(screen.getByTestId("ai-chat-fab")).toBeInTheDocument(); + expect(screen.getByTestId("ai-chat-panel")).toBeInTheDocument(); + }); + + it("hides AI chat when the operator cannot read the fleet", () => { + vi.mocked(useHasPermission).mockImplementation((permission) => permission !== "fleet:read"); + + render( + + +
Body content
+
+
, + ); + + expect(screen.queryByTestId("ai-chat-fab")).not.toBeInTheDocument(); + expect(screen.queryByTestId("ai-chat-panel")).not.toBeInTheDocument(); + }); }); diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.tsx index 516b4533ab..7158c905fc 100644 --- a/client/src/protoFleet/components/AppLayout/AppLayout.tsx +++ b/client/src/protoFleet/components/AppLayout/AppLayout.tsx @@ -16,6 +16,7 @@ import { import { useCurtailmentPillData } from "@/protoFleet/components/PageHeader/useCurtailmentPillData"; import { useSchedulePillData } from "@/protoFleet/components/PageHeader/useSchedulePillData"; import { primaryNavItems } from "@/protoFleet/config/navItems"; +import { ChatFab, ChatPanel } from "@/protoFleet/features/aiChat"; import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; import { useHasPermission } from "@/protoFleet/store"; import { Menu } from "@/shared/assets/icons"; @@ -36,6 +37,7 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => { const { activeEvent: activeCurtailmentEvent } = useCurtailmentPillData(); const hasDismissedSetup = Boolean(dismissedSetup); const canReadCurtailment = useHasPermission("curtailment:read"); + const canReadFleet = useHasPermission("fleet:read"); const hasVisibleCurtailmentPill = activeCurtailmentEvent !== null && canReadCurtailment; const headerWidgetCount = getVisibleHeaderWidgetCount({ hasDismissedSetup, @@ -115,6 +117,14 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => { > {children}
+ + {canReadFleet ? ( + <> + {/* AI Chat: FAB launcher + slide-up panel */} + + + + ) : null}
); }; diff --git a/client/src/protoFleet/config/navItems.test.ts b/client/src/protoFleet/config/navItems.test.ts index 2ccf3f4da3..815a355a27 100644 --- a/client/src/protoFleet/config/navItems.test.ts +++ b/client/src/protoFleet/config/navItems.test.ts @@ -100,6 +100,18 @@ describe("secondaryNavItems", () => { ); }); + it("gives agent configuration its own Admin destination", () => { + expect(secondaryNavItems).toContainEqual( + expect.objectContaining({ + path: "/settings/agents", + label: "Agents", + parent: "/settings", + section: "Admin", + requiredPermission: "apikey:manage", + }), + ); + }); + it("folds role management into the Team destination", () => { expect(secondaryNavItems).toContainEqual( expect.objectContaining({ diff --git a/client/src/protoFleet/config/navItems.ts b/client/src/protoFleet/config/navItems.ts index fb2ad915ec..6238d2acfb 100644 --- a/client/src/protoFleet/config/navItems.ts +++ b/client/src/protoFleet/config/navItems.ts @@ -191,6 +191,13 @@ export const secondaryNavItems: SecondaryNavItem[] = [ // surface is usable so role-only admins are not stranded after the merge. requiredAnyPermission: ["user:read", "role:manage"], }, + { + path: "/settings/agents", + label: "Agents", + parent: "/settings", + section: "Admin", + requiredPermission: "apikey:manage", + }, { path: "/settings/integrations", label: "Integrations", diff --git a/client/src/protoFleet/features/aiChat/AgentActivityStatus.test.tsx b/client/src/protoFleet/features/aiChat/AgentActivityStatus.test.tsx new file mode 100644 index 0000000000..441c89db52 --- /dev/null +++ b/client/src/protoFleet/features/aiChat/AgentActivityStatus.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import AgentActivityStatus from "./AgentActivityStatus"; +import type { AgentActivity } from "./types"; + +const activity = (status: AgentActivity["status"], summary: string): AgentActivity => ({ + id: "activity-1", + status, + summary, + timestamp: new Date("2026-07-16T12:00:00Z"), + sequence: 1, +}); + +describe("AgentActivityStatus", () => { + test("renders compact running, completed, and unsuccessful states", () => { + const { rerender } = render(); + + expect(screen.getByRole("img", { name: "In progress" })).toBeInTheDocument(); + expect(screen.getByText("Checking fleet health")).toBeInTheDocument(); + expect(screen.getByTestId("agent-activity-status").tagName).toBe("DIV"); + + rerender(); + expect(screen.getByRole("img", { name: "Completed" })).toBeInTheDocument(); + expect(screen.getByText("Read state for 14 miners")).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole("img", { name: "Couldn't complete" })).toBeInTheDocument(); + expect(screen.getByText("Unable to read this fleet data")).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/aiChat/AgentActivityStatus.tsx b/client/src/protoFleet/features/aiChat/AgentActivityStatus.tsx new file mode 100644 index 0000000000..5110675827 --- /dev/null +++ b/client/src/protoFleet/features/aiChat/AgentActivityStatus.tsx @@ -0,0 +1,39 @@ +import clsx from "clsx"; + +import type { AgentActivity } from "./types"; +import { Checkmark, DismissTiny } from "@/shared/assets/icons"; +import ProgressCircular from "@/shared/components/ProgressCircular"; + +const STATUS_LABELS: Record = { + running: "In progress", + completed: "Completed", + failed: "Couldn't complete", +}; + +interface AgentActivityStatusProps { + activity: AgentActivity; +} + +const AgentActivityStatus = ({ activity }: AgentActivityStatusProps) => ( +
+ + {activity.status === "completed" ? : null} + {activity.status === "running" ? : null} + {activity.status === "failed" ? : null} + + {activity.summary} +
+); + +export default AgentActivityStatus; diff --git a/client/src/protoFleet/features/aiChat/ChatFab.tsx b/client/src/protoFleet/features/aiChat/ChatFab.tsx new file mode 100644 index 0000000000..dc49f8dc37 --- /dev/null +++ b/client/src/protoFleet/features/aiChat/ChatFab.tsx @@ -0,0 +1,34 @@ +import { AnimatePresence, motion } from "motion/react"; + +import { useChatStore } from "./useChatStore"; +import { LogoAlt } from "@/shared/assets/icons"; + +const ChatFab = () => { + const isOpen = useChatStore((state) => state.isOpen); + const open = useChatStore((state) => state.open); + + return ( + + {!isOpen ? ( + + + + ) : null} + + ); +}; + +export default ChatFab; diff --git a/client/src/protoFleet/features/aiChat/ChatHeaderButton.tsx b/client/src/protoFleet/features/aiChat/ChatHeaderButton.tsx new file mode 100644 index 0000000000..efd64872ed --- /dev/null +++ b/client/src/protoFleet/features/aiChat/ChatHeaderButton.tsx @@ -0,0 +1,20 @@ +import { Dismiss, LogoAlt } from "@/shared/assets/icons"; + +type ChatHeaderButtonProps = { + onClose: () => void; +}; + +const ChatHeaderButton = ({ onClose }: ChatHeaderButtonProps) => ( + +); + +export default ChatHeaderButton; diff --git a/client/src/protoFleet/features/aiChat/ChatInput.tsx b/client/src/protoFleet/features/aiChat/ChatInput.tsx new file mode 100644 index 0000000000..2964c7c48e --- /dev/null +++ b/client/src/protoFleet/features/aiChat/ChatInput.tsx @@ -0,0 +1,63 @@ +import { FormEvent, KeyboardEvent, useState } from "react"; + +import { ArrowUp } from "@/shared/assets/icons"; + +type ChatInputProps = { + disabled?: boolean; + onSend: (content: string) => void; +}; + +const ChatInput = ({ disabled = false, onSend }: ChatInputProps) => { + const [content, setContent] = useState(""); + const canSend = content.trim().length > 0 && !disabled; + + const submit = () => { + const nextMessage = content.trim(); + if (!nextMessage || disabled) return; + onSend(nextMessage); + setContent(""); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + submit(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submit(); + } + }; + + return ( +
+ +
+