diff --git a/client/src/protoFleet/api/clients.ts b/client/src/protoFleet/api/clients.ts index 6da8b36c1..638dd0145 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 000000000..59fef2edb --- /dev/null +++ b/client/src/protoFleet/api/generated/chat/v1/chat_pb.ts @@ -0,0 +1,734 @@ +// @generated by protoc-gen-es v2.13.0 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/ARIgCg5nb29zZV9iYXNlX3VybBgHIAEoCUIIukgFcgMYgBASHgoMZ29vc2Vfc2VjcmV0GAggASgJQgi6SAVyAxiAQBIVCg1jbGVhcl9hcGlfa2V5GAkgASgIEhoKEmNsZWFyX2dvb3NlX3NlY3JldBgKIAEoCEoECAYQByI9ChdVcGRhdGVMTE1Db25maWdSZXNwb25zZRIiCgZjb25maWcYASABKAsyEi5jaGF0LnYxLkxMTUNvbmZpZyJVCghDaGF0VHVybhIrCgRyb2xlGAEgASgOMhEuY2hhdC52MS5DaGF0Um9sZUIKukgHggEEEAEgABIcCgdjb250ZW50GAIgASgJQgu6SAhyBhABGICAAiKDAQoSU2VuZE1lc3NhZ2VSZXF1ZXN0EiEKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCUIIukgFcgMYgAESHAoHY29udGVudBgCIAEoCUILukgIcgYQARiAgAISLAoHaGlzdG9yeRgDIAMoCzIRLmNoYXQudjEuQ2hhdFR1cm5CCLpIBZIBAhAyIhwKCVRleHREZWx0YRIPCgdjb250ZW50GAEgASgJIjUKCFRvb2xDYWxsEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSDwoHc3VtbWFyeRgDIAEoCSJbCgpUb29sUmVzdWx0EgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSDwoHc3VjY2VzcxgDIAEoCBIPCgdzdW1tYXJ5GAQgASgJEhEKCWNhbmNlbGxlZBgFIAEoCCI2ChZUb29sQ29uZmlybWF0aW9uRGV0YWlsEg0KBWxhYmVsGAEgASgJEg0KBXZhbHVlGAIgASgJIskBChhUb29sQ29uZmlybWF0aW9uUmVxdWlyZWQSFwoPY29uZmlybWF0aW9uX2lkGAEgASgJEhQKDHRvb2xfY2FsbF9pZBgCIAEoCRIRCgl0b29sX25hbWUYAyABKAkSDQoFdGl0bGUYBCABKAkSEwoLZGVzY3JpcHRpb24YBSABKAkSFQoNY29uZmlybV9sYWJlbBgGIAEoCRIwCgdkZXRhaWxzGAcgAygLMh8uY2hhdC52MS5Ub29sQ29uZmlybWF0aW9uRGV0YWlsIoYBCh5SZXNvbHZlVG9vbENvbmZpcm1hdGlvblJlcXVlc3QSIwoPY29uZmlybWF0aW9uX2lkGAEgASgJQgq6SAdyBRABGIABEj8KCGRlY2lzaW9uGAIgASgOMiEuY2hhdC52MS5Ub29sQ29uZmlybWF0aW9uRGVjaXNpb25CCrpIB4IBBBABIAAiIQofUmVzb2x2ZVRvb2xDb25maXJtYXRpb25SZXNwb25zZSIdCgREb25lEhUKDWZpbmlzaF9yZWFzb24YASABKAkikwIKE1NlbmRNZXNzYWdlUmVzcG9uc2USEgoKbWVzc2FnZV9pZBgBIAEoCRIoCgp0ZXh0X2RlbHRhGAIgASgLMhIuY2hhdC52MS5UZXh0RGVsdGFIABImCgl0b29sX2NhbGwYAyABKAsyES5jaGF0LnYxLlRvb2xDYWxsSAASKgoLdG9vbF9yZXN1bHQYBCABKAsyEy5jaGF0LnYxLlRvb2xSZXN1bHRIABIdCgRkb25lGAUgASgLMg0uY2hhdC52MS5Eb25lSAASQgoVY29uZmlybWF0aW9uX3JlcXVpcmVkGAYgASgLMiEuY2hhdC52MS5Ub29sQ29uZmlybWF0aW9uUmVxdWlyZWRIAEIHCgVldmVudCpgCgxBZ2VudEhhcm5lc3MSHQoZQUdFTlRfSEFSTkVTU19VTlNQRUNJRklFRBAAEhgKFEFHRU5UX0hBUk5FU1NfTkFUSVZFEAESFwoTQUdFTlRfSEFSTkVTU19HT09TRRACKpIBCgtMTE1Qcm92aWRlchIcChhMTE1fUFJPVklERVJfVU5TUEVDSUZJRUQQABIXChNMTE1fUFJPVklERVJfT1BFTkFJEAESGgoWTExNX1BST1ZJREVSX0FOVEhST1BJQxACEhcKE0xMTV9QUk9WSURFUl9PTExBTUEQAxIXChNMTE1fUFJPVklERVJfQ1VTVE9NEAQqUgoIQ2hhdFJvbGUSGQoVQ0hBVF9ST0xFX1VOU1BFQ0lGSUVEEAASEgoOQ0hBVF9ST0xFX1VTRVIQARIXChNDSEFUX1JPTEVfQVNTSVNUQU5UEAIqlQEKGFRvb2xDb25maXJtYXRpb25EZWNpc2lvbhIqCiZUT09MX0NPTkZJUk1BVElPTl9ERUNJU0lPTl9VTlNQRUNJRklFRBAAEiYKIlRPT0xfQ09ORklSTUFUSU9OX0RFQ0lTSU9OX0FQUFJPVkUQARIlCiFUT09MX0NPTkZJUk1BVElPTl9ERUNJU0lPTl9DQU5DRUwQAjK9AwoLQ2hhdFNlcnZpY2USSwoMR2V0TExNQ29uZmlnEhwuY2hhdC52MS5HZXRMTE1Db25maWdSZXF1ZXN0Gh0uY2hhdC52MS5HZXRMTE1Db25maWdSZXNwb25zZRJRCg5EaXNjb3Zlck1vZGVscxIeLmNoYXQudjEuRGlzY292ZXJNb2RlbHNSZXF1ZXN0Gh8uY2hhdC52MS5EaXNjb3Zlck1vZGVsc1Jlc3BvbnNlElQKD1VwZGF0ZUxMTUNvbmZpZxIfLmNoYXQudjEuVXBkYXRlTExNQ29uZmlnUmVxdWVzdBogLmNoYXQudjEuVXBkYXRlTExNQ29uZmlnUmVzcG9uc2USSgoLU2VuZE1lc3NhZ2USGy5jaGF0LnYxLlNlbmRNZXNzYWdlUmVxdWVzdBocLmNoYXQudjEuU2VuZE1lc3NhZ2VSZXNwb25zZTABEmwKF1Jlc29sdmVUb29sQ29uZmlybWF0aW9uEicuY2hhdC52MS5SZXNvbHZlVG9vbENvbmZpcm1hdGlvblJlcXVlc3QaKC5jaGF0LnYxLlJlc29sdmVUb29sQ29uZmlybWF0aW9uUmVzcG9uc2VCmAEKC2NvbS5jaGF0LnYxQglDaGF0UHJvdG9QAVpBZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvY2hhdC92MTtjaGF0djGiAgNDWFiqAgdDaGF0LlYxygIHQ2hhdFxWMeICE0NoYXRcVjFcR1BCTWV0YWRhdGHqAghDaGF0OjpWMWIGcHJvdG8z", + [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; + + /** + * @generated from field: bool cancelled = 5; + */ + cancelled: boolean; +}; + +/** + * 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.ToolConfirmationDetail + */ +export type ToolConfirmationDetail = Message<"chat.v1.ToolConfirmationDetail"> & { + /** + * @generated from field: string label = 1; + */ + label: string; + + /** + * @generated from field: string value = 2; + */ + value: string; +}; + +/** + * Describes the message chat.v1.ToolConfirmationDetail. + * Use `create(ToolConfirmationDetailSchema)` to create a new message. + */ +export const ToolConfirmationDetailSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 13); + +/** + * @generated from message chat.v1.ToolConfirmationRequired + */ +export type ToolConfirmationRequired = Message<"chat.v1.ToolConfirmationRequired"> & { + /** + * Opaque, short-lived identifier bound to the current operator and org. + * + * @generated from field: string confirmation_id = 1; + */ + confirmationId: string; + + /** + * @generated from field: string tool_call_id = 2; + */ + toolCallId: string; + + /** + * @generated from field: string tool_name = 3; + */ + toolName: string; + + /** + * @generated from field: string title = 4; + */ + title: string; + + /** + * @generated from field: string description = 5; + */ + description: string; + + /** + * @generated from field: string confirm_label = 6; + */ + confirmLabel: string; + + /** + * @generated from field: repeated chat.v1.ToolConfirmationDetail details = 7; + */ + details: ToolConfirmationDetail[]; +}; + +/** + * Describes the message chat.v1.ToolConfirmationRequired. + * Use `create(ToolConfirmationRequiredSchema)` to create a new message. + */ +export const ToolConfirmationRequiredSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 14); + +/** + * @generated from message chat.v1.ResolveToolConfirmationRequest + */ +export type ResolveToolConfirmationRequest = Message<"chat.v1.ResolveToolConfirmationRequest"> & { + /** + * @generated from field: string confirmation_id = 1; + */ + confirmationId: string; + + /** + * @generated from field: chat.v1.ToolConfirmationDecision decision = 2; + */ + decision: ToolConfirmationDecision; +}; + +/** + * Describes the message chat.v1.ResolveToolConfirmationRequest. + * Use `create(ResolveToolConfirmationRequestSchema)` to create a new message. + */ +export const ResolveToolConfirmationRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 15); + +/** + * @generated from message chat.v1.ResolveToolConfirmationResponse + */ +export type ResolveToolConfirmationResponse = Message<"chat.v1.ResolveToolConfirmationResponse"> & {}; + +/** + * Describes the message chat.v1.ResolveToolConfirmationResponse. + * Use `create(ResolveToolConfirmationResponseSchema)` to create a new message. + */ +export const ResolveToolConfirmationResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_chat_v1_chat, 16); + +/** + * @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, 17); + +/** + * @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"; + } + | { + /** + * @generated from field: chat.v1.ToolConfirmationRequired confirmation_required = 6; + */ + value: ToolConfirmationRequired; + case: "confirmationRequired"; + } + | { 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, 18); + +/** + * @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); + +/** + * @generated from enum chat.v1.ToolConfirmationDecision + */ +export enum ToolConfirmationDecision { + /** + * @generated from enum value: TOOL_CONFIRMATION_DECISION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: TOOL_CONFIRMATION_DECISION_APPROVE = 1; + */ + APPROVE = 1, + + /** + * @generated from enum value: TOOL_CONFIRMATION_DECISION_CANCEL = 2; + */ + CANCEL = 2, +} + +/** + * Describes the enum chat.v1.ToolConfirmationDecision. + */ +export const ToolConfirmationDecisionSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_chat_v1_chat, 3); + +/** + * 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; + }; + /** + * ResolveToolConfirmation approves or cancels a pending write requested by + * SendMessage. The original stream remains open and resumes after this RPC. + * + * @generated from rpc chat.v1.ChatService.ResolveToolConfirmation + */ + resolveToolConfirmation: { + methodKind: "unary"; + input: typeof ResolveToolConfirmationRequestSchema; + output: typeof ResolveToolConfirmationResponseSchema; + }; +}> = /*@__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 3c093bbf3..8b7343885 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(), })); @@ -184,6 +189,19 @@ describe("AppLayout", () => { expect(screen.getByText("Body content").parentElement).toHaveClass("top-[calc(theme(spacing.1)*12)]"); }); + it("hides the floating Minerbot launcher on the dedicated Minerbot page", () => { + render( + + +
Body content
+
+
, + ); + + expect(screen.queryByTestId("ai-chat-fab")).not.toBeInTheDocument(); + expect(screen.queryByTestId("ai-chat-panel")).not.toBeInTheDocument(); + }); + it("uses the two-widget phone content offset when all three header widgets are visible", () => { mockUseReactiveLocalStorage.mockReturnValue([true, vi.fn()]); mockUseCurtailmentPillData.mockReturnValue({ activeEvent: activeCurtailmentEvent }); @@ -251,4 +269,32 @@ describe("AppLayout", () => { expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12)]"); }); + + it("shows Minerbot 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 Minerbot 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 516b4533a..5c0e6e217 100644 --- a/client/src/protoFleet/components/AppLayout/AppLayout.tsx +++ b/client/src/protoFleet/components/AppLayout/AppLayout.tsx @@ -1,4 +1,5 @@ import { ReactNode, useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import clsx from "clsx"; import NavigationMenu from "../NavigationMenu"; @@ -16,6 +17,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"; @@ -29,6 +31,7 @@ type Props = { const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => { const [isMenuOpen, setIsMenuOpen] = useState(false); + const { pathname } = useLocation(); const { bgClass } = usePageBackground(); const { isPhone, isTablet } = useWindowDimensions(); const [dismissedSetup] = useReactiveLocalStorage("completeSetupDismissed"); @@ -36,6 +39,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, @@ -46,6 +50,7 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => { const phoneRowWidgetCount = getPhoneHeaderWidgetRowCount(headerWidgetCount, inlineFirstPhoneWidget); const stackPhoneWidgets = shouldStackPhoneHeaderWidgets(headerWidgetCount); + const isMinerbotPage = pathname === "/minerbot"; const showDetailMenuTrigger = hideShellHeader && (isPhone || isTablet) && !isMenuOpen; const showPhoneWidgets = !hideShellHeader && isPhone && phoneRowWidgetCount > 0; @@ -115,6 +120,14 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => { > {children}
+ + {canReadFleet && !isMinerbotPage ? ( + <> + {/* 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 2ccf3f4da..1e622e2f3 100644 --- a/client/src/protoFleet/config/navItems.test.ts +++ b/client/src/protoFleet/config/navItems.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getSettingsLandingPath, isNavItemAllowedByPermissions, primaryNavItems, secondaryNavItems } from "./navItems"; -import { LightningAlt } from "@/shared/assets/icons"; +import { AIStroked, LightningAlt } from "@/shared/assets/icons"; describe("primaryNavItems", () => { it("shows Energy above Activity with the electric icon", () => { @@ -51,6 +51,18 @@ describe("primaryNavItems", () => { expect(home?.requiredPermission).toBeUndefined(); expect(home?.requiredAnyPermission).toBeUndefined(); }); + + it("adds Minerbot as a fleet-readable primary destination before Settings", () => { + const labels = primaryNavItems.map((item) => item.label); + const minerbot = primaryNavItems.find((item) => item.label === "Minerbot"); + + expect(minerbot).toMatchObject({ + path: "/minerbot", + icon: AIStroked, + requiredPermission: "fleet:read", + }); + expect(labels.indexOf("Minerbot")).toBe(labels.indexOf("Settings") - 1); + }); }); describe("secondaryNavItems", () => { @@ -100,6 +112,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 fb2ad915e..d9b471302 100644 --- a/client/src/protoFleet/config/navItems.ts +++ b/client/src/protoFleet/config/navItems.ts @@ -1,6 +1,6 @@ import { type ReactNode } from "react"; -import { Activity, Fleet, Groups, Home, IconProps, LightningAlt, Settings } from "@/shared/assets/icons"; +import { Activity, AIStroked, Fleet, Groups, Home, IconProps, LightningAlt, Settings } from "@/shared/assets/icons"; // Runtime-gated features: an entry tagged with one is shown only when the server // reports the feature enabled (see SecondaryNavigation). Distinct from @@ -102,6 +102,12 @@ export const primaryNavItems: NavItem[] = [ requiredPermission: "activity:read", scopable: true, }, + { + path: "/minerbot", + label: "Minerbot", + icon: AIStroked, + requiredPermission: "fleet:read", + }, { path: "/settings", label: "Settings", @@ -191,6 +197,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 000000000..441c89db5 --- /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 000000000..c369ad8c9 --- /dev/null +++ b/client/src/protoFleet/features/aiChat/AgentActivityStatus.tsx @@ -0,0 +1,42 @@ +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", + cancelled: "Cancelled", +}; + +interface AgentActivityStatusProps { + activity: AgentActivity; +} + +const AgentActivityStatus = ({ activity }: AgentActivityStatusProps) => ( +
+ + {activity.status === "completed" ? : null} + {activity.status === "running" ? : null} + {activity.status === "failed" ? : null} + {activity.status === "cancelled" ? : null} + + {activity.summary} +
+); + +export default AgentActivityStatus; diff --git a/client/src/protoFleet/features/aiChat/ChatConversation.tsx b/client/src/protoFleet/features/aiChat/ChatConversation.tsx new file mode 100644 index 000000000..16b5a2c96 --- /dev/null +++ b/client/src/protoFleet/features/aiChat/ChatConversation.tsx @@ -0,0 +1,123 @@ +import { type ReactNode, type Ref } from "react"; +import clsx from "clsx"; + +import AgentActivityStatus from "./AgentActivityStatus"; +import ChatMessageContent from "./ChatMessageContent"; +import ToolConfirmationCard from "./ToolConfirmationCard"; +import type { ToolConfirmation } from "./types"; +import type { MinerbotConversationItem } from "./useMinerbotConversation"; +import { Alert } from "@/shared/assets/icons"; +import Callout from "@/shared/components/Callout"; + +type ChatConversationProps = { + conversationEndRef?: Ref; + conversationItems: MinerbotConversationItem[]; + header?: ReactNode; + hasPendingConfirmation: boolean; + isStreaming: boolean; + layout?: "panel" | "fullScreen"; + onOpenSettings: () => void; + onResolveConfirmation: (confirmation: ToolConfirmation, decision: "approve" | "cancel") => void; + streamError: string; + streamingContent: string; +}; + +const ChatConversation = ({ + conversationEndRef, + conversationItems, + header, + hasPendingConfirmation, + isStreaming, + layout = "panel", + onOpenSettings, + onResolveConfirmation, + streamError, + streamingContent, +}: ChatConversationProps) => { + const isFullScreen = layout === "fullScreen"; + + return ( +
+ {header ?
{header}
: null} + {conversationItems.map((item) => + item.kind === "activity" ? ( + + ) : item.kind === "confirmation" ? ( + + ) : ( +
+
+ {item.message.role === "assistant" ? ( + + ) : ( + item.message.content + )} +
+
+ ), + )} + {isStreaming && streamingContent ? ( +
+
+ +
+
+ ) : null} + {isStreaming && !streamingContent && !hasPendingConfirmation ? ( +
+
+ {[0, 1, 2].map((dot) => ( + + ))} +
+
+ ) : null} + {streamError ? ( + } + title={streamError} + /> + ) : null} +
+
+ ); +}; + +export default ChatConversation; diff --git a/client/src/protoFleet/features/aiChat/ChatFab.tsx b/client/src/protoFleet/features/aiChat/ChatFab.tsx new file mode 100644 index 000000000..2a1cd5fab --- /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 { AI } 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/ChatInput.tsx b/client/src/protoFleet/features/aiChat/ChatInput.tsx new file mode 100644 index 000000000..00200508a --- /dev/null +++ b/client/src/protoFleet/features/aiChat/ChatInput.tsx @@ -0,0 +1,64 @@ +import { FormEvent, KeyboardEvent, useState } from "react"; + +import { ArrowUp } from "@/shared/assets/icons"; +import Button, { variants } from "@/shared/components/Button"; + +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 ( +
+ +
+