From b4dd1043f87c4437609976e10be06a52c40fdbc2 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 8 Jun 2026 18:47:32 +0800 Subject: [PATCH 1/9] feat(tunnel): add local project tunnel support --- Cargo.lock | 2 + .../internal/proto/v1/gateway.pb.go | 1726 ++++++++++++----- crates/agent-gateway/internal/server/grpc.go | 22 +- crates/agent-gateway/internal/server/http.go | 1 + .../internal/server/http_test.go | 8 +- .../agent-gateway/internal/server/tunnel.go | 722 +++++++ .../internal/server/tunnel_rewrite.go | 229 +++ .../internal/server/tunnel_rewrite_test.go | 269 +++ .../internal/server/websocket.go | 19 + .../server/websocket_chat_handlers.go | 4 +- .../internal/server/websocket_roundtrip.go | 2 +- .../internal/server/websocket_routes.go | 4 + .../internal/server/websocket_routes_test.go | 56 +- .../server/websocket_tunnel_handlers.go | 322 +++ .../internal/session/agent_session.go | 67 +- .../agent-gateway/internal/session/manager.go | 8 +- .../internal/session/manager_chat_runs.go | 10 + .../internal/session/manager_registry.go | 12 + .../internal/session/manager_tunnel.go | 843 ++++++++ .../internal/session/manager_tunnel_test.go | 265 +++ crates/agent-gateway/proto/v1/gateway.proto | 75 + .../test/session/manager_test.go | 23 + .../test/upload/import_readable_files_test.go | 3 +- .../test/websocket/chat_bridge_test.go | 47 +- .../test/webui/gateway-socket-client.test.mjs | 202 ++ .../test/webui/web-settings.test.mjs | 68 +- crates/agent-gateway/web/src/App.tsx | 129 +- .../project-tools/LocalTunnelPanel.tsx | 783 ++++++++ .../project-tools/ProjectToolsPanel.tsx | 182 +- crates/agent-gateway/web/src/i18n/config.ts | 107 + .../web/src/lib/gatewaySocket.ts | 176 ++ .../web/src/lib/gatewaySocket.worker.ts | 65 + .../web/src/lib/settings/index.ts | 103 +- .../web/src/lib/settings/storage.ts | 6 +- .../web/src/lib/settings/sync.ts | 99 +- .../agent-gateway/web/src/lib/webSettings.ts | 2 + .../web/src/pages/chat/ChatComposerBar.tsx | 48 +- .../web/src/pages/settings/RemoteSection.tsx | 20 + crates/agent-gui/src-tauri/Cargo.toml | 2 + .../src-tauri/src/commands/gateway.rs | 49 +- .../src-tauri/src/commands/settings.rs | 12 + crates/agent-gui/src-tauri/src/lib.rs | 6 + .../src-tauri/src/services/gateway.rs | 1411 +++++++++++++- .../project-tools/LocalTunnelPanel.tsx | 804 ++++++++ .../project-tools/ProjectToolsPanel.tsx | 177 +- crates/agent-gui/src/i18n/config.ts | 108 ++ crates/agent-gui/src/lib/settings/index.ts | 110 +- crates/agent-gui/src/lib/settings/storage.ts | 6 +- crates/agent-gui/src/lib/settings/sync.ts | 98 +- .../src/lib/tools/builtinRegistry.ts | 27 + .../src/lib/tools/tunnelManagerTools.ts | 322 +++ crates/agent-gui/src/pages/ChatPage.tsx | 101 +- .../src/pages/chat/ChatComposerBar.tsx | 37 + .../pages/chat/runAgentConversationTurn.ts | 26 +- .../pages/chat/useGatewayBridgeListeners.ts | 201 +- .../src/pages/settings/RemoteSection.tsx | 18 + .../test/settings/normalization.test.mjs | 159 +- .../test/tools/tunnel-manager-tools.test.mjs | 209 ++ 58 files changed, 9899 insertions(+), 713 deletions(-) create mode 100644 crates/agent-gateway/internal/server/tunnel.go create mode 100644 crates/agent-gateway/internal/server/tunnel_rewrite.go create mode 100644 crates/agent-gateway/internal/server/tunnel_rewrite_test.go create mode 100644 crates/agent-gateway/internal/server/websocket_tunnel_handlers.go create mode 100644 crates/agent-gateway/internal/session/manager_tunnel.go create mode 100644 crates/agent-gateway/internal/session/manager_tunnel_test.go create mode 100644 crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx create mode 100644 crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx create mode 100644 crates/agent-gui/src/lib/tools/tunnelManagerTools.ts create mode 100644 crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs diff --git a/Cargo.lock b/Cargo.lock index c936b824e..5361af655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,6 +2627,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dirs", + "futures-util", "globset", "ignore", "lopdf", @@ -2652,6 +2653,7 @@ dependencies = [ "tokio", "tokio-cron-scheduler", "tokio-stream", + "tokio-tungstenite", "tonic", "tonic-build", "uuid", diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index 0d65fb222..019569c1e 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -21,6 +21,82 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type TunnelFrameKind int32 + +const ( + TunnelFrameKind_TUNNEL_FRAME_KIND_UNSPECIFIED TunnelFrameKind = 0 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START TunnelFrameKind = 1 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY TunnelFrameKind = 2 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_END TunnelFrameKind = 3 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START TunnelFrameKind = 4 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY TunnelFrameKind = 5 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END TunnelFrameKind = 6 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN TunnelFrameKind = 7 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME TunnelFrameKind = 8 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE TunnelFrameKind = 9 + TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR TunnelFrameKind = 10 + TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL TunnelFrameKind = 11 +) + +// Enum value maps for TunnelFrameKind. +var ( + TunnelFrameKind_name = map[int32]string{ + 0: "TUNNEL_FRAME_KIND_UNSPECIFIED", + 1: "TUNNEL_FRAME_KIND_HTTP_REQUEST_START", + 2: "TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY", + 3: "TUNNEL_FRAME_KIND_HTTP_REQUEST_END", + 4: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_START", + 5: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY", + 6: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_END", + 7: "TUNNEL_FRAME_KIND_WS_OPEN", + 8: "TUNNEL_FRAME_KIND_WS_FRAME", + 9: "TUNNEL_FRAME_KIND_WS_CLOSE", + 10: "TUNNEL_FRAME_KIND_ERROR", + 11: "TUNNEL_FRAME_KIND_CANCEL", + } + TunnelFrameKind_value = map[string]int32{ + "TUNNEL_FRAME_KIND_UNSPECIFIED": 0, + "TUNNEL_FRAME_KIND_HTTP_REQUEST_START": 1, + "TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY": 2, + "TUNNEL_FRAME_KIND_HTTP_REQUEST_END": 3, + "TUNNEL_FRAME_KIND_HTTP_RESPONSE_START": 4, + "TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY": 5, + "TUNNEL_FRAME_KIND_HTTP_RESPONSE_END": 6, + "TUNNEL_FRAME_KIND_WS_OPEN": 7, + "TUNNEL_FRAME_KIND_WS_FRAME": 8, + "TUNNEL_FRAME_KIND_WS_CLOSE": 9, + "TUNNEL_FRAME_KIND_ERROR": 10, + "TUNNEL_FRAME_KIND_CANCEL": 11, + } +) + +func (x TunnelFrameKind) Enum() *TunnelFrameKind { + p := new(TunnelFrameKind) + *p = x + return p +} + +func (x TunnelFrameKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TunnelFrameKind) Descriptor() protoreflect.EnumDescriptor { + return file_proto_v1_gateway_proto_enumTypes[0].Descriptor() +} + +func (TunnelFrameKind) Type() protoreflect.EnumType { + return &file_proto_v1_gateway_proto_enumTypes[0] +} + +func (x TunnelFrameKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TunnelFrameKind.Descriptor instead. +func (TunnelFrameKind) EnumDescriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{0} +} + type ChatEvent_ChatEventType int32 const ( @@ -69,11 +145,11 @@ func (x ChatEvent_ChatEventType) String() string { } func (ChatEvent_ChatEventType) Descriptor() protoreflect.EnumDescriptor { - return file_proto_v1_gateway_proto_enumTypes[0].Descriptor() + return file_proto_v1_gateway_proto_enumTypes[1].Descriptor() } func (ChatEvent_ChatEventType) Type() protoreflect.EnumType { - return &file_proto_v1_gateway_proto_enumTypes[0] + return &file_proto_v1_gateway_proto_enumTypes[1] } func (x ChatEvent_ChatEventType) Number() protoreflect.EnumNumber { @@ -82,7 +158,7 @@ func (x ChatEvent_ChatEventType) Number() protoreflect.EnumNumber { // Deprecated: Use ChatEvent_ChatEventType.Descriptor instead. func (ChatEvent_ChatEventType) EnumDescriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23, 0} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28, 0} } type AuthRequest struct { @@ -248,6 +324,9 @@ type GatewayEnvelope struct { // *GatewayEnvelope_GitRequest // *GatewayEnvelope_FsReadEditableText // *GatewayEnvelope_FsReadWorkspaceImage + // *GatewayEnvelope_TunnelControl + // *GatewayEnvelope_TunnelControlResp + // *GatewayEnvelope_TunnelFrame Payload isGatewayEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -637,6 +716,33 @@ func (x *GatewayEnvelope) GetFsReadWorkspaceImage() *FsReadWorkspaceImageRequest return nil } +func (x *GatewayEnvelope) GetTunnelControl() *TunnelControlRequest { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_TunnelControl); ok { + return x.TunnelControl + } + } + return nil +} + +func (x *GatewayEnvelope) GetTunnelControlResp() *TunnelControlResponse { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_TunnelControlResp); ok { + return x.TunnelControlResp + } + } + return nil +} + +func (x *GatewayEnvelope) GetTunnelFrame() *TunnelFrame { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_TunnelFrame); ok { + return x.TunnelFrame + } + } + return nil +} + type isGatewayEnvelope_Payload interface { isGatewayEnvelope_Payload() } @@ -789,6 +895,18 @@ type GatewayEnvelope_FsReadWorkspaceImage struct { FsReadWorkspaceImage *FsReadWorkspaceImageRequest `protobuf:"bytes,63,opt,name=fs_read_workspace_image,json=fsReadWorkspaceImage,proto3,oneof"` } +type GatewayEnvelope_TunnelControl struct { + TunnelControl *TunnelControlRequest `protobuf:"bytes,67,opt,name=tunnel_control,json=tunnelControl,proto3,oneof"` +} + +type GatewayEnvelope_TunnelControlResp struct { + TunnelControlResp *TunnelControlResponse `protobuf:"bytes,68,opt,name=tunnel_control_resp,json=tunnelControlResp,proto3,oneof"` +} + +type GatewayEnvelope_TunnelFrame struct { + TunnelFrame *TunnelFrame `protobuf:"bytes,69,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +} + func (*GatewayEnvelope_ChatRequest) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_CancelChat) isGatewayEnvelope_Payload() {} @@ -863,6 +981,12 @@ func (*GatewayEnvelope_FsReadEditableText) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_FsReadWorkspaceImage) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_TunnelControl) isGatewayEnvelope_Payload() {} + +func (*GatewayEnvelope_TunnelControlResp) isGatewayEnvelope_Payload() {} + +func (*GatewayEnvelope_TunnelFrame) isGatewayEnvelope_Payload() {} + type AgentEnvelope struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -908,6 +1032,9 @@ type AgentEnvelope struct { // *AgentEnvelope_GitResponse // *AgentEnvelope_FsReadEditableTextResp // *AgentEnvelope_FsReadWorkspaceImageResp + // *AgentEnvelope_TunnelControl + // *AgentEnvelope_TunnelControlResp + // *AgentEnvelope_TunnelFrame // *AgentEnvelope_Error Payload isAgentEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields @@ -1316,6 +1443,33 @@ func (x *AgentEnvelope) GetFsReadWorkspaceImageResp() *FsReadWorkspaceImageRespo return nil } +func (x *AgentEnvelope) GetTunnelControl() *TunnelControlRequest { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelControl); ok { + return x.TunnelControl + } + } + return nil +} + +func (x *AgentEnvelope) GetTunnelControlResp() *TunnelControlResponse { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelControlResp); ok { + return x.TunnelControlResp + } + } + return nil +} + +func (x *AgentEnvelope) GetTunnelFrame() *TunnelFrame { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelFrame); ok { + return x.TunnelFrame + } + } + return nil +} + func (x *AgentEnvelope) GetError() *ErrorResponse { if x != nil { if x, ok := x.Payload.(*AgentEnvelope_Error); ok { @@ -1485,6 +1639,18 @@ type AgentEnvelope_FsReadWorkspaceImageResp struct { FsReadWorkspaceImageResp *FsReadWorkspaceImageResponse `protobuf:"bytes,66,opt,name=fs_read_workspace_image_resp,json=fsReadWorkspaceImageResp,proto3,oneof"` } +type AgentEnvelope_TunnelControl struct { + TunnelControl *TunnelControlRequest `protobuf:"bytes,67,opt,name=tunnel_control,json=tunnelControl,proto3,oneof"` +} + +type AgentEnvelope_TunnelControlResp struct { + TunnelControlResp *TunnelControlResponse `protobuf:"bytes,68,opt,name=tunnel_control_resp,json=tunnelControlResp,proto3,oneof"` +} + +type AgentEnvelope_TunnelFrame struct { + TunnelFrame *TunnelFrame `protobuf:"bytes,69,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +} + type AgentEnvelope_Error struct { Error *ErrorResponse `protobuf:"bytes,99,opt,name=error,proto3,oneof"` } @@ -1567,6 +1733,12 @@ func (*AgentEnvelope_FsReadEditableTextResp) isAgentEnvelope_Payload() {} func (*AgentEnvelope_FsReadWorkspaceImageResp) isAgentEnvelope_Payload() {} +func (*AgentEnvelope_TunnelControl) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelControlResp) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelFrame) isAgentEnvelope_Payload() {} + func (*AgentEnvelope_Error) isAgentEnvelope_Payload() {} type ChatSelectedModel struct { @@ -2000,35 +2172,527 @@ func (x *UploadedImagePreviewResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*UploadedImagePreviewResponse) ProtoMessage() {} +func (*UploadedImagePreviewResponse) ProtoMessage() {} + +func (x *UploadedImagePreviewResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadedImagePreviewResponse.ProtoReflect.Descriptor instead. +func (*UploadedImagePreviewResponse) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{11} +} + +func (x *UploadedImagePreviewResponse) GetMimeType() string { + if x != nil { + return x.MimeType + } + return "" +} + +func (x *UploadedImagePreviewResponse) GetData() string { + if x != nil { + return x.Data + } + return "" +} + +type TunnelControlRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` + TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` + TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` + Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` + TtlSeconds uint32 `protobuf:"varint,6,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` + ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + PublicUrl string `protobuf:"bytes,8,opt,name=public_url,json=publicUrl,proto3" json:"public_url,omitempty"` + PublicBaseUrl string `protobuf:"bytes,9,opt,name=public_base_url,json=publicBaseUrl,proto3" json:"public_base_url,omitempty"` + ProjectPathKey string `protobuf:"bytes,10,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelControlRequest) Reset() { + *x = TunnelControlRequest{} + mi := &file_proto_v1_gateway_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelControlRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelControlRequest) ProtoMessage() {} + +func (x *TunnelControlRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelControlRequest.ProtoReflect.Descriptor instead. +func (*TunnelControlRequest) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{12} +} + +func (x *TunnelControlRequest) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *TunnelControlRequest) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelControlRequest) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *TunnelControlRequest) GetTargetUrl() string { + if x != nil { + return x.TargetUrl + } + return "" +} + +func (x *TunnelControlRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TunnelControlRequest) GetTtlSeconds() uint32 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +func (x *TunnelControlRequest) GetExpiresAt() int64 { + if x != nil { + return x.ExpiresAt + } + return 0 +} + +func (x *TunnelControlRequest) GetPublicUrl() string { + if x != nil { + return x.PublicUrl + } + return "" +} + +func (x *TunnelControlRequest) GetPublicBaseUrl() string { + if x != nil { + return x.PublicBaseUrl + } + return "" +} + +func (x *TunnelControlRequest) GetProjectPathKey() string { + if x != nil { + return x.ProjectPathKey + } + return "" +} + +type TunnelControlResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` + Tunnels []*TunnelSummary `protobuf:"bytes,2,rep,name=tunnels,proto3" json:"tunnels,omitempty"` + Tunnel *TunnelSummary `protobuf:"bytes,3,opt,name=tunnel,proto3" json:"tunnel,omitempty"` + ErrorCode string `protobuf:"bytes,4,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + ErrorMessage string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelControlResponse) Reset() { + *x = TunnelControlResponse{} + mi := &file_proto_v1_gateway_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelControlResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelControlResponse) ProtoMessage() {} + +func (x *TunnelControlResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelControlResponse.ProtoReflect.Descriptor instead. +func (*TunnelControlResponse) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{13} +} + +func (x *TunnelControlResponse) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *TunnelControlResponse) GetTunnels() []*TunnelSummary { + if x != nil { + return x.Tunnels + } + return nil +} + +func (x *TunnelControlResponse) GetTunnel() *TunnelSummary { + if x != nil { + return x.Tunnel + } + return nil +} + +func (x *TunnelControlResponse) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *TunnelControlResponse) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +type TunnelSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` + PublicUrl string `protobuf:"bytes,5,opt,name=public_url,json=publicUrl,proto3" json:"public_url,omitempty"` + CreatedAt int64 `protobuf:"varint,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + ActiveConnections uint32 `protobuf:"varint,8,opt,name=active_connections,json=activeConnections,proto3" json:"active_connections,omitempty"` + Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"` + ProjectPathKey string `protobuf:"bytes,10,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelSummary) Reset() { + *x = TunnelSummary{} + mi := &file_proto_v1_gateway_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelSummary) ProtoMessage() {} + +func (x *TunnelSummary) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelSummary.ProtoReflect.Descriptor instead. +func (*TunnelSummary) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{14} +} + +func (x *TunnelSummary) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *TunnelSummary) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *TunnelSummary) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TunnelSummary) GetTargetUrl() string { + if x != nil { + return x.TargetUrl + } + return "" +} + +func (x *TunnelSummary) GetPublicUrl() string { + if x != nil { + return x.PublicUrl + } + return "" +} + +func (x *TunnelSummary) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *TunnelSummary) GetExpiresAt() int64 { + if x != nil { + return x.ExpiresAt + } + return 0 +} + +func (x *TunnelSummary) GetActiveConnections() uint32 { + if x != nil { + return x.ActiveConnections + } + return 0 +} + +func (x *TunnelSummary) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *TunnelSummary) GetProjectPathKey() string { + if x != nil { + return x.ProjectPathKey + } + return "" +} + +type TunnelHeader struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelHeader) Reset() { + *x = TunnelHeader{} + mi := &file_proto_v1_gateway_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelHeader) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelHeader) ProtoMessage() {} + +func (x *TunnelHeader) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelHeader.ProtoReflect.Descriptor instead. +func (*TunnelHeader) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{15} +} + +func (x *TunnelHeader) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TunnelHeader) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type TunnelFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` + Kind TunnelFrameKind `protobuf:"varint,4,opt,name=kind,proto3,enum=liveagent.gateway.v1.TunnelFrameKind" json:"kind,omitempty"` + Method string `protobuf:"bytes,5,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,6,opt,name=path,proto3" json:"path,omitempty"` + Headers []*TunnelHeader `protobuf:"bytes,7,rep,name=headers,proto3" json:"headers,omitempty"` + StatusCode uint32 `protobuf:"varint,8,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + Body []byte `protobuf:"bytes,9,opt,name=body,proto3" json:"body,omitempty"` + EndStream bool `protobuf:"varint,10,opt,name=end_stream,json=endStream,proto3" json:"end_stream,omitempty"` + Error string `protobuf:"bytes,11,opt,name=error,proto3" json:"error,omitempty"` + WsMessageType string `protobuf:"bytes,12,opt,name=ws_message_type,json=wsMessageType,proto3" json:"ws_message_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelFrame) Reset() { + *x = TunnelFrame{} + mi := &file_proto_v1_gateway_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelFrame) ProtoMessage() {} + +func (x *TunnelFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelFrame.ProtoReflect.Descriptor instead. +func (*TunnelFrame) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{16} +} + +func (x *TunnelFrame) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *TunnelFrame) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelFrame) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *TunnelFrame) GetKind() TunnelFrameKind { + if x != nil { + return x.Kind + } + return TunnelFrameKind_TUNNEL_FRAME_KIND_UNSPECIFIED +} + +func (x *TunnelFrame) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *TunnelFrame) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *TunnelFrame) GetHeaders() []*TunnelHeader { + if x != nil { + return x.Headers + } + return nil +} + +func (x *TunnelFrame) GetStatusCode() uint32 { + if x != nil { + return x.StatusCode + } + return 0 +} -func (x *UploadedImagePreviewResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[11] +func (x *TunnelFrame) GetBody() []byte { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.Body } - return mi.MessageOf(x) + return nil } -// Deprecated: Use UploadedImagePreviewResponse.ProtoReflect.Descriptor instead. -func (*UploadedImagePreviewResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{11} +func (x *TunnelFrame) GetEndStream() bool { + if x != nil { + return x.EndStream + } + return false } -func (x *UploadedImagePreviewResponse) GetMimeType() string { +func (x *TunnelFrame) GetError() string { if x != nil { - return x.MimeType + return x.Error } return "" } -func (x *UploadedImagePreviewResponse) GetData() string { +func (x *TunnelFrame) GetWsMessageType() string { if x != nil { - return x.Data + return x.WsMessageType } return "" } @@ -2043,7 +2707,7 @@ type MemoryManageRequest struct { func (x *MemoryManageRequest) Reset() { *x = MemoryManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[12] + mi := &file_proto_v1_gateway_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2055,7 +2719,7 @@ func (x *MemoryManageRequest) String() string { func (*MemoryManageRequest) ProtoMessage() {} func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[12] + mi := &file_proto_v1_gateway_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2068,7 +2732,7 @@ func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MemoryManageRequest.ProtoReflect.Descriptor instead. func (*MemoryManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{12} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{17} } func (x *MemoryManageRequest) GetCommand() string { @@ -2094,7 +2758,7 @@ type MemoryManageResponse struct { func (x *MemoryManageResponse) Reset() { *x = MemoryManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[13] + mi := &file_proto_v1_gateway_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2106,7 +2770,7 @@ func (x *MemoryManageResponse) String() string { func (*MemoryManageResponse) ProtoMessage() {} func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[13] + mi := &file_proto_v1_gateway_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2119,7 +2783,7 @@ func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MemoryManageResponse.ProtoReflect.Descriptor instead. func (*MemoryManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{13} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{18} } func (x *MemoryManageResponse) GetResultJson() string { @@ -2147,7 +2811,7 @@ type TerminalRequest struct { func (x *TerminalRequest) Reset() { *x = TerminalRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[14] + mi := &file_proto_v1_gateway_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2159,7 +2823,7 @@ func (x *TerminalRequest) String() string { func (*TerminalRequest) ProtoMessage() {} func (x *TerminalRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[14] + mi := &file_proto_v1_gateway_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2172,7 +2836,7 @@ func (x *TerminalRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalRequest.ProtoReflect.Descriptor instead. func (*TerminalRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{14} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{19} } func (x *TerminalRequest) GetAction() string { @@ -2266,7 +2930,7 @@ type TerminalSession struct { func (x *TerminalSession) Reset() { *x = TerminalSession{} - mi := &file_proto_v1_gateway_proto_msgTypes[15] + mi := &file_proto_v1_gateway_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2278,7 +2942,7 @@ func (x *TerminalSession) String() string { func (*TerminalSession) ProtoMessage() {} func (x *TerminalSession) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[15] + mi := &file_proto_v1_gateway_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2291,7 +2955,7 @@ func (x *TerminalSession) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSession.ProtoReflect.Descriptor instead. func (*TerminalSession) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{15} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{20} } func (x *TerminalSession) GetId() string { @@ -2396,7 +3060,7 @@ type TerminalShellOption struct { func (x *TerminalShellOption) Reset() { *x = TerminalShellOption{} - mi := &file_proto_v1_gateway_proto_msgTypes[16] + mi := &file_proto_v1_gateway_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2408,7 +3072,7 @@ func (x *TerminalShellOption) String() string { func (*TerminalShellOption) ProtoMessage() {} func (x *TerminalShellOption) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[16] + mi := &file_proto_v1_gateway_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2421,7 +3085,7 @@ func (x *TerminalShellOption) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalShellOption.ProtoReflect.Descriptor instead. func (*TerminalShellOption) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{16} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{21} } func (x *TerminalShellOption) GetId() string { @@ -2462,7 +3126,7 @@ type TerminalResponse struct { func (x *TerminalResponse) Reset() { *x = TerminalResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[17] + mi := &file_proto_v1_gateway_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2474,7 +3138,7 @@ func (x *TerminalResponse) String() string { func (*TerminalResponse) ProtoMessage() {} func (x *TerminalResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[17] + mi := &file_proto_v1_gateway_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2487,7 +3151,7 @@ func (x *TerminalResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalResponse.ProtoReflect.Descriptor instead. func (*TerminalResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{17} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{22} } func (x *TerminalResponse) GetAction() string { @@ -2568,7 +3232,7 @@ type TerminalEvent struct { func (x *TerminalEvent) Reset() { *x = TerminalEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[18] + mi := &file_proto_v1_gateway_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2580,7 +3244,7 @@ func (x *TerminalEvent) String() string { func (*TerminalEvent) ProtoMessage() {} func (x *TerminalEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[18] + mi := &file_proto_v1_gateway_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2593,7 +3257,7 @@ func (x *TerminalEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalEvent.ProtoReflect.Descriptor instead. func (*TerminalEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{18} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23} } func (x *TerminalEvent) GetKind() string { @@ -2656,7 +3320,7 @@ type GitRequest struct { func (x *GitRequest) Reset() { *x = GitRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[19] + mi := &file_proto_v1_gateway_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2668,7 +3332,7 @@ func (x *GitRequest) String() string { func (*GitRequest) ProtoMessage() {} func (x *GitRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[19] + mi := &file_proto_v1_gateway_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2681,7 +3345,7 @@ func (x *GitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GitRequest.ProtoReflect.Descriptor instead. func (*GitRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{19} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{24} } func (x *GitRequest) GetAction() string { @@ -2715,7 +3379,7 @@ type GitResponse struct { func (x *GitResponse) Reset() { *x = GitResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[20] + mi := &file_proto_v1_gateway_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2727,7 +3391,7 @@ func (x *GitResponse) String() string { func (*GitResponse) ProtoMessage() {} func (x *GitResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[20] + mi := &file_proto_v1_gateway_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2740,7 +3404,7 @@ func (x *GitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GitResponse.ProtoReflect.Descriptor instead. func (*GitResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{20} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{25} } func (x *GitResponse) GetAction() string { @@ -2774,7 +3438,7 @@ type ChatRequest struct { func (x *ChatRequest) Reset() { *x = ChatRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[21] + mi := &file_proto_v1_gateway_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2786,7 +3450,7 @@ func (x *ChatRequest) String() string { func (*ChatRequest) ProtoMessage() {} func (x *ChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[21] + mi := &file_proto_v1_gateway_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2799,7 +3463,7 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead. func (*ChatRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{21} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{26} } func (x *ChatRequest) GetConversationId() string { @@ -2874,7 +3538,7 @@ type CancelChatRequest struct { func (x *CancelChatRequest) Reset() { *x = CancelChatRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[22] + mi := &file_proto_v1_gateway_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2886,7 +3550,7 @@ func (x *CancelChatRequest) String() string { func (*CancelChatRequest) ProtoMessage() {} func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[22] + mi := &file_proto_v1_gateway_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2899,7 +3563,7 @@ func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelChatRequest.ProtoReflect.Descriptor instead. func (*CancelChatRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{22} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{27} } func (x *CancelChatRequest) GetConversationId() string { @@ -2920,7 +3584,7 @@ type ChatEvent struct { func (x *ChatEvent) Reset() { *x = ChatEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[23] + mi := &file_proto_v1_gateway_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2932,7 +3596,7 @@ func (x *ChatEvent) String() string { func (*ChatEvent) ProtoMessage() {} func (x *ChatEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[23] + mi := &file_proto_v1_gateway_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2945,7 +3609,7 @@ func (x *ChatEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatEvent.ProtoReflect.Descriptor instead. func (*ChatEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28} } func (x *ChatEvent) GetType() ChatEvent_ChatEventType { @@ -2980,7 +3644,7 @@ type CronManageRequest struct { func (x *CronManageRequest) Reset() { *x = CronManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[24] + mi := &file_proto_v1_gateway_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2992,7 +3656,7 @@ func (x *CronManageRequest) String() string { func (*CronManageRequest) ProtoMessage() {} func (x *CronManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[24] + mi := &file_proto_v1_gateway_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3005,7 +3669,7 @@ func (x *CronManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageRequest.ProtoReflect.Descriptor instead. func (*CronManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{24} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{29} } func (x *CronManageRequest) GetAction() string { @@ -3039,7 +3703,7 @@ type CronManageResponse struct { func (x *CronManageResponse) Reset() { *x = CronManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[25] + mi := &file_proto_v1_gateway_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3051,7 +3715,7 @@ func (x *CronManageResponse) String() string { func (*CronManageResponse) ProtoMessage() {} func (x *CronManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[25] + mi := &file_proto_v1_gateway_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3064,7 +3728,7 @@ func (x *CronManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageResponse.ProtoReflect.Descriptor instead. func (*CronManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{25} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{30} } func (x *CronManageResponse) GetAction() string { @@ -3093,7 +3757,7 @@ type HistoryListRequest struct { func (x *HistoryListRequest) Reset() { *x = HistoryListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[26] + mi := &file_proto_v1_gateway_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3105,7 +3769,7 @@ func (x *HistoryListRequest) String() string { func (*HistoryListRequest) ProtoMessage() {} func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[26] + mi := &file_proto_v1_gateway_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3118,7 +3782,7 @@ func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListRequest.ProtoReflect.Descriptor instead. func (*HistoryListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{26} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{31} } func (x *HistoryListRequest) GetPage() int32 { @@ -3159,7 +3823,7 @@ type HistoryListResponse struct { func (x *HistoryListResponse) Reset() { *x = HistoryListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[27] + mi := &file_proto_v1_gateway_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3171,7 +3835,7 @@ func (x *HistoryListResponse) String() string { func (*HistoryListResponse) ProtoMessage() {} func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[27] + mi := &file_proto_v1_gateway_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3184,7 +3848,7 @@ func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListResponse.ProtoReflect.Descriptor instead. func (*HistoryListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{27} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{32} } func (x *HistoryListResponse) GetConversations() []*ConversationSummary { @@ -3221,7 +3885,7 @@ type ConversationSummary struct { func (x *ConversationSummary) Reset() { *x = ConversationSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[28] + mi := &file_proto_v1_gateway_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3233,7 +3897,7 @@ func (x *ConversationSummary) String() string { func (*ConversationSummary) ProtoMessage() {} func (x *ConversationSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[28] + mi := &file_proto_v1_gateway_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3246,7 +3910,7 @@ func (x *ConversationSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use ConversationSummary.ProtoReflect.Descriptor instead. func (*ConversationSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{33} } func (x *ConversationSummary) GetId() string { @@ -3343,7 +4007,7 @@ type HistoryGetRequest struct { func (x *HistoryGetRequest) Reset() { *x = HistoryGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[29] + mi := &file_proto_v1_gateway_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3355,7 +4019,7 @@ func (x *HistoryGetRequest) String() string { func (*HistoryGetRequest) ProtoMessage() {} func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[29] + mi := &file_proto_v1_gateway_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3368,7 +4032,7 @@ func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetRequest.ProtoReflect.Descriptor instead. func (*HistoryGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{29} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{34} } func (x *HistoryGetRequest) GetConversationId() string { @@ -3399,7 +4063,7 @@ type HistoryGetResponse struct { func (x *HistoryGetResponse) Reset() { *x = HistoryGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[30] + mi := &file_proto_v1_gateway_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3411,7 +4075,7 @@ func (x *HistoryGetResponse) String() string { func (*HistoryGetResponse) ProtoMessage() {} func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[30] + mi := &file_proto_v1_gateway_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3424,7 +4088,7 @@ func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetResponse.ProtoReflect.Descriptor instead. func (*HistoryGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{30} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{35} } func (x *HistoryGetResponse) GetConversationId() string { @@ -3479,7 +4143,7 @@ type HistoryRenameRequest struct { func (x *HistoryRenameRequest) Reset() { *x = HistoryRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[31] + mi := &file_proto_v1_gateway_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3491,7 +4155,7 @@ func (x *HistoryRenameRequest) String() string { func (*HistoryRenameRequest) ProtoMessage() {} func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[31] + mi := &file_proto_v1_gateway_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3504,7 +4168,7 @@ func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameRequest.ProtoReflect.Descriptor instead. func (*HistoryRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{31} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{36} } func (x *HistoryRenameRequest) GetConversationId() string { @@ -3530,7 +4194,7 @@ type HistoryRenameResponse struct { func (x *HistoryRenameResponse) Reset() { *x = HistoryRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[32] + mi := &file_proto_v1_gateway_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3542,7 +4206,7 @@ func (x *HistoryRenameResponse) String() string { func (*HistoryRenameResponse) ProtoMessage() {} func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[32] + mi := &file_proto_v1_gateway_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3555,7 +4219,7 @@ func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameResponse.ProtoReflect.Descriptor instead. func (*HistoryRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{32} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{37} } func (x *HistoryRenameResponse) GetConversation() *ConversationSummary { @@ -3575,7 +4239,7 @@ type HistoryPinRequest struct { func (x *HistoryPinRequest) Reset() { *x = HistoryPinRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[33] + mi := &file_proto_v1_gateway_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3587,7 +4251,7 @@ func (x *HistoryPinRequest) String() string { func (*HistoryPinRequest) ProtoMessage() {} func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[33] + mi := &file_proto_v1_gateway_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3600,7 +4264,7 @@ func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinRequest.ProtoReflect.Descriptor instead. func (*HistoryPinRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{33} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{38} } func (x *HistoryPinRequest) GetConversationId() string { @@ -3626,7 +4290,7 @@ type HistoryPinResponse struct { func (x *HistoryPinResponse) Reset() { *x = HistoryPinResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[34] + mi := &file_proto_v1_gateway_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3638,7 +4302,7 @@ func (x *HistoryPinResponse) String() string { func (*HistoryPinResponse) ProtoMessage() {} func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[34] + mi := &file_proto_v1_gateway_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3651,7 +4315,7 @@ func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinResponse.ProtoReflect.Descriptor instead. func (*HistoryPinResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{34} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{39} } func (x *HistoryPinResponse) GetConversation() *ConversationSummary { @@ -3675,7 +4339,7 @@ type HistoryShareStatus struct { func (x *HistoryShareStatus) Reset() { *x = HistoryShareStatus{} - mi := &file_proto_v1_gateway_proto_msgTypes[35] + mi := &file_proto_v1_gateway_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3687,7 +4351,7 @@ func (x *HistoryShareStatus) String() string { func (*HistoryShareStatus) ProtoMessage() {} func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[35] + mi := &file_proto_v1_gateway_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3700,7 +4364,7 @@ func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareStatus.ProtoReflect.Descriptor instead. func (*HistoryShareStatus) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{35} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{40} } func (x *HistoryShareStatus) GetConversationId() string { @@ -3754,7 +4418,7 @@ type HistoryShareGetRequest struct { func (x *HistoryShareGetRequest) Reset() { *x = HistoryShareGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[36] + mi := &file_proto_v1_gateway_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3766,7 +4430,7 @@ func (x *HistoryShareGetRequest) String() string { func (*HistoryShareGetRequest) ProtoMessage() {} func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[36] + mi := &file_proto_v1_gateway_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3779,7 +4443,7 @@ func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{36} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{41} } func (x *HistoryShareGetRequest) GetConversationId() string { @@ -3798,7 +4462,7 @@ type HistoryShareGetResponse struct { func (x *HistoryShareGetResponse) Reset() { *x = HistoryShareGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[37] + mi := &file_proto_v1_gateway_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3810,7 +4474,7 @@ func (x *HistoryShareGetResponse) String() string { func (*HistoryShareGetResponse) ProtoMessage() {} func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[37] + mi := &file_proto_v1_gateway_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3823,7 +4487,7 @@ func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{37} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{42} } func (x *HistoryShareGetResponse) GetShare() *HistoryShareStatus { @@ -3844,7 +4508,7 @@ type HistoryShareSetRequest struct { func (x *HistoryShareSetRequest) Reset() { *x = HistoryShareSetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[38] + mi := &file_proto_v1_gateway_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3856,7 +4520,7 @@ func (x *HistoryShareSetRequest) String() string { func (*HistoryShareSetRequest) ProtoMessage() {} func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[38] + mi := &file_proto_v1_gateway_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3869,7 +4533,7 @@ func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareSetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{38} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43} } func (x *HistoryShareSetRequest) GetConversationId() string { @@ -3902,7 +4566,7 @@ type HistoryShareSetResponse struct { func (x *HistoryShareSetResponse) Reset() { *x = HistoryShareSetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[39] + mi := &file_proto_v1_gateway_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3914,7 +4578,7 @@ func (x *HistoryShareSetResponse) String() string { func (*HistoryShareSetResponse) ProtoMessage() {} func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[39] + mi := &file_proto_v1_gateway_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3927,7 +4591,7 @@ func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareSetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{39} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44} } func (x *HistoryShareSetResponse) GetShare() *HistoryShareStatus { @@ -3946,7 +4610,7 @@ type HistoryShareResolveRequest struct { func (x *HistoryShareResolveRequest) Reset() { *x = HistoryShareResolveRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[40] + mi := &file_proto_v1_gateway_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3958,7 +4622,7 @@ func (x *HistoryShareResolveRequest) String() string { func (*HistoryShareResolveRequest) ProtoMessage() {} func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[40] + mi := &file_proto_v1_gateway_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3971,7 +4635,7 @@ func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveRequest.ProtoReflect.Descriptor instead. func (*HistoryShareResolveRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{40} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{45} } func (x *HistoryShareResolveRequest) GetToken() string { @@ -3994,7 +4658,7 @@ type HistoryShareResolveResponse struct { func (x *HistoryShareResolveResponse) Reset() { *x = HistoryShareResolveResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[41] + mi := &file_proto_v1_gateway_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4006,7 +4670,7 @@ func (x *HistoryShareResolveResponse) String() string { func (*HistoryShareResolveResponse) ProtoMessage() {} func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[41] + mi := &file_proto_v1_gateway_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4019,7 +4683,7 @@ func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveResponse.ProtoReflect.Descriptor instead. func (*HistoryShareResolveResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{41} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{46} } func (x *HistoryShareResolveResponse) GetConversationId() string { @@ -4065,7 +4729,7 @@ type HistoryWorkdirsRequest struct { func (x *HistoryWorkdirsRequest) Reset() { *x = HistoryWorkdirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[42] + mi := &file_proto_v1_gateway_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4077,7 +4741,7 @@ func (x *HistoryWorkdirsRequest) String() string { func (*HistoryWorkdirsRequest) ProtoMessage() {} func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[42] + mi := &file_proto_v1_gateway_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4090,7 +4754,7 @@ func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsRequest.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{42} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{47} } type HistoryWorkdirSummary struct { @@ -4104,7 +4768,7 @@ type HistoryWorkdirSummary struct { func (x *HistoryWorkdirSummary) Reset() { *x = HistoryWorkdirSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[43] + mi := &file_proto_v1_gateway_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4116,7 +4780,7 @@ func (x *HistoryWorkdirSummary) String() string { func (*HistoryWorkdirSummary) ProtoMessage() {} func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[43] + mi := &file_proto_v1_gateway_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4129,7 +4793,7 @@ func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirSummary.ProtoReflect.Descriptor instead. func (*HistoryWorkdirSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} } func (x *HistoryWorkdirSummary) GetPath() string { @@ -4162,7 +4826,7 @@ type HistoryWorkdirsResponse struct { func (x *HistoryWorkdirsResponse) Reset() { *x = HistoryWorkdirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[44] + mi := &file_proto_v1_gateway_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4174,7 +4838,7 @@ func (x *HistoryWorkdirsResponse) String() string { func (*HistoryWorkdirsResponse) ProtoMessage() {} func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[44] + mi := &file_proto_v1_gateway_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4187,7 +4851,7 @@ func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsResponse.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} } func (x *HistoryWorkdirsResponse) GetWorkdirs() []*HistoryWorkdirSummary { @@ -4206,7 +4870,7 @@ type HistoryDeleteRequest struct { func (x *HistoryDeleteRequest) Reset() { *x = HistoryDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[45] + mi := &file_proto_v1_gateway_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4218,7 +4882,7 @@ func (x *HistoryDeleteRequest) String() string { func (*HistoryDeleteRequest) ProtoMessage() {} func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[45] + mi := &file_proto_v1_gateway_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4231,7 +4895,7 @@ func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteRequest.ProtoReflect.Descriptor instead. func (*HistoryDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{45} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} } func (x *HistoryDeleteRequest) GetConversationId() string { @@ -4249,7 +4913,7 @@ type HistoryDeleteResponse struct { func (x *HistoryDeleteResponse) Reset() { *x = HistoryDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[46] + mi := &file_proto_v1_gateway_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4261,7 +4925,7 @@ func (x *HistoryDeleteResponse) String() string { func (*HistoryDeleteResponse) ProtoMessage() {} func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[46] + mi := &file_proto_v1_gateway_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4274,7 +4938,7 @@ func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteResponse.ProtoReflect.Descriptor instead. func (*HistoryDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{46} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} } type HistoryTruncateRequest struct { @@ -4289,7 +4953,7 @@ type HistoryTruncateRequest struct { func (x *HistoryTruncateRequest) Reset() { *x = HistoryTruncateRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[47] + mi := &file_proto_v1_gateway_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4301,7 +4965,7 @@ func (x *HistoryTruncateRequest) String() string { func (*HistoryTruncateRequest) ProtoMessage() {} func (x *HistoryTruncateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[47] + mi := &file_proto_v1_gateway_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4314,7 +4978,7 @@ func (x *HistoryTruncateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryTruncateRequest.ProtoReflect.Descriptor instead. func (*HistoryTruncateRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{47} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} } func (x *HistoryTruncateRequest) GetConversationId() string { @@ -4356,7 +5020,7 @@ type HistoryTruncateResponse struct { func (x *HistoryTruncateResponse) Reset() { *x = HistoryTruncateResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4368,7 +5032,7 @@ func (x *HistoryTruncateResponse) String() string { func (*HistoryTruncateResponse) ProtoMessage() {} func (x *HistoryTruncateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4381,7 +5045,7 @@ func (x *HistoryTruncateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryTruncateResponse.ProtoReflect.Descriptor instead. func (*HistoryTruncateResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} } func (x *HistoryTruncateResponse) GetConversationId() string { @@ -4416,7 +5080,7 @@ type HistorySyncEvent struct { func (x *HistorySyncEvent) Reset() { *x = HistorySyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4428,7 +5092,7 @@ func (x *HistorySyncEvent) String() string { func (*HistorySyncEvent) ProtoMessage() {} func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4441,7 +5105,7 @@ func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use HistorySyncEvent.ProtoReflect.Descriptor instead. func (*HistorySyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} } func (x *HistorySyncEvent) GetKind() string { @@ -4473,7 +5137,7 @@ type ProviderListRequest struct { func (x *ProviderListRequest) Reset() { *x = ProviderListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4485,7 +5149,7 @@ func (x *ProviderListRequest) String() string { func (*ProviderListRequest) ProtoMessage() {} func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4498,7 +5162,7 @@ func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListRequest.ProtoReflect.Descriptor instead. func (*ProviderListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} } type ProviderListResponse struct { @@ -4510,7 +5174,7 @@ type ProviderListResponse struct { func (x *ProviderListResponse) Reset() { *x = ProviderListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4522,7 +5186,7 @@ func (x *ProviderListResponse) String() string { func (*ProviderListResponse) ProtoMessage() {} func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4535,7 +5199,7 @@ func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListResponse.ProtoReflect.Descriptor instead. func (*ProviderListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} } func (x *ProviderListResponse) GetProvidersJson() string { @@ -4553,7 +5217,7 @@ type SettingsGetRequest struct { func (x *SettingsGetRequest) Reset() { *x = SettingsGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4565,7 +5229,7 @@ func (x *SettingsGetRequest) String() string { func (*SettingsGetRequest) ProtoMessage() {} func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4578,7 +5242,7 @@ func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetRequest.ProtoReflect.Descriptor instead. func (*SettingsGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} } type SettingsGetResponse struct { @@ -4590,7 +5254,7 @@ type SettingsGetResponse struct { func (x *SettingsGetResponse) Reset() { *x = SettingsGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4602,7 +5266,7 @@ func (x *SettingsGetResponse) String() string { func (*SettingsGetResponse) ProtoMessage() {} func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4615,7 +5279,7 @@ func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetResponse.ProtoReflect.Descriptor instead. func (*SettingsGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} } func (x *SettingsGetResponse) GetSettingsJson() string { @@ -4634,7 +5298,7 @@ type SettingsUpdateRequest struct { func (x *SettingsUpdateRequest) Reset() { *x = SettingsUpdateRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4646,7 +5310,7 @@ func (x *SettingsUpdateRequest) String() string { func (*SettingsUpdateRequest) ProtoMessage() {} func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4659,7 +5323,7 @@ func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateRequest.ProtoReflect.Descriptor instead. func (*SettingsUpdateRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} } func (x *SettingsUpdateRequest) GetSettingsJson() string { @@ -4679,7 +5343,7 @@ type SettingsUpdateResponse struct { func (x *SettingsUpdateResponse) Reset() { *x = SettingsUpdateResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4691,7 +5355,7 @@ func (x *SettingsUpdateResponse) String() string { func (*SettingsUpdateResponse) ProtoMessage() {} func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4704,7 +5368,7 @@ func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateResponse.ProtoReflect.Descriptor instead. func (*SettingsUpdateResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} } func (x *SettingsUpdateResponse) GetAccepted() bool { @@ -4730,7 +5394,7 @@ type SettingsSyncEvent struct { func (x *SettingsSyncEvent) Reset() { *x = SettingsSyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4742,7 +5406,7 @@ func (x *SettingsSyncEvent) String() string { func (*SettingsSyncEvent) ProtoMessage() {} func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4755,7 +5419,7 @@ func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsSyncEvent.ProtoReflect.Descriptor instead. func (*SettingsSyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} } func (x *SettingsSyncEvent) GetSettingsJson() string { @@ -4773,7 +5437,7 @@ type SkillFilesListRequest struct { func (x *SkillFilesListRequest) Reset() { *x = SkillFilesListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4785,7 +5449,7 @@ func (x *SkillFilesListRequest) String() string { func (*SkillFilesListRequest) ProtoMessage() {} func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4798,7 +5462,7 @@ func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListRequest.ProtoReflect.Descriptor instead. func (*SkillFilesListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} } type SkillFilesListResponse struct { @@ -4812,7 +5476,7 @@ type SkillFilesListResponse struct { func (x *SkillFilesListResponse) Reset() { *x = SkillFilesListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4824,7 +5488,7 @@ func (x *SkillFilesListResponse) String() string { func (*SkillFilesListResponse) ProtoMessage() {} func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4837,7 +5501,7 @@ func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListResponse.ProtoReflect.Descriptor instead. func (*SkillFilesListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} } func (x *SkillFilesListResponse) GetRootDir() string { @@ -4870,7 +5534,7 @@ type SkillMetadataReadRequest struct { func (x *SkillMetadataReadRequest) Reset() { *x = SkillMetadataReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4882,7 +5546,7 @@ func (x *SkillMetadataReadRequest) String() string { func (*SkillMetadataReadRequest) ProtoMessage() {} func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4895,7 +5559,7 @@ func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadRequest.ProtoReflect.Descriptor instead. func (*SkillMetadataReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} } func (x *SkillMetadataReadRequest) GetPath() string { @@ -4915,7 +5579,7 @@ type SkillMetadataReadResponse struct { func (x *SkillMetadataReadResponse) Reset() { *x = SkillMetadataReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4927,7 +5591,7 @@ func (x *SkillMetadataReadResponse) String() string { func (*SkillMetadataReadResponse) ProtoMessage() {} func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4940,7 +5604,7 @@ func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadResponse.ProtoReflect.Descriptor instead. func (*SkillMetadataReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} } func (x *SkillMetadataReadResponse) GetName() string { @@ -4968,7 +5632,7 @@ type SkillTextReadRequest struct { func (x *SkillTextReadRequest) Reset() { *x = SkillTextReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4980,7 +5644,7 @@ func (x *SkillTextReadRequest) String() string { func (*SkillTextReadRequest) ProtoMessage() {} func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4993,7 +5657,7 @@ func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadRequest.ProtoReflect.Descriptor instead. func (*SkillTextReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} } func (x *SkillTextReadRequest) GetPath() string { @@ -5027,7 +5691,7 @@ type SkillTextReadResponse struct { func (x *SkillTextReadResponse) Reset() { *x = SkillTextReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5039,7 +5703,7 @@ func (x *SkillTextReadResponse) String() string { func (*SkillTextReadResponse) ProtoMessage() {} func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5052,7 +5716,7 @@ func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadResponse.ProtoReflect.Descriptor instead. func (*SkillTextReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} } func (x *SkillTextReadResponse) GetContent() string { @@ -5078,7 +5742,7 @@ type SkillManageRequest struct { func (x *SkillManageRequest) Reset() { *x = SkillManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5090,7 +5754,7 @@ func (x *SkillManageRequest) String() string { func (*SkillManageRequest) ProtoMessage() {} func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5103,7 +5767,7 @@ func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageRequest.ProtoReflect.Descriptor instead. func (*SkillManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} } func (x *SkillManageRequest) GetPayloadJson() string { @@ -5122,7 +5786,7 @@ type SkillManageResponse struct { func (x *SkillManageResponse) Reset() { *x = SkillManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5134,7 +5798,7 @@ func (x *SkillManageResponse) String() string { func (*SkillManageResponse) ProtoMessage() {} func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5147,7 +5811,7 @@ func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageResponse.ProtoReflect.Descriptor instead. func (*SkillManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} } func (x *SkillManageResponse) GetResultJson() string { @@ -5168,7 +5832,7 @@ type FileMentionListRequest struct { func (x *FileMentionListRequest) Reset() { *x = FileMentionListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5180,7 +5844,7 @@ func (x *FileMentionListRequest) String() string { func (*FileMentionListRequest) ProtoMessage() {} func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5193,7 +5857,7 @@ func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListRequest.ProtoReflect.Descriptor instead. func (*FileMentionListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} } func (x *FileMentionListRequest) GetWorkdir() string { @@ -5227,7 +5891,7 @@ type FileMentionEntry struct { func (x *FileMentionEntry) Reset() { *x = FileMentionEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5239,7 +5903,7 @@ func (x *FileMentionEntry) String() string { func (*FileMentionEntry) ProtoMessage() {} func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5252,7 +5916,7 @@ func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionEntry.ProtoReflect.Descriptor instead. func (*FileMentionEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} } func (x *FileMentionEntry) GetPath() string { @@ -5279,7 +5943,7 @@ type FileMentionListResponse struct { func (x *FileMentionListResponse) Reset() { *x = FileMentionListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5291,7 +5955,7 @@ func (x *FileMentionListResponse) String() string { func (*FileMentionListResponse) ProtoMessage() {} func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5304,7 +5968,7 @@ func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListResponse.ProtoReflect.Descriptor instead. func (*FileMentionListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} } func (x *FileMentionListResponse) GetEntries() []*FileMentionEntry { @@ -5333,7 +5997,7 @@ type FsRoot struct { func (x *FsRoot) Reset() { *x = FsRoot{} - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5345,7 +6009,7 @@ func (x *FsRoot) String() string { func (*FsRoot) ProtoMessage() {} func (x *FsRoot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5358,7 +6022,7 @@ func (x *FsRoot) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRoot.ProtoReflect.Descriptor instead. func (*FsRoot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} } func (x *FsRoot) GetId() string { @@ -5397,7 +6061,7 @@ type FsRootsRequest struct { func (x *FsRootsRequest) Reset() { *x = FsRootsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5409,7 +6073,7 @@ func (x *FsRootsRequest) String() string { func (*FsRootsRequest) ProtoMessage() {} func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5422,7 +6086,7 @@ func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsRequest.ProtoReflect.Descriptor instead. func (*FsRootsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} } type FsRootsResponse struct { @@ -5434,7 +6098,7 @@ type FsRootsResponse struct { func (x *FsRootsResponse) Reset() { *x = FsRootsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5446,7 +6110,7 @@ func (x *FsRootsResponse) String() string { func (*FsRootsResponse) ProtoMessage() {} func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5459,7 +6123,7 @@ func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsResponse.ProtoReflect.Descriptor instead. func (*FsRootsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} } func (x *FsRootsResponse) GetRoots() []*FsRoot { @@ -5479,7 +6143,7 @@ type FsListDirsRequest struct { func (x *FsListDirsRequest) Reset() { *x = FsListDirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5491,7 +6155,7 @@ func (x *FsListDirsRequest) String() string { func (*FsListDirsRequest) ProtoMessage() {} func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5504,7 +6168,7 @@ func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsRequest.ProtoReflect.Descriptor instead. func (*FsListDirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} } func (x *FsListDirsRequest) GetPath() string { @@ -5531,7 +6195,7 @@ type FsDirEntry struct { func (x *FsDirEntry) Reset() { *x = FsDirEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5543,7 +6207,7 @@ func (x *FsDirEntry) String() string { func (*FsDirEntry) ProtoMessage() {} func (x *FsDirEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5556,7 +6220,7 @@ func (x *FsDirEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDirEntry.ProtoReflect.Descriptor instead. func (*FsDirEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} } func (x *FsDirEntry) GetPath() string { @@ -5584,7 +6248,7 @@ type FsListDirsResponse struct { func (x *FsListDirsResponse) Reset() { *x = FsListDirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5596,7 +6260,7 @@ func (x *FsListDirsResponse) String() string { func (*FsListDirsResponse) ProtoMessage() {} func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5609,7 +6273,7 @@ func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsResponse.ProtoReflect.Descriptor instead. func (*FsListDirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} } func (x *FsListDirsResponse) GetPath() string { @@ -5643,7 +6307,7 @@ type FsCreateProjectFolderRequest struct { func (x *FsCreateProjectFolderRequest) Reset() { *x = FsCreateProjectFolderRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5655,7 +6319,7 @@ func (x *FsCreateProjectFolderRequest) String() string { func (*FsCreateProjectFolderRequest) ProtoMessage() {} func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5668,7 +6332,7 @@ func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderRequest.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} } func (x *FsCreateProjectFolderRequest) GetParent() string { @@ -5694,7 +6358,7 @@ type FsCreateProjectFolderResponse struct { func (x *FsCreateProjectFolderResponse) Reset() { *x = FsCreateProjectFolderResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5706,7 +6370,7 @@ func (x *FsCreateProjectFolderResponse) String() string { func (*FsCreateProjectFolderResponse) ProtoMessage() {} func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5719,7 +6383,7 @@ func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderResponse.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} } func (x *FsCreateProjectFolderResponse) GetPath() string { @@ -5742,7 +6406,7 @@ type FsListRequest struct { func (x *FsListRequest) Reset() { *x = FsListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5754,7 +6418,7 @@ func (x *FsListRequest) String() string { func (*FsListRequest) ProtoMessage() {} func (x *FsListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5767,7 +6431,7 @@ func (x *FsListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListRequest.ProtoReflect.Descriptor instead. func (*FsListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} } func (x *FsListRequest) GetWorkdir() string { @@ -5815,7 +6479,7 @@ type FsListEntry struct { func (x *FsListEntry) Reset() { *x = FsListEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5827,7 +6491,7 @@ func (x *FsListEntry) String() string { func (*FsListEntry) ProtoMessage() {} func (x *FsListEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5840,7 +6504,7 @@ func (x *FsListEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListEntry.ProtoReflect.Descriptor instead. func (*FsListEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} } func (x *FsListEntry) GetPath() string { @@ -5873,7 +6537,7 @@ type FsListResponse struct { func (x *FsListResponse) Reset() { *x = FsListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5885,7 +6549,7 @@ func (x *FsListResponse) String() string { func (*FsListResponse) ProtoMessage() {} func (x *FsListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5898,7 +6562,7 @@ func (x *FsListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListResponse.ProtoReflect.Descriptor instead. func (*FsListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} } func (x *FsListResponse) GetPath() string { @@ -5967,7 +6631,7 @@ type FsReadEditableTextRequest struct { func (x *FsReadEditableTextRequest) Reset() { *x = FsReadEditableTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5979,7 +6643,7 @@ func (x *FsReadEditableTextRequest) String() string { func (*FsReadEditableTextRequest) ProtoMessage() {} func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5992,7 +6656,7 @@ func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextRequest.ProtoReflect.Descriptor instead. func (*FsReadEditableTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} } func (x *FsReadEditableTextRequest) GetWorkdir() string { @@ -6023,7 +6687,7 @@ type FsReadEditableTextResponse struct { func (x *FsReadEditableTextResponse) Reset() { *x = FsReadEditableTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6035,7 +6699,7 @@ func (x *FsReadEditableTextResponse) String() string { func (*FsReadEditableTextResponse) ProtoMessage() {} func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6048,7 +6712,7 @@ func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextResponse.ProtoReflect.Descriptor instead. func (*FsReadEditableTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} } func (x *FsReadEditableTextResponse) GetPath() string { @@ -6103,7 +6767,7 @@ type FsReadWorkspaceImageRequest struct { func (x *FsReadWorkspaceImageRequest) Reset() { *x = FsReadWorkspaceImageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6115,7 +6779,7 @@ func (x *FsReadWorkspaceImageRequest) String() string { func (*FsReadWorkspaceImageRequest) ProtoMessage() {} func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6128,7 +6792,7 @@ func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageRequest.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} } func (x *FsReadWorkspaceImageRequest) GetWorkdir() string { @@ -6159,7 +6823,7 @@ type FsReadWorkspaceImageResponse struct { func (x *FsReadWorkspaceImageResponse) Reset() { *x = FsReadWorkspaceImageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6171,7 +6835,7 @@ func (x *FsReadWorkspaceImageResponse) String() string { func (*FsReadWorkspaceImageResponse) ProtoMessage() {} func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6184,7 +6848,7 @@ func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageResponse.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} } func (x *FsReadWorkspaceImageResponse) GetPath() string { @@ -6245,7 +6909,7 @@ type FsWriteTextRequest struct { func (x *FsWriteTextRequest) Reset() { *x = FsWriteTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6257,7 +6921,7 @@ func (x *FsWriteTextRequest) String() string { func (*FsWriteTextRequest) ProtoMessage() {} func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6270,7 +6934,7 @@ func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextRequest.ProtoReflect.Descriptor instead. func (*FsWriteTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} } func (x *FsWriteTextRequest) GetWorkdir() string { @@ -6344,7 +7008,7 @@ type FsWriteTextResponse struct { func (x *FsWriteTextResponse) Reset() { *x = FsWriteTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6356,7 +7020,7 @@ func (x *FsWriteTextResponse) String() string { func (*FsWriteTextResponse) ProtoMessage() {} func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6369,7 +7033,7 @@ func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextResponse.ProtoReflect.Descriptor instead. func (*FsWriteTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} } func (x *FsWriteTextResponse) GetPath() string { @@ -6431,7 +7095,7 @@ type FsCreateDirRequest struct { func (x *FsCreateDirRequest) Reset() { *x = FsCreateDirRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6443,7 +7107,7 @@ func (x *FsCreateDirRequest) String() string { func (*FsCreateDirRequest) ProtoMessage() {} func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6456,7 +7120,7 @@ func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirRequest.ProtoReflect.Descriptor instead. func (*FsCreateDirRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} } func (x *FsCreateDirRequest) GetWorkdir() string { @@ -6483,7 +7147,7 @@ type FsCreateDirResponse struct { func (x *FsCreateDirResponse) Reset() { *x = FsCreateDirResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6495,7 +7159,7 @@ func (x *FsCreateDirResponse) String() string { func (*FsCreateDirResponse) ProtoMessage() {} func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6508,7 +7172,7 @@ func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirResponse.ProtoReflect.Descriptor instead. func (*FsCreateDirResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} } func (x *FsCreateDirResponse) GetPath() string { @@ -6536,7 +7200,7 @@ type FsRenameRequest struct { func (x *FsRenameRequest) Reset() { *x = FsRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6548,7 +7212,7 @@ func (x *FsRenameRequest) String() string { func (*FsRenameRequest) ProtoMessage() {} func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6561,7 +7225,7 @@ func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameRequest.ProtoReflect.Descriptor instead. func (*FsRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} } func (x *FsRenameRequest) GetWorkdir() string { @@ -6596,7 +7260,7 @@ type FsRenameResponse struct { func (x *FsRenameResponse) Reset() { *x = FsRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6608,7 +7272,7 @@ func (x *FsRenameResponse) String() string { func (*FsRenameResponse) ProtoMessage() {} func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6621,7 +7285,7 @@ func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameResponse.ProtoReflect.Descriptor instead. func (*FsRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} } func (x *FsRenameResponse) GetFromPath() string { @@ -6655,7 +7319,7 @@ type FsDeleteRequest struct { func (x *FsDeleteRequest) Reset() { *x = FsDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6667,7 +7331,7 @@ func (x *FsDeleteRequest) String() string { func (*FsDeleteRequest) ProtoMessage() {} func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6680,7 +7344,7 @@ func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteRequest.ProtoReflect.Descriptor instead. func (*FsDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} } func (x *FsDeleteRequest) GetWorkdir() string { @@ -6707,7 +7371,7 @@ type FsDeleteResponse struct { func (x *FsDeleteResponse) Reset() { *x = FsDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6719,7 +7383,7 @@ func (x *FsDeleteResponse) String() string { func (*FsDeleteResponse) ProtoMessage() {} func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6732,7 +7396,7 @@ func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteResponse.ProtoReflect.Descriptor instead. func (*FsDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} } func (x *FsDeleteResponse) GetPath() string { @@ -6758,7 +7422,7 @@ type PingRequest struct { func (x *PingRequest) Reset() { *x = PingRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6770,7 +7434,7 @@ func (x *PingRequest) String() string { func (*PingRequest) ProtoMessage() {} func (x *PingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6783,7 +7447,7 @@ func (x *PingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. func (*PingRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} } func (x *PingRequest) GetTimestamp() int64 { @@ -6802,7 +7466,7 @@ type PongResponse struct { func (x *PongResponse) Reset() { *x = PongResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6814,7 +7478,7 @@ func (x *PongResponse) String() string { func (*PongResponse) ProtoMessage() {} func (x *PongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6827,7 +7491,7 @@ func (x *PongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PongResponse.ProtoReflect.Descriptor instead. func (*PongResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} } func (x *PongResponse) GetTimestamp() int64 { @@ -6847,7 +7511,7 @@ type ErrorResponse struct { func (x *ErrorResponse) Reset() { *x = ErrorResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6859,7 +7523,7 @@ func (x *ErrorResponse) String() string { func (*ErrorResponse) ProtoMessage() {} func (x *ErrorResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6872,7 +7536,7 @@ func (x *ErrorResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ErrorResponse.ProtoReflect.Descriptor instead. func (*ErrorResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} } func (x *ErrorResponse) GetCode() int32 { @@ -6902,7 +7566,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + "\n" + - "session_id\x18\x03 \x01(\tR\tsessionId\"\x89\x19\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\"\x85\x1b\n" + "\x0fGatewayEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -6950,8 +7614,11 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\vgit_request\x18= \x01(\v2 .liveagent.gateway.v1.GitRequestH\x00R\n" + "gitRequest\x12d\n" + "\x15fs_read_editable_text\x18> \x01(\v2/.liveagent.gateway.v1.FsReadEditableTextRequestH\x00R\x12fsReadEditableText\x12j\n" + - "\x17fs_read_workspace_image\x18? \x01(\v21.liveagent.gateway.v1.FsReadWorkspaceImageRequestH\x00R\x14fsReadWorkspaceImageB\t\n" + - "\apayload\"\xa4\x1d\n" + + "\x17fs_read_workspace_image\x18? \x01(\v21.liveagent.gateway.v1.FsReadWorkspaceImageRequestH\x00R\x14fsReadWorkspaceImage\x12S\n" + + "\x0etunnel_control\x18C \x01(\v2*.liveagent.gateway.v1.TunnelControlRequestH\x00R\rtunnelControl\x12]\n" + + "\x13tunnel_control_resp\x18D \x01(\v2+.liveagent.gateway.v1.TunnelControlResponseH\x00R\x11tunnelControlResp\x12F\n" + + "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrameB\t\n" + + "\apayload\"\xa0\x1f\n" + "\rAgentEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -6997,7 +7664,10 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x0efs_delete_resp\x18? \x01(\v2&.liveagent.gateway.v1.FsDeleteResponseH\x00R\ffsDeleteResp\x12F\n" + "\fgit_response\x18@ \x01(\v2!.liveagent.gateway.v1.GitResponseH\x00R\vgitResponse\x12n\n" + "\x1afs_read_editable_text_resp\x18A \x01(\v20.liveagent.gateway.v1.FsReadEditableTextResponseH\x00R\x16fsReadEditableTextResp\x12t\n" + - "\x1cfs_read_workspace_image_resp\x18B \x01(\v22.liveagent.gateway.v1.FsReadWorkspaceImageResponseH\x00R\x18fsReadWorkspaceImageResp\x12;\n" + + "\x1cfs_read_workspace_image_resp\x18B \x01(\v22.liveagent.gateway.v1.FsReadWorkspaceImageResponseH\x00R\x18fsReadWorkspaceImageResp\x12S\n" + + "\x0etunnel_control\x18C \x01(\v2*.liveagent.gateway.v1.TunnelControlRequestH\x00R\rtunnelControl\x12]\n" + + "\x13tunnel_control_resp\x18D \x01(\v2+.liveagent.gateway.v1.TunnelControlResponseH\x00R\x11tunnelControlResp\x12F\n" + + "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12;\n" + "\x05error\x18c \x01(\v2#.liveagent.gateway.v1.ErrorResponseH\x00R\x05errorB\t\n" + "\apayload\"|\n" + "\x11ChatSelectedModel\x12,\n" + @@ -7030,7 +7700,65 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\rabsolute_path\x18\x02 \x01(\tR\fabsolutePath\"O\n" + "\x1cUploadedImagePreviewResponse\x12\x1b\n" + "\tmime_type\x18\x01 \x01(\tR\bmimeType\x12\x12\n" + - "\x04data\x18\x02 \x01(\tR\x04data\"L\n" + + "\x04data\x18\x02 \x01(\tR\x04data\"\xc3\x02\n" + + "\x14TunnelControlRequest\x12\x16\n" + + "\x06action\x18\x01 \x01(\tR\x06action\x12\x1b\n" + + "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x12\n" + + "\x04slug\x18\x03 \x01(\tR\x04slug\x12\x1d\n" + + "\n" + + "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x12\n" + + "\x04name\x18\x05 \x01(\tR\x04name\x12\x1f\n" + + "\vttl_seconds\x18\x06 \x01(\rR\n" + + "ttlSeconds\x12\x1d\n" + + "\n" + + "expires_at\x18\a \x01(\x03R\texpiresAt\x12\x1d\n" + + "\n" + + "public_url\x18\b \x01(\tR\tpublicUrl\x12&\n" + + "\x0fpublic_base_url\x18\t \x01(\tR\rpublicBaseUrl\x12(\n" + + "\x10project_path_key\x18\n" + + " \x01(\tR\x0eprojectPathKey\"\xef\x01\n" + + "\x15TunnelControlResponse\x12\x16\n" + + "\x06action\x18\x01 \x01(\tR\x06action\x12=\n" + + "\atunnels\x18\x02 \x03(\v2#.liveagent.gateway.v1.TunnelSummaryR\atunnels\x12;\n" + + "\x06tunnel\x18\x03 \x01(\v2#.liveagent.gateway.v1.TunnelSummaryR\x06tunnel\x12\x1d\n" + + "\n" + + "error_code\x18\x04 \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\x05 \x01(\tR\ferrorMessage\"\xb4\x02\n" + + "\rTunnelSummary\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04slug\x18\x02 \x01(\tR\x04slug\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x1d\n" + + "\n" + + "public_url\x18\x05 \x01(\tR\tpublicUrl\x12\x1d\n" + + "\n" + + "created_at\x18\x06 \x01(\x03R\tcreatedAt\x12\x1d\n" + + "\n" + + "expires_at\x18\a \x01(\x03R\texpiresAt\x12-\n" + + "\x12active_connections\x18\b \x01(\rR\x11activeConnections\x12\x16\n" + + "\x06status\x18\t \x01(\tR\x06status\x12(\n" + + "\x10project_path_key\x18\n" + + " \x01(\tR\x0eprojectPathKey\"8\n" + + "\fTunnelHeader\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"\x92\x03\n" + + "\vTunnelFrame\x12\x1b\n" + + "\tstream_id\x18\x01 \x01(\tR\bstreamId\x12\x1b\n" + + "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x12\n" + + "\x04slug\x18\x03 \x01(\tR\x04slug\x129\n" + + "\x04kind\x18\x04 \x01(\x0e2%.liveagent.gateway.v1.TunnelFrameKindR\x04kind\x12\x16\n" + + "\x06method\x18\x05 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x06 \x01(\tR\x04path\x12<\n" + + "\aheaders\x18\a \x03(\v2\".liveagent.gateway.v1.TunnelHeaderR\aheaders\x12\x1f\n" + + "\vstatus_code\x18\b \x01(\rR\n" + + "statusCode\x12\x12\n" + + "\x04body\x18\t \x01(\fR\x04body\x12\x1d\n" + + "\n" + + "end_stream\x18\n" + + " \x01(\bR\tendStream\x12\x14\n" + + "\x05error\x18\v \x01(\tR\x05error\x12&\n" + + "\x0fws_message_type\x18\f \x01(\tR\rwsMessageType\"L\n" + "\x13MemoryManageRequest\x12\x18\n" + "\acommand\x18\x01 \x01(\tR\acommand\x12\x1b\n" + "\targs_json\x18\x02 \x01(\tR\bargsJson\"7\n" + @@ -7390,7 +8118,21 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\ttimestamp\x18\x01 \x01(\x03R\ttimestamp\"=\n" + "\rErrorResponse\x12\x12\n" + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage2\xc5\x01\n" + + "\amessage\x18\x02 \x01(\tR\amessage*\xc7\x03\n" + + "\x0fTunnelFrameKind\x12!\n" + + "\x1dTUNNEL_FRAME_KIND_UNSPECIFIED\x10\x00\x12(\n" + + "$TUNNEL_FRAME_KIND_HTTP_REQUEST_START\x10\x01\x12'\n" + + "#TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY\x10\x02\x12&\n" + + "\"TUNNEL_FRAME_KIND_HTTP_REQUEST_END\x10\x03\x12)\n" + + "%TUNNEL_FRAME_KIND_HTTP_RESPONSE_START\x10\x04\x12(\n" + + "$TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY\x10\x05\x12'\n" + + "#TUNNEL_FRAME_KIND_HTTP_RESPONSE_END\x10\x06\x12\x1d\n" + + "\x19TUNNEL_FRAME_KIND_WS_OPEN\x10\a\x12\x1e\n" + + "\x1aTUNNEL_FRAME_KIND_WS_FRAME\x10\b\x12\x1e\n" + + "\x1aTUNNEL_FRAME_KIND_WS_CLOSE\x10\t\x12\x1b\n" + + "\x17TUNNEL_FRAME_KIND_ERROR\x10\n" + + "\x12\x1c\n" + + "\x18TUNNEL_FRAME_KIND_CANCEL\x10\v2\xc5\x01\n" + "\fAgentGateway\x12^\n" + "\fAgentConnect\x12#.liveagent.gateway.v1.AgentEnvelope\x1a%.liveagent.gateway.v1.GatewayEnvelope(\x010\x01\x12U\n" + "\fAuthenticate\x12!.liveagent.gateway.v1.AuthRequest\x1a\".liveagent.gateway.v1.AuthResponseB@Z>github.com/liveagent/agent-gateway/internal/proto/v1;gatewayv1b\x06proto3" @@ -7407,216 +8149,232 @@ func file_proto_v1_gateway_proto_rawDescGZIP() []byte { return file_proto_v1_gateway_proto_rawDescData } -var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 94) +var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 99) var file_proto_v1_gateway_proto_goTypes = []any{ - (ChatEvent_ChatEventType)(0), // 0: liveagent.gateway.v1.ChatEvent.ChatEventType - (*AuthRequest)(nil), // 1: liveagent.gateway.v1.AuthRequest - (*AuthResponse)(nil), // 2: liveagent.gateway.v1.AuthResponse - (*GatewayEnvelope)(nil), // 3: liveagent.gateway.v1.GatewayEnvelope - (*AgentEnvelope)(nil), // 4: liveagent.gateway.v1.AgentEnvelope - (*ChatSelectedModel)(nil), // 5: liveagent.gateway.v1.ChatSelectedModel - (*ChatRuntimeControls)(nil), // 6: liveagent.gateway.v1.ChatRuntimeControls - (*ChatUploadedFile)(nil), // 7: liveagent.gateway.v1.ChatUploadedFile - (*UploadReadableFile)(nil), // 8: liveagent.gateway.v1.UploadReadableFile - (*UploadReadableFilesRequest)(nil), // 9: liveagent.gateway.v1.UploadReadableFilesRequest - (*UploadReadableFilesResponse)(nil), // 10: liveagent.gateway.v1.UploadReadableFilesResponse - (*UploadedImagePreviewRequest)(nil), // 11: liveagent.gateway.v1.UploadedImagePreviewRequest - (*UploadedImagePreviewResponse)(nil), // 12: liveagent.gateway.v1.UploadedImagePreviewResponse - (*MemoryManageRequest)(nil), // 13: liveagent.gateway.v1.MemoryManageRequest - (*MemoryManageResponse)(nil), // 14: liveagent.gateway.v1.MemoryManageResponse - (*TerminalRequest)(nil), // 15: liveagent.gateway.v1.TerminalRequest - (*TerminalSession)(nil), // 16: liveagent.gateway.v1.TerminalSession - (*TerminalShellOption)(nil), // 17: liveagent.gateway.v1.TerminalShellOption - (*TerminalResponse)(nil), // 18: liveagent.gateway.v1.TerminalResponse - (*TerminalEvent)(nil), // 19: liveagent.gateway.v1.TerminalEvent - (*GitRequest)(nil), // 20: liveagent.gateway.v1.GitRequest - (*GitResponse)(nil), // 21: liveagent.gateway.v1.GitResponse - (*ChatRequest)(nil), // 22: liveagent.gateway.v1.ChatRequest - (*CancelChatRequest)(nil), // 23: liveagent.gateway.v1.CancelChatRequest - (*ChatEvent)(nil), // 24: liveagent.gateway.v1.ChatEvent - (*CronManageRequest)(nil), // 25: liveagent.gateway.v1.CronManageRequest - (*CronManageResponse)(nil), // 26: liveagent.gateway.v1.CronManageResponse - (*HistoryListRequest)(nil), // 27: liveagent.gateway.v1.HistoryListRequest - (*HistoryListResponse)(nil), // 28: liveagent.gateway.v1.HistoryListResponse - (*ConversationSummary)(nil), // 29: liveagent.gateway.v1.ConversationSummary - (*HistoryGetRequest)(nil), // 30: liveagent.gateway.v1.HistoryGetRequest - (*HistoryGetResponse)(nil), // 31: liveagent.gateway.v1.HistoryGetResponse - (*HistoryRenameRequest)(nil), // 32: liveagent.gateway.v1.HistoryRenameRequest - (*HistoryRenameResponse)(nil), // 33: liveagent.gateway.v1.HistoryRenameResponse - (*HistoryPinRequest)(nil), // 34: liveagent.gateway.v1.HistoryPinRequest - (*HistoryPinResponse)(nil), // 35: liveagent.gateway.v1.HistoryPinResponse - (*HistoryShareStatus)(nil), // 36: liveagent.gateway.v1.HistoryShareStatus - (*HistoryShareGetRequest)(nil), // 37: liveagent.gateway.v1.HistoryShareGetRequest - (*HistoryShareGetResponse)(nil), // 38: liveagent.gateway.v1.HistoryShareGetResponse - (*HistoryShareSetRequest)(nil), // 39: liveagent.gateway.v1.HistoryShareSetRequest - (*HistoryShareSetResponse)(nil), // 40: liveagent.gateway.v1.HistoryShareSetResponse - (*HistoryShareResolveRequest)(nil), // 41: liveagent.gateway.v1.HistoryShareResolveRequest - (*HistoryShareResolveResponse)(nil), // 42: liveagent.gateway.v1.HistoryShareResolveResponse - (*HistoryWorkdirsRequest)(nil), // 43: liveagent.gateway.v1.HistoryWorkdirsRequest - (*HistoryWorkdirSummary)(nil), // 44: liveagent.gateway.v1.HistoryWorkdirSummary - (*HistoryWorkdirsResponse)(nil), // 45: liveagent.gateway.v1.HistoryWorkdirsResponse - (*HistoryDeleteRequest)(nil), // 46: liveagent.gateway.v1.HistoryDeleteRequest - (*HistoryDeleteResponse)(nil), // 47: liveagent.gateway.v1.HistoryDeleteResponse - (*HistoryTruncateRequest)(nil), // 48: liveagent.gateway.v1.HistoryTruncateRequest - (*HistoryTruncateResponse)(nil), // 49: liveagent.gateway.v1.HistoryTruncateResponse - (*HistorySyncEvent)(nil), // 50: liveagent.gateway.v1.HistorySyncEvent - (*ProviderListRequest)(nil), // 51: liveagent.gateway.v1.ProviderListRequest - (*ProviderListResponse)(nil), // 52: liveagent.gateway.v1.ProviderListResponse - (*SettingsGetRequest)(nil), // 53: liveagent.gateway.v1.SettingsGetRequest - (*SettingsGetResponse)(nil), // 54: liveagent.gateway.v1.SettingsGetResponse - (*SettingsUpdateRequest)(nil), // 55: liveagent.gateway.v1.SettingsUpdateRequest - (*SettingsUpdateResponse)(nil), // 56: liveagent.gateway.v1.SettingsUpdateResponse - (*SettingsSyncEvent)(nil), // 57: liveagent.gateway.v1.SettingsSyncEvent - (*SkillFilesListRequest)(nil), // 58: liveagent.gateway.v1.SkillFilesListRequest - (*SkillFilesListResponse)(nil), // 59: liveagent.gateway.v1.SkillFilesListResponse - (*SkillMetadataReadRequest)(nil), // 60: liveagent.gateway.v1.SkillMetadataReadRequest - (*SkillMetadataReadResponse)(nil), // 61: liveagent.gateway.v1.SkillMetadataReadResponse - (*SkillTextReadRequest)(nil), // 62: liveagent.gateway.v1.SkillTextReadRequest - (*SkillTextReadResponse)(nil), // 63: liveagent.gateway.v1.SkillTextReadResponse - (*SkillManageRequest)(nil), // 64: liveagent.gateway.v1.SkillManageRequest - (*SkillManageResponse)(nil), // 65: liveagent.gateway.v1.SkillManageResponse - (*FileMentionListRequest)(nil), // 66: liveagent.gateway.v1.FileMentionListRequest - (*FileMentionEntry)(nil), // 67: liveagent.gateway.v1.FileMentionEntry - (*FileMentionListResponse)(nil), // 68: liveagent.gateway.v1.FileMentionListResponse - (*FsRoot)(nil), // 69: liveagent.gateway.v1.FsRoot - (*FsRootsRequest)(nil), // 70: liveagent.gateway.v1.FsRootsRequest - (*FsRootsResponse)(nil), // 71: liveagent.gateway.v1.FsRootsResponse - (*FsListDirsRequest)(nil), // 72: liveagent.gateway.v1.FsListDirsRequest - (*FsDirEntry)(nil), // 73: liveagent.gateway.v1.FsDirEntry - (*FsListDirsResponse)(nil), // 74: liveagent.gateway.v1.FsListDirsResponse - (*FsCreateProjectFolderRequest)(nil), // 75: liveagent.gateway.v1.FsCreateProjectFolderRequest - (*FsCreateProjectFolderResponse)(nil), // 76: liveagent.gateway.v1.FsCreateProjectFolderResponse - (*FsListRequest)(nil), // 77: liveagent.gateway.v1.FsListRequest - (*FsListEntry)(nil), // 78: liveagent.gateway.v1.FsListEntry - (*FsListResponse)(nil), // 79: liveagent.gateway.v1.FsListResponse - (*FsReadEditableTextRequest)(nil), // 80: liveagent.gateway.v1.FsReadEditableTextRequest - (*FsReadEditableTextResponse)(nil), // 81: liveagent.gateway.v1.FsReadEditableTextResponse - (*FsReadWorkspaceImageRequest)(nil), // 82: liveagent.gateway.v1.FsReadWorkspaceImageRequest - (*FsReadWorkspaceImageResponse)(nil), // 83: liveagent.gateway.v1.FsReadWorkspaceImageResponse - (*FsWriteTextRequest)(nil), // 84: liveagent.gateway.v1.FsWriteTextRequest - (*FsWriteTextResponse)(nil), // 85: liveagent.gateway.v1.FsWriteTextResponse - (*FsCreateDirRequest)(nil), // 86: liveagent.gateway.v1.FsCreateDirRequest - (*FsCreateDirResponse)(nil), // 87: liveagent.gateway.v1.FsCreateDirResponse - (*FsRenameRequest)(nil), // 88: liveagent.gateway.v1.FsRenameRequest - (*FsRenameResponse)(nil), // 89: liveagent.gateway.v1.FsRenameResponse - (*FsDeleteRequest)(nil), // 90: liveagent.gateway.v1.FsDeleteRequest - (*FsDeleteResponse)(nil), // 91: liveagent.gateway.v1.FsDeleteResponse - (*PingRequest)(nil), // 92: liveagent.gateway.v1.PingRequest - (*PongResponse)(nil), // 93: liveagent.gateway.v1.PongResponse - (*ErrorResponse)(nil), // 94: liveagent.gateway.v1.ErrorResponse + (TunnelFrameKind)(0), // 0: liveagent.gateway.v1.TunnelFrameKind + (ChatEvent_ChatEventType)(0), // 1: liveagent.gateway.v1.ChatEvent.ChatEventType + (*AuthRequest)(nil), // 2: liveagent.gateway.v1.AuthRequest + (*AuthResponse)(nil), // 3: liveagent.gateway.v1.AuthResponse + (*GatewayEnvelope)(nil), // 4: liveagent.gateway.v1.GatewayEnvelope + (*AgentEnvelope)(nil), // 5: liveagent.gateway.v1.AgentEnvelope + (*ChatSelectedModel)(nil), // 6: liveagent.gateway.v1.ChatSelectedModel + (*ChatRuntimeControls)(nil), // 7: liveagent.gateway.v1.ChatRuntimeControls + (*ChatUploadedFile)(nil), // 8: liveagent.gateway.v1.ChatUploadedFile + (*UploadReadableFile)(nil), // 9: liveagent.gateway.v1.UploadReadableFile + (*UploadReadableFilesRequest)(nil), // 10: liveagent.gateway.v1.UploadReadableFilesRequest + (*UploadReadableFilesResponse)(nil), // 11: liveagent.gateway.v1.UploadReadableFilesResponse + (*UploadedImagePreviewRequest)(nil), // 12: liveagent.gateway.v1.UploadedImagePreviewRequest + (*UploadedImagePreviewResponse)(nil), // 13: liveagent.gateway.v1.UploadedImagePreviewResponse + (*TunnelControlRequest)(nil), // 14: liveagent.gateway.v1.TunnelControlRequest + (*TunnelControlResponse)(nil), // 15: liveagent.gateway.v1.TunnelControlResponse + (*TunnelSummary)(nil), // 16: liveagent.gateway.v1.TunnelSummary + (*TunnelHeader)(nil), // 17: liveagent.gateway.v1.TunnelHeader + (*TunnelFrame)(nil), // 18: liveagent.gateway.v1.TunnelFrame + (*MemoryManageRequest)(nil), // 19: liveagent.gateway.v1.MemoryManageRequest + (*MemoryManageResponse)(nil), // 20: liveagent.gateway.v1.MemoryManageResponse + (*TerminalRequest)(nil), // 21: liveagent.gateway.v1.TerminalRequest + (*TerminalSession)(nil), // 22: liveagent.gateway.v1.TerminalSession + (*TerminalShellOption)(nil), // 23: liveagent.gateway.v1.TerminalShellOption + (*TerminalResponse)(nil), // 24: liveagent.gateway.v1.TerminalResponse + (*TerminalEvent)(nil), // 25: liveagent.gateway.v1.TerminalEvent + (*GitRequest)(nil), // 26: liveagent.gateway.v1.GitRequest + (*GitResponse)(nil), // 27: liveagent.gateway.v1.GitResponse + (*ChatRequest)(nil), // 28: liveagent.gateway.v1.ChatRequest + (*CancelChatRequest)(nil), // 29: liveagent.gateway.v1.CancelChatRequest + (*ChatEvent)(nil), // 30: liveagent.gateway.v1.ChatEvent + (*CronManageRequest)(nil), // 31: liveagent.gateway.v1.CronManageRequest + (*CronManageResponse)(nil), // 32: liveagent.gateway.v1.CronManageResponse + (*HistoryListRequest)(nil), // 33: liveagent.gateway.v1.HistoryListRequest + (*HistoryListResponse)(nil), // 34: liveagent.gateway.v1.HistoryListResponse + (*ConversationSummary)(nil), // 35: liveagent.gateway.v1.ConversationSummary + (*HistoryGetRequest)(nil), // 36: liveagent.gateway.v1.HistoryGetRequest + (*HistoryGetResponse)(nil), // 37: liveagent.gateway.v1.HistoryGetResponse + (*HistoryRenameRequest)(nil), // 38: liveagent.gateway.v1.HistoryRenameRequest + (*HistoryRenameResponse)(nil), // 39: liveagent.gateway.v1.HistoryRenameResponse + (*HistoryPinRequest)(nil), // 40: liveagent.gateway.v1.HistoryPinRequest + (*HistoryPinResponse)(nil), // 41: liveagent.gateway.v1.HistoryPinResponse + (*HistoryShareStatus)(nil), // 42: liveagent.gateway.v1.HistoryShareStatus + (*HistoryShareGetRequest)(nil), // 43: liveagent.gateway.v1.HistoryShareGetRequest + (*HistoryShareGetResponse)(nil), // 44: liveagent.gateway.v1.HistoryShareGetResponse + (*HistoryShareSetRequest)(nil), // 45: liveagent.gateway.v1.HistoryShareSetRequest + (*HistoryShareSetResponse)(nil), // 46: liveagent.gateway.v1.HistoryShareSetResponse + (*HistoryShareResolveRequest)(nil), // 47: liveagent.gateway.v1.HistoryShareResolveRequest + (*HistoryShareResolveResponse)(nil), // 48: liveagent.gateway.v1.HistoryShareResolveResponse + (*HistoryWorkdirsRequest)(nil), // 49: liveagent.gateway.v1.HistoryWorkdirsRequest + (*HistoryWorkdirSummary)(nil), // 50: liveagent.gateway.v1.HistoryWorkdirSummary + (*HistoryWorkdirsResponse)(nil), // 51: liveagent.gateway.v1.HistoryWorkdirsResponse + (*HistoryDeleteRequest)(nil), // 52: liveagent.gateway.v1.HistoryDeleteRequest + (*HistoryDeleteResponse)(nil), // 53: liveagent.gateway.v1.HistoryDeleteResponse + (*HistoryTruncateRequest)(nil), // 54: liveagent.gateway.v1.HistoryTruncateRequest + (*HistoryTruncateResponse)(nil), // 55: liveagent.gateway.v1.HistoryTruncateResponse + (*HistorySyncEvent)(nil), // 56: liveagent.gateway.v1.HistorySyncEvent + (*ProviderListRequest)(nil), // 57: liveagent.gateway.v1.ProviderListRequest + (*ProviderListResponse)(nil), // 58: liveagent.gateway.v1.ProviderListResponse + (*SettingsGetRequest)(nil), // 59: liveagent.gateway.v1.SettingsGetRequest + (*SettingsGetResponse)(nil), // 60: liveagent.gateway.v1.SettingsGetResponse + (*SettingsUpdateRequest)(nil), // 61: liveagent.gateway.v1.SettingsUpdateRequest + (*SettingsUpdateResponse)(nil), // 62: liveagent.gateway.v1.SettingsUpdateResponse + (*SettingsSyncEvent)(nil), // 63: liveagent.gateway.v1.SettingsSyncEvent + (*SkillFilesListRequest)(nil), // 64: liveagent.gateway.v1.SkillFilesListRequest + (*SkillFilesListResponse)(nil), // 65: liveagent.gateway.v1.SkillFilesListResponse + (*SkillMetadataReadRequest)(nil), // 66: liveagent.gateway.v1.SkillMetadataReadRequest + (*SkillMetadataReadResponse)(nil), // 67: liveagent.gateway.v1.SkillMetadataReadResponse + (*SkillTextReadRequest)(nil), // 68: liveagent.gateway.v1.SkillTextReadRequest + (*SkillTextReadResponse)(nil), // 69: liveagent.gateway.v1.SkillTextReadResponse + (*SkillManageRequest)(nil), // 70: liveagent.gateway.v1.SkillManageRequest + (*SkillManageResponse)(nil), // 71: liveagent.gateway.v1.SkillManageResponse + (*FileMentionListRequest)(nil), // 72: liveagent.gateway.v1.FileMentionListRequest + (*FileMentionEntry)(nil), // 73: liveagent.gateway.v1.FileMentionEntry + (*FileMentionListResponse)(nil), // 74: liveagent.gateway.v1.FileMentionListResponse + (*FsRoot)(nil), // 75: liveagent.gateway.v1.FsRoot + (*FsRootsRequest)(nil), // 76: liveagent.gateway.v1.FsRootsRequest + (*FsRootsResponse)(nil), // 77: liveagent.gateway.v1.FsRootsResponse + (*FsListDirsRequest)(nil), // 78: liveagent.gateway.v1.FsListDirsRequest + (*FsDirEntry)(nil), // 79: liveagent.gateway.v1.FsDirEntry + (*FsListDirsResponse)(nil), // 80: liveagent.gateway.v1.FsListDirsResponse + (*FsCreateProjectFolderRequest)(nil), // 81: liveagent.gateway.v1.FsCreateProjectFolderRequest + (*FsCreateProjectFolderResponse)(nil), // 82: liveagent.gateway.v1.FsCreateProjectFolderResponse + (*FsListRequest)(nil), // 83: liveagent.gateway.v1.FsListRequest + (*FsListEntry)(nil), // 84: liveagent.gateway.v1.FsListEntry + (*FsListResponse)(nil), // 85: liveagent.gateway.v1.FsListResponse + (*FsReadEditableTextRequest)(nil), // 86: liveagent.gateway.v1.FsReadEditableTextRequest + (*FsReadEditableTextResponse)(nil), // 87: liveagent.gateway.v1.FsReadEditableTextResponse + (*FsReadWorkspaceImageRequest)(nil), // 88: liveagent.gateway.v1.FsReadWorkspaceImageRequest + (*FsReadWorkspaceImageResponse)(nil), // 89: liveagent.gateway.v1.FsReadWorkspaceImageResponse + (*FsWriteTextRequest)(nil), // 90: liveagent.gateway.v1.FsWriteTextRequest + (*FsWriteTextResponse)(nil), // 91: liveagent.gateway.v1.FsWriteTextResponse + (*FsCreateDirRequest)(nil), // 92: liveagent.gateway.v1.FsCreateDirRequest + (*FsCreateDirResponse)(nil), // 93: liveagent.gateway.v1.FsCreateDirResponse + (*FsRenameRequest)(nil), // 94: liveagent.gateway.v1.FsRenameRequest + (*FsRenameResponse)(nil), // 95: liveagent.gateway.v1.FsRenameResponse + (*FsDeleteRequest)(nil), // 96: liveagent.gateway.v1.FsDeleteRequest + (*FsDeleteResponse)(nil), // 97: liveagent.gateway.v1.FsDeleteResponse + (*PingRequest)(nil), // 98: liveagent.gateway.v1.PingRequest + (*PongResponse)(nil), // 99: liveagent.gateway.v1.PongResponse + (*ErrorResponse)(nil), // 100: liveagent.gateway.v1.ErrorResponse } var file_proto_v1_gateway_proto_depIdxs = []int32{ - 22, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_request:type_name -> liveagent.gateway.v1.ChatRequest - 23, // 1: liveagent.gateway.v1.GatewayEnvelope.cancel_chat:type_name -> liveagent.gateway.v1.CancelChatRequest - 25, // 2: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest - 27, // 3: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest - 30, // 4: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest - 32, // 5: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest - 46, // 6: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest - 48, // 7: liveagent.gateway.v1.GatewayEnvelope.history_truncate:type_name -> liveagent.gateway.v1.HistoryTruncateRequest - 34, // 8: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest - 37, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest - 39, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest - 41, // 11: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest - 43, // 12: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest - 51, // 13: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest - 53, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest - 55, // 15: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest - 58, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest - 60, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest - 62, // 18: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest - 66, // 19: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest - 9, // 20: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest - 70, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest - 72, // 22: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest - 92, // 23: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest - 11, // 24: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest - 13, // 25: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest - 64, // 26: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest - 75, // 27: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest - 15, // 28: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest - 77, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest - 84, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest - 86, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest - 88, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest - 90, // 33: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest - 20, // 34: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest - 80, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest - 82, // 36: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest - 24, // 37: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent - 26, // 38: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse - 28, // 39: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse - 31, // 40: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse - 33, // 41: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse - 47, // 42: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse - 50, // 43: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent - 49, // 44: liveagent.gateway.v1.AgentEnvelope.history_truncate_resp:type_name -> liveagent.gateway.v1.HistoryTruncateResponse - 35, // 45: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse - 38, // 46: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse - 40, // 47: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse - 42, // 48: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse - 45, // 49: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse - 52, // 50: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse - 54, // 51: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse - 56, // 52: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse - 57, // 53: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent - 59, // 54: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse - 61, // 55: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse - 63, // 56: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse - 68, // 57: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse - 10, // 58: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse - 71, // 59: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse - 93, // 60: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse - 74, // 61: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse - 12, // 62: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse - 14, // 63: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse - 65, // 64: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse - 76, // 65: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse - 18, // 66: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse - 19, // 67: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent - 79, // 68: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse - 85, // 69: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse - 87, // 70: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse - 89, // 71: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse - 91, // 72: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse - 21, // 73: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse - 81, // 74: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse - 83, // 75: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse - 94, // 76: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse - 8, // 77: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile - 7, // 78: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 16, // 79: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession - 16, // 80: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession - 17, // 81: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption - 16, // 82: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession - 5, // 83: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel - 7, // 84: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 6, // 85: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls - 0, // 86: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType - 29, // 87: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 88: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 89: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 90: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 36, // 91: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 36, // 92: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 29, // 93: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 44, // 94: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary - 29, // 95: liveagent.gateway.v1.HistoryTruncateResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 96: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 67, // 97: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry - 69, // 98: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot - 73, // 99: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry - 78, // 100: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry - 4, // 101: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope - 1, // 102: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest - 3, // 103: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope - 2, // 104: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse - 103, // [103:105] is the sub-list for method output_type - 101, // [101:103] is the sub-list for method input_type - 101, // [101:101] is the sub-list for extension type_name - 101, // [101:101] is the sub-list for extension extendee - 0, // [0:101] is the sub-list for field type_name + 28, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_request:type_name -> liveagent.gateway.v1.ChatRequest + 29, // 1: liveagent.gateway.v1.GatewayEnvelope.cancel_chat:type_name -> liveagent.gateway.v1.CancelChatRequest + 31, // 2: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest + 33, // 3: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest + 36, // 4: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest + 38, // 5: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest + 52, // 6: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest + 54, // 7: liveagent.gateway.v1.GatewayEnvelope.history_truncate:type_name -> liveagent.gateway.v1.HistoryTruncateRequest + 40, // 8: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest + 43, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest + 45, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest + 47, // 11: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest + 49, // 12: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest + 57, // 13: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest + 59, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest + 61, // 15: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest + 64, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest + 66, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest + 68, // 18: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest + 72, // 19: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest + 10, // 20: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest + 76, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest + 78, // 22: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest + 98, // 23: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest + 12, // 24: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest + 19, // 25: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest + 70, // 26: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest + 81, // 27: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest + 21, // 28: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest + 83, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest + 90, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest + 92, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest + 94, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest + 96, // 33: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest + 26, // 34: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest + 86, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest + 88, // 36: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest + 14, // 37: liveagent.gateway.v1.GatewayEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest + 15, // 38: liveagent.gateway.v1.GatewayEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse + 18, // 39: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 30, // 40: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent + 32, // 41: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse + 34, // 42: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse + 37, // 43: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse + 39, // 44: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse + 53, // 45: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse + 56, // 46: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent + 55, // 47: liveagent.gateway.v1.AgentEnvelope.history_truncate_resp:type_name -> liveagent.gateway.v1.HistoryTruncateResponse + 41, // 48: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse + 44, // 49: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse + 46, // 50: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse + 48, // 51: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse + 51, // 52: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse + 58, // 53: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse + 60, // 54: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse + 62, // 55: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse + 63, // 56: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent + 65, // 57: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse + 67, // 58: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse + 69, // 59: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse + 74, // 60: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse + 11, // 61: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse + 77, // 62: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse + 99, // 63: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse + 80, // 64: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse + 13, // 65: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse + 20, // 66: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse + 71, // 67: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse + 82, // 68: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse + 24, // 69: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse + 25, // 70: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent + 85, // 71: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse + 91, // 72: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse + 93, // 73: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse + 95, // 74: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse + 97, // 75: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse + 27, // 76: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse + 87, // 77: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse + 89, // 78: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse + 14, // 79: liveagent.gateway.v1.AgentEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest + 15, // 80: liveagent.gateway.v1.AgentEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse + 18, // 81: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 100, // 82: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse + 9, // 83: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile + 8, // 84: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 16, // 85: liveagent.gateway.v1.TunnelControlResponse.tunnels:type_name -> liveagent.gateway.v1.TunnelSummary + 16, // 86: liveagent.gateway.v1.TunnelControlResponse.tunnel:type_name -> liveagent.gateway.v1.TunnelSummary + 0, // 87: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind + 17, // 88: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader + 22, // 89: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession + 22, // 90: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession + 23, // 91: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption + 22, // 92: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession + 6, // 93: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel + 8, // 94: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 7, // 95: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls + 1, // 96: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType + 35, // 97: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary + 35, // 98: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 35, // 99: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 35, // 100: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 42, // 101: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 42, // 102: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 35, // 103: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 50, // 104: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary + 35, // 105: liveagent.gateway.v1.HistoryTruncateResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 35, // 106: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 73, // 107: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry + 75, // 108: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot + 79, // 109: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry + 84, // 110: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry + 5, // 111: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope + 2, // 112: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest + 4, // 113: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope + 3, // 114: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse + 113, // [113:115] is the sub-list for method output_type + 111, // [111:113] is the sub-list for method input_type + 111, // [111:111] is the sub-list for extension type_name + 111, // [111:111] is the sub-list for extension extendee + 0, // [0:111] is the sub-list for field type_name } func init() { file_proto_v1_gateway_proto_init() } @@ -7662,6 +8420,9 @@ func file_proto_v1_gateway_proto_init() { (*GatewayEnvelope_GitRequest)(nil), (*GatewayEnvelope_FsReadEditableText)(nil), (*GatewayEnvelope_FsReadWorkspaceImage)(nil), + (*GatewayEnvelope_TunnelControl)(nil), + (*GatewayEnvelope_TunnelControlResp)(nil), + (*GatewayEnvelope_TunnelFrame)(nil), } file_proto_v1_gateway_proto_msgTypes[3].OneofWrappers = []any{ (*AgentEnvelope_ChatEvent)(nil), @@ -7703,16 +8464,19 @@ func file_proto_v1_gateway_proto_init() { (*AgentEnvelope_GitResponse)(nil), (*AgentEnvelope_FsReadEditableTextResp)(nil), (*AgentEnvelope_FsReadWorkspaceImageResp)(nil), + (*AgentEnvelope_TunnelControl)(nil), + (*AgentEnvelope_TunnelControlResp)(nil), + (*AgentEnvelope_TunnelFrame)(nil), (*AgentEnvelope_Error)(nil), } - file_proto_v1_gateway_proto_msgTypes[38].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[43].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_v1_gateway_proto_rawDesc), len(file_proto_v1_gateway_proto_rawDesc)), - NumEnums: 1, - NumMessages: 94, + NumEnums: 2, + NumMessages: 99, NumExtensions: 0, NumServices: 1, }, diff --git a/crates/agent-gateway/internal/server/grpc.go b/crates/agent-gateway/internal/server/grpc.go index a51f0a494..9234b0970 100644 --- a/crates/agent-gateway/internal/server/grpc.go +++ b/crates/agent-gateway/internal/server/grpc.go @@ -59,6 +59,13 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ defer cancel() go s.heartbeatLoop(ctx, sess) + go func() { + select { + case <-ctx.Done(): + case <-sess.Done(): + cancel() + } + }() sendErrCh := make(chan error, 1) go func() { @@ -71,12 +78,23 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ sendErrCh <- nil cancel() return - case env := <-toAgent: - if err := stream.Send(env); err != nil { + case outbound := <-toAgent: + if outbound == nil || outbound.GatewayEnvelope == nil { + continue + } + select { + case <-outbound.Context().Done(): + outbound.Ack(outbound.Context().Err()) + continue + default: + } + if err := stream.Send(outbound.GatewayEnvelope); err != nil { + outbound.Ack(err) sendErrCh <- err cancel() return } + outbound.Ack(nil) } } }() diff --git a/crates/agent-gateway/internal/server/http.go b/crates/agent-gateway/internal/server/http.go index 89e96eebb..db8124167 100644 --- a/crates/agent-gateway/internal/server/http.go +++ b/crates/agent-gateway/internal/server/http.go @@ -24,6 +24,7 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { rootMux := http.NewServeMux() rootMux.HandleFunc("GET /healthz", handler.Health()) rootMux.Handle("/ws", NewWebSocketServer(cfg, sm)) + rootMux.HandleFunc("/t/", publicTunnelProxy(sm)) rootMux.HandleFunc("GET /image-proxy", handler.ImageProxy(cfg.RequestTimeout)) rootMux.HandleFunc("GET /api/public/history-shares/{token}", publicHistoryShare(cfg, sm)) diff --git a/crates/agent-gateway/internal/server/http_test.go b/crates/agent-gateway/internal/server/http_test.go index 2f780b98d..0e28201a6 100644 --- a/crates/agent-gateway/internal/server/http_test.go +++ b/crates/agent-gateway/internal/server/http_test.go @@ -69,7 +69,9 @@ func TestPublicHistoryShareResolvesWithoutAuthorization(t *testing.T) { var outbound *gatewayv1.GatewayEnvelope select { - case outbound = <-agentSession.Outbound(): + case delivered := <-agentSession.Outbound(): + delivered.Ack(nil) + outbound = delivered.GatewayEnvelope case <-time.After(time.Second): t.Fatal("timed out waiting for public share request") } @@ -164,7 +166,9 @@ func publicHistoryShareErrorStatusForTest(t *testing.T, code int, message string var outbound *gatewayv1.GatewayEnvelope select { - case outbound = <-agentSession.Outbound(): + case delivered := <-agentSession.Outbound(): + delivered.Ack(nil) + outbound = delivered.GatewayEnvelope case <-time.After(time.Second): t.Fatal("timed out waiting for public share request") } diff --git a/crates/agent-gateway/internal/server/tunnel.go b/crates/agent-gateway/internal/server/tunnel.go new file mode 100644 index 000000000..33fbc97ab --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel.go @@ -0,0 +1,722 @@ +package server + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/session" + "golang.org/x/net/websocket" +) + +const tunnelBodyChunkSize = 64 * 1024 + +func publicTunnelProxy(sm *session.Manager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if slug, ok := parseTunnelPublicPathWithoutTrailingSlash(r.URL.Path); ok { + target := "/t/" + slug + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusPermanentRedirect) + return + } + + slug, restPath, ok := parseTunnelPublicPath(r.URL.Path) + if !ok { + writeTunnelError(w, http.StatusNotFound, "tunnel not found") + return + } + if r.URL.RawQuery != "" { + restPath += "?" + r.URL.RawQuery + } + + if isWebSocketUpgrade(r) { + serveTunnelWebSocket(w, r, sm, slug, restPath) + return + } + serveTunnelHTTP(w, r, sm, slug, restPath) + } +} + +func serveTunnelHTTP( + w http.ResponseWriter, + r *http.Request, + sm *session.Manager, + slug string, + restPath string, +) { + streamID := "http-" + uuid.NewString() + lease, err := sm.AcquireTunnel(slug, streamID) + if err != nil { + writeTunnelAcquireError(w, err) + return + } + + ctx, cancel := context.WithCancel(r.Context()) + completed := false + defer func() { + cancel() + if !completed { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + }) + } + lease.Release() + }() + + start := &gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START, + Method: r.Method, + Path: restPath, + Headers: filteredTunnelRequestHeaders(r.Header), + } + if err := sm.SendTunnelFrameToAgent(start); err != nil { + writeTunnelAcquireError(w, err) + return + } + + bodyDone := make(chan struct{}) + go streamTunnelHTTPRequestBody(ctx, sm, lease.TunnelID(), slug, streamID, r.Body, bodyDone) + + responseStarted := false + responseHeadersWritten := false + responseStatus := http.StatusOK + responseHeaders := http.Header{} + responseRewriteKind := tunnelResponseRewriteNone + var responseBody []byte + writeResponseHeaders := func() { + if responseHeadersWritten { + return + } + writeTunnelHTTPHeaders(w, responseHeaders) + w.WriteHeader(responseStatus) + responseHeadersWritten = true + } + writeBufferedResponse := func() { + if responseRewriteKind == tunnelResponseRewriteNone { + return + } + writeResponseHeaders() + if len(responseBody) > 0 { + _, _ = w.Write(responseBody) + responseBody = nil + } + flushTunnelResponse(w) + } + for { + select { + case <-r.Context().Done(): + return + case <-lease.Done(): + if !responseStarted { + writeTunnelError(w, http.StatusBadGateway, "tunnel stream closed") + } else if !responseHeadersWritten { + writeBufferedResponse() + } + return + case <-bodyDone: + bodyDone = nil + case frame := <-lease.Frames(): + if frame == nil { + continue + } + switch frame.GetKind() { + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START: + if responseStarted { + continue + } + responseStarted = true + status := int(frame.GetStatusCode()) + if status <= 0 { + status = http.StatusOK + } + responseStatus = status + responseHeaders = tunnelResponseHeaders(frame, lease.Tunnel()) + responseRewriteKind = tunnelResponseRewriteKindFor(r.Method, responseStatus, responseHeaders) + if responseRewriteKind == tunnelResponseRewriteNone { + writeResponseHeaders() + flushTunnelResponse(w) + } + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY: + if !responseStarted { + responseStarted = true + responseStatus = http.StatusOK + responseHeaders = http.Header{} + responseRewriteKind = tunnelResponseRewriteNone + writeResponseHeaders() + } + if body := frame.GetBody(); len(body) > 0 { + if responseRewriteKind != tunnelResponseRewriteNone { + if len(responseBody)+len(body) <= tunnelRewriteBodyMaxBytes { + responseBody = append(responseBody, body...) + continue + } + responseRewriteKind = tunnelResponseRewriteNone + writeResponseHeaders() + if len(responseBody) > 0 { + if _, err := w.Write(responseBody); err != nil { + return + } + responseBody = nil + } + } + writeResponseHeaders() + if _, err := w.Write(body); err != nil { + return + } + flushTunnelResponse(w) + } + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END: + if responseRewriteKind != tunnelResponseRewriteNone { + body := responseBody + if rewritten, changed := rewriteTunnelResponseBody(body, lease.Tunnel(), responseRewriteKind); changed { + body = rewritten + responseHeaders.Del("Content-Length") + responseHeaders.Del("Etag") + responseHeaders.Del("ETag") + } + writeResponseHeaders() + if len(body) > 0 { + if _, err := w.Write(body); err != nil { + return + } + } + responseBody = nil + } else if responseStarted && !responseHeadersWritten { + writeResponseHeaders() + } + completed = true + flushTunnelResponse(w) + return + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR: + if !responseStarted { + writeTunnelError(w, http.StatusBadGateway, frame.GetError()) + } else if !responseHeadersWritten { + writeBufferedResponse() + } + return + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL: + if !responseStarted { + writeTunnelError(w, http.StatusServiceUnavailable, "tunnel canceled") + } else if !responseHeadersWritten { + writeBufferedResponse() + } + return + } + } + } +} + +func serveTunnelWebSocket( + w http.ResponseWriter, + r *http.Request, + sm *session.Manager, + slug string, + restPath string, +) { + streamID := "ws-" + uuid.NewString() + lease, err := sm.AcquireTunnel(slug, streamID) + if err != nil { + writeTunnelAcquireError(w, err) + return + } + handlerStarted := false + handler := websocket.Handler(func(ws *websocket.Conn) { + handlerStarted = true + defer lease.Release() + handleTunnelWebSocket(ws, r, sm, lease, slug, streamID, restPath) + }) + handler.ServeHTTP(w, r) + if !handlerStarted { + lease.Release() + } +} + +func handleTunnelWebSocket( + ws *websocket.Conn, + r *http.Request, + sm *session.Manager, + lease *session.TunnelStreamLease, + slug string, + streamID string, + restPath string, +) { + ws.MaxPayloadBytes = 16 * 1024 * 1024 + closed := false + defer func() { + if !closed { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + }) + } + _ = ws.Close() + }() + + if err := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN, + Method: r.Method, + Path: restPath, + Headers: filteredTunnelRequestHeaders(r.Header), + }); err != nil { + return + } + + if !awaitTunnelWebSocketOpen(ws, lease) { + closed = true + return + } + + browserFrames := make(chan *gatewayv1.TunnelFrame, 64) + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + for { + var body []byte + if err := websocket.Message.Receive(ws, &body); err != nil { + return + } + messageType := "binary" + if utf8.Valid(body) { + messageType = "text" + } + frame := &gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME, + Body: body, + WsMessageType: messageType, + } + select { + case browserFrames <- frame: + case <-lease.Done(): + return + } + } + }() + + for { + select { + case <-lease.Done(): + closed = true + return + case <-readerDone: + closed = true + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + }) + return + case frame := <-browserFrames: + if frame != nil { + _ = sm.SendTunnelFrameToAgent(frame) + } + case frame := <-lease.Frames(): + if frame == nil { + continue + } + switch frame.GetKind() { + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME: + if strings.EqualFold(frame.GetWsMessageType(), "text") { + if err := websocket.Message.Send(ws, string(frame.GetBody())); err != nil { + return + } + } else { + if err := websocket.Message.Send(ws, frame.GetBody()); err != nil { + return + } + } + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR: + closed = true + return + } + } + } +} + +func awaitTunnelWebSocketOpen(ws *websocket.Conn, lease *session.TunnelStreamLease) bool { + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + for { + select { + case <-timer.C: + return false + case <-lease.Done(): + return false + case frame := <-lease.Frames(): + if frame == nil { + continue + } + switch frame.GetKind() { + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN: + return true + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE: + if frame.GetError() != "" { + _ = websocket.Message.Send(ws, frame.GetError()) + } + return false + } + } + } +} + +func streamTunnelHTTPRequestBody( + ctx context.Context, + sm *session.Manager, + tunnelID string, + slug string, + streamID string, + body io.ReadCloser, + done chan<- struct{}, +) { + defer close(done) + defer body.Close() + + buffer := make([]byte, tunnelBodyChunkSize) + for { + n, err := body.Read(buffer) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buffer[:n]) + if sendErr := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: tunnelID, + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY, + Body: chunk, + }); sendErr != nil { + return + } + } + if errors.Is(err, io.EOF) { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: tunnelID, + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_END, + }) + return + } + if err != nil { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: tunnelID, + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + Error: err.Error(), + }) + return + } + select { + case <-ctx.Done(): + return + default: + } + } +} + +func parseTunnelPublicPath(rawPath string) (string, string, bool) { + if !strings.HasPrefix(rawPath, "/t/") { + return "", "", false + } + trimmed := strings.TrimPrefix(rawPath, "/t/") + parts := strings.SplitN(trimmed, "/", 2) + slug := strings.TrimSpace(parts[0]) + if slug == "" { + return "", "", false + } + if len(parts) == 1 || parts[1] == "" { + return slug, "/", true + } + return slug, "/" + parts[1], true +} + +func parseTunnelPublicPathWithoutTrailingSlash(rawPath string) (string, bool) { + if !strings.HasPrefix(rawPath, "/t/") { + return "", false + } + trimmed := strings.TrimPrefix(rawPath, "/t/") + if trimmed == "" || strings.Contains(trimmed, "/") { + return "", false + } + return strings.TrimSpace(trimmed), strings.TrimSpace(trimmed) != "" +} + +func writeTunnelAcquireError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, session.ErrTunnelNotFound), errors.Is(err, session.ErrTunnelExpired): + writeTunnelError(w, http.StatusNotFound, "tunnel not found") + case errors.Is(err, session.ErrAgentOffline): + writeTunnelError(w, http.StatusServiceUnavailable, "agent offline") + case errors.Is(err, session.ErrTunnelOverLimit): + writeTunnelError(w, http.StatusTooManyRequests, "tunnel connection limit exceeded") + default: + writeTunnelError(w, http.StatusBadGateway, err.Error()) + } +} + +func writeTunnelError(w http.ResponseWriter, status int, message string) { + message = strings.TrimSpace(message) + if message == "" { + message = http.StatusText(status) + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write([]byte(message)) +} + +func isWebSocketUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && + strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") +} + +func filteredTunnelRequestHeaders(headers http.Header) []*gatewayv1.TunnelHeader { + return filteredTunnelHeaders(headers, true) +} + +func filteredTunnelResponseHeaders(headers []*gatewayv1.TunnelHeader) http.Header { + out := http.Header{} + for _, header := range headers { + name := http.CanonicalHeaderKey(strings.TrimSpace(header.GetName())) + if name == "" || shouldDropTunnelHeader(name, false) { + continue + } + out.Add(name, header.GetValue()) + } + return out +} + +func filteredTunnelHeaders(headers http.Header, request bool) []*gatewayv1.TunnelHeader { + out := make([]*gatewayv1.TunnelHeader, 0, len(headers)) + for name, values := range headers { + canonical := http.CanonicalHeaderKey(strings.TrimSpace(name)) + if canonical == "" || shouldDropTunnelHeader(canonical, request) { + continue + } + for _, value := range values { + out = append(out, &gatewayv1.TunnelHeader{ + Name: canonical, + Value: value, + }) + } + } + return out +} + +func shouldDropTunnelHeader(name string, request bool) bool { + switch strings.ToLower(name) { + case "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade": + return true + case "host": + return request + default: + return false + } +} + +func writeTunnelResponseHeaders( + w http.ResponseWriter, + frame *gatewayv1.TunnelFrame, + tunnel *gatewayv1.TunnelSummary, +) { + writeTunnelHTTPHeaders(w, tunnelResponseHeaders(frame, tunnel)) +} + +func tunnelResponseHeaders( + frame *gatewayv1.TunnelFrame, + tunnel *gatewayv1.TunnelSummary, +) http.Header { + headers := filteredTunnelResponseHeaders(frame.GetHeaders()) + for name, values := range headers { + rewritten := make([]string, 0, len(values)) + for _, value := range values { + if strings.EqualFold(name, "Location") { + value = rewriteTunnelLocation(value, tunnel) + } + if strings.EqualFold(name, "Set-Cookie") { + value = rewriteTunnelSetCookiePath(value, tunnel) + } + rewritten = append(rewritten, value) + } + headers[name] = rewritten + } + return headers +} + +func writeTunnelHTTPHeaders(w http.ResponseWriter, headers http.Header) { + for name, values := range headers { + for _, value := range values { + w.Header().Add(name, value) + } + } +} + +func flushTunnelResponse(w http.ResponseWriter) { + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func rewriteTunnelLocation(value string, tunnel *gatewayv1.TunnelSummary) string { + if tunnel == nil { + return value + } + target, err := url.Parse(tunnel.GetTargetUrl()) + if err != nil || target.Host == "" { + return value + } + publicPrefix := "/t/" + tunnel.GetSlug() + parsed, err := url.Parse(value) + if err != nil { + return value + } + if parsed.IsAbs() { + if !strings.EqualFold(parsed.Scheme, target.Scheme) || !strings.EqualFold(parsed.Host, target.Host) { + return value + } + path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) + if path == "" { + path = "/" + } + if parsed.RawQuery != "" { + path += "?" + parsed.RawQuery + } + if parsed.Fragment != "" { + path += "#" + parsed.EscapedFragment() + } + return publicPrefix + path + } + if strings.HasPrefix(value, "/") { + path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) + if path == "" { + path = "/" + } + if parsed.RawQuery != "" { + path += "?" + parsed.RawQuery + } + if parsed.Fragment != "" { + path += "#" + parsed.EscapedFragment() + } + return publicPrefix + path + } + return value +} + +func rewriteTunnelSetCookiePath(value string, tunnel *gatewayv1.TunnelSummary) string { + if tunnel == nil || tunnel.GetSlug() == "" { + return value + } + parts := strings.Split(value, ";") + targetBasePath := "/" + if target, err := url.Parse(tunnel.GetTargetUrl()); err == nil { + targetBasePath = target.EscapedPath() + } + for index, part := range parts { + trimmed := strings.TrimSpace(part) + if !strings.HasPrefix(strings.ToLower(trimmed), "path=") { + continue + } + cookiePath := strings.TrimSpace(trimmed[len("path="):]) + if cookiePath == "" { + cookiePath = "/" + } + rest := stripTunnelTargetBasePath(cookiePath, targetBasePath) + if rest == "" { + rest = "/" + } + prefix := "" + if leading := len(part) - len(strings.TrimLeft(part, " \t")); leading > 0 { + prefix = part[:leading] + } + parts[index] = fmt.Sprintf("%sPath=/t/%s%s", prefix, tunnel.GetSlug(), rest) + } + return strings.Join(parts, ";") +} + +func stripTunnelTargetBasePath(pathValue string, basePath string) string { + pathValue = normalizeTunnelPath(pathValue) + basePath = normalizeTunnelPath(basePath) + if basePath == "/" { + return pathValue + } + if pathValue == basePath { + return "/" + } + if strings.HasPrefix(pathValue, strings.TrimRight(basePath, "/")+"/") { + return strings.TrimPrefix(pathValue, strings.TrimRight(basePath, "/")) + } + return pathValue +} + +func normalizeTunnelPath(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "/" + } + if !strings.HasPrefix(value, "/") { + value = "/" + value + } + return value +} + +func publicBaseURLFromHTTPRequest(r *http.Request) string { + if r == nil { + return "" + } + scheme := forwardedHeaderFirst(r.Header.Get("X-Forwarded-Proto")) + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + host := forwardedHeaderFirst(r.Header.Get("X-Forwarded-Host")) + if host == "" { + host = r.Host + } + if host == "" { + return "" + } + return scheme + "://" + host +} + +func forwardedHeaderFirst(value string) string { + first := strings.TrimSpace(strings.Split(value, ",")[0]) + return strings.TrimSpace(first) +} diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite.go b/crates/agent-gateway/internal/server/tunnel_rewrite.go new file mode 100644 index 000000000..b6b743be5 --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel_rewrite.go @@ -0,0 +1,229 @@ +package server + +import ( + "mime" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const tunnelRewriteBodyMaxBytes = 4 * 1024 * 1024 + +type tunnelResponseRewriteKind int + +const ( + tunnelResponseRewriteNone tunnelResponseRewriteKind = iota + tunnelResponseRewriteHTML + tunnelResponseRewriteCSS + tunnelResponseRewriteJavaScript +) + +var ( + tunnelHTMLQuotedAttrURLPattern = regexp.MustCompile(`(?i)(\b(?:href|src|action|poster|data|formaction)\s*=\s*)(["'])([^"']+)(["'])`) + tunnelHTMLBareAttrURLPattern = regexp.MustCompile(`(?i)(\b(?:href|src|action|poster|data|formaction)\s*=\s*)([^\s"'<>]+)`) + tunnelJSQuotedURLPattern = regexp.MustCompile(`(["'])(/[^"'\\]*)(["'])`) + tunnelCSSURLPattern = regexp.MustCompile(`(?i)(url\(\s*)(["']?)([^"')]+)(["']?\s*\))`) +) + +func tunnelResponseRewriteKindFor( + method string, + status int, + headers http.Header, +) tunnelResponseRewriteKind { + if strings.EqualFold(strings.TrimSpace(method), http.MethodHead) { + return tunnelResponseRewriteNone + } + if status < http.StatusOK || + status == http.StatusNoContent || + status == http.StatusNotModified { + return tunnelResponseRewriteNone + } + if strings.TrimSpace(headers.Get("Content-Encoding")) != "" { + return tunnelResponseRewriteNone + } + + contentType := strings.TrimSpace(headers.Get("Content-Type")) + if contentType == "" { + return tunnelResponseRewriteNone + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = contentType + } + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + + switch { + case mediaType == "text/html" || mediaType == "application/xhtml+xml": + return tunnelResponseRewriteHTML + case mediaType == "text/css": + return tunnelResponseRewriteCSS + case mediaType == "text/javascript", + mediaType == "application/javascript", + mediaType == "application/x-javascript", + mediaType == "text/ecmascript", + mediaType == "application/ecmascript", + strings.HasSuffix(mediaType, "+javascript"): + return tunnelResponseRewriteJavaScript + default: + return tunnelResponseRewriteNone + } +} + +func rewriteTunnelResponseBody( + body []byte, + tunnel *gatewayv1.TunnelSummary, + kind tunnelResponseRewriteKind, +) ([]byte, bool) { + if len(body) == 0 || kind == tunnelResponseRewriteNone || tunnelPublicPathPrefix(tunnel) == "" { + return body, false + } + if !utf8.Valid(body) { + return body, false + } + + original := string(body) + rewritten := original + switch kind { + case tunnelResponseRewriteHTML: + rewritten = rewriteTunnelHTMLBody(rewritten, tunnel) + case tunnelResponseRewriteCSS: + rewritten = rewriteTunnelCSSBody(rewritten, tunnel) + case tunnelResponseRewriteJavaScript: + rewritten = rewriteTunnelJavaScriptBody(rewritten, tunnel) + } + if rewritten == original { + return body, false + } + return []byte(rewritten), true +} + +func rewriteTunnelHTMLBody(input string, tunnel *gatewayv1.TunnelSummary) string { + input = tunnelHTMLQuotedAttrURLPattern.ReplaceAllStringFunc(input, func(match string) string { + parts := tunnelHTMLQuotedAttrURLPattern.FindStringSubmatch(match) + if len(parts) != 5 { + return match + } + rewritten := rewriteTunnelBodyURL(parts[3], tunnel) + if rewritten == parts[3] { + return match + } + return parts[1] + parts[2] + rewritten + parts[4] + }) + + return tunnelHTMLBareAttrURLPattern.ReplaceAllStringFunc(input, func(match string) string { + parts := tunnelHTMLBareAttrURLPattern.FindStringSubmatch(match) + if len(parts) != 3 { + return match + } + rewritten := rewriteTunnelBodyURL(parts[2], tunnel) + if rewritten == parts[2] { + return match + } + return parts[1] + rewritten + }) +} + +func rewriteTunnelCSSBody(input string, tunnel *gatewayv1.TunnelSummary) string { + return tunnelCSSURLPattern.ReplaceAllStringFunc(input, func(match string) string { + parts := tunnelCSSURLPattern.FindStringSubmatch(match) + if len(parts) != 5 { + return match + } + rewritten := rewriteTunnelBodyURL(strings.TrimSpace(parts[3]), tunnel) + if rewritten == strings.TrimSpace(parts[3]) { + return match + } + return parts[1] + parts[2] + rewritten + parts[4] + }) +} + +func rewriteTunnelJavaScriptBody(input string, tunnel *gatewayv1.TunnelSummary) string { + return tunnelJSQuotedURLPattern.ReplaceAllStringFunc(input, func(match string) string { + parts := tunnelJSQuotedURLPattern.FindStringSubmatch(match) + if len(parts) != 4 || parts[1] != parts[3] { + return match + } + rewritten := rewriteTunnelBodyURL(parts[2], tunnel) + if rewritten == parts[2] { + return match + } + return parts[1] + rewritten + parts[1] + }) +} + +func rewriteTunnelBodyURL(value string, tunnel *gatewayv1.TunnelSummary) string { + prefix := tunnelPublicPathPrefix(tunnel) + if prefix == "" { + return value + } + trimmed := strings.TrimSpace(value) + if trimmed == "" || + strings.HasPrefix(trimmed, "#") || + strings.HasPrefix(trimmed, "//") { + return value + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return value + } + target, targetErr := url.Parse(tunnel.GetTargetUrl()) + if parsed.IsAbs() { + if targetErr != nil || target.Host == "" { + return value + } + if !strings.EqualFold(parsed.Scheme, target.Scheme) || + !strings.EqualFold(parsed.Host, target.Host) { + return value + } + path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) + return appendTunnelURLQueryAndFragment(prefix+pathOrRoot(path), parsed) + } + if !strings.HasPrefix(trimmed, "/") { + return value + } + if trimmed == prefix || strings.HasPrefix(trimmed, prefix+"/") { + return value + } + + path := parsed.EscapedPath() + if targetErr == nil && target.Host != "" { + path = stripTunnelTargetBasePath(path, target.EscapedPath()) + } + return appendTunnelURLQueryAndFragment(prefix+pathOrRoot(path), parsed) +} + +func tunnelPublicPathPrefix(tunnel *gatewayv1.TunnelSummary) string { + if tunnel == nil { + return "" + } + slug := strings.TrimSpace(tunnel.GetSlug()) + if slug == "" { + return "" + } + return "/t/" + slug +} + +func pathOrRoot(path string) string { + if strings.TrimSpace(path) == "" { + return "/" + } + return path +} + +func appendTunnelURLQueryAndFragment(path string, parsed *url.URL) string { + if parsed == nil { + return path + } + if parsed.RawQuery != "" { + path += "?" + parsed.RawQuery + } + if parsed.Fragment != "" { + path += "#" + parsed.EscapedFragment() + } + return path +} diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go new file mode 100644 index 000000000..3d9d45136 --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go @@ -0,0 +1,269 @@ +package server + +import ( + "net/http" + "strings" + "testing" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func TestTunnelResponseRewriteKindFor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + status int + headers http.Header + want tunnelResponseRewriteKind + }{ + { + name: "html", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"text/html; charset=utf-8"}, + }, + want: tunnelResponseRewriteHTML, + }, + { + name: "javascript", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"application/javascript"}, + }, + want: tunnelResponseRewriteJavaScript, + }, + { + name: "css", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"text/css"}, + }, + want: tunnelResponseRewriteCSS, + }, + { + name: "compressed response", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"text/html; charset=utf-8"}, + "Content-Encoding": []string{"gzip"}, + }, + want: tunnelResponseRewriteNone, + }, + { + name: "head request", + method: http.MethodHead, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"text/html; charset=utf-8"}, + }, + want: tunnelResponseRewriteNone, + }, + { + name: "json", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"application/json"}, + }, + want: tunnelResponseRewriteNone, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tunnelResponseRewriteKindFor(tt.method, tt.status, tt.headers); got != tt.want { + t.Fatalf("tunnelResponseRewriteKindFor() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRewriteTunnelHTMLBodyPrefixesRootRelativeAttributes(t *testing.T) { + t.Parallel() + + tunnel := tunnelRewriteTestSummary() + input := strings.Join([]string{ + ``, + ``, + `
`, + `health`, + `cdn`, + `external`, + `already`, + `absolute target`, + }, "\n") + + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteHTML) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a change") + } + output := string(body) + + assertContains(t, output, `href="/t/test-slug/styles.css"`) + assertContains(t, output, `src='/t/test-slug/app.js'`) + assertContains(t, output, `action=/t/test-slug/api/messages`) + assertContains(t, output, `href="/t/test-slug/api/health?check=1#ready"`) + assertContains(t, output, `href="//cdn.example.com/lib.js"`) + assertContains(t, output, `href="https://example.com/page"`) + assertContains(t, output, `href="/t/test-slug/already"`) + assertContains(t, output, `href="/t/test-slug/api/showcase"`) + assertNotContains(t, output, `/t/test-slug/t/test-slug`) +} + +func TestRewriteTunnelBodyStripsTargetBasePath(t *testing.T) { + t.Parallel() + + tunnel := &gatewayv1.TunnelSummary{ + Slug: "base-slug", + TargetUrl: "http://127.0.0.1:3100/app", + } + input := strings.Join([]string{ + ``, + ``, + `root api`, + }, "\n") + + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteHTML) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a change") + } + output := string(body) + + assertContains(t, output, `src="/t/base-slug/assets/main.js"`) + assertContains(t, output, `href="/t/base-slug/styles.css"`) + assertContains(t, output, `href="/t/base-slug/api/health"`) + assertNotContains(t, output, `/t/base-slug/app/`) + + jsBody, changed := rewriteTunnelResponseBody( + []byte(`fetch("/app/api/health?check=1#ready")`), + tunnel, + tunnelResponseRewriteJavaScript, + ) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a JavaScript change") + } + assertContains(t, string(jsBody), `fetch("/t/base-slug/api/health?check=1#ready")`) + + cssBody, changed := rewriteTunnelResponseBody( + []byte(`body { background: url(/app/images/bg.png); }`), + tunnel, + tunnelResponseRewriteCSS, + ) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a CSS change") + } + assertContains(t, string(cssBody), `url(/t/base-slug/images/bg.png)`) +} + +func TestRewriteTunnelJavaScriptBodyPrefixesRootRelativeStrings(t *testing.T) { + t.Parallel() + + tunnel := tunnelRewriteTestSummary() + input := strings.Join([]string{ + `requestJson('/api/showcase')`, + `fetch("/api/health?check=1")`, + `const root = "/"`, + `const external = "https://example.com/api"`, + `const cdn = "//cdn.example.com/app.js"`, + `const already = "/t/test-slug/api/health"`, + }, "\n") + + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteJavaScript) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a change") + } + output := string(body) + + assertContains(t, output, `requestJson('/t/test-slug/api/showcase')`) + assertContains(t, output, `fetch("/t/test-slug/api/health?check=1")`) + assertContains(t, output, `const root = "/t/test-slug/"`) + assertContains(t, output, `const external = "https://example.com/api"`) + assertContains(t, output, `const cdn = "//cdn.example.com/app.js"`) + assertContains(t, output, `const already = "/t/test-slug/api/health"`) + assertNotContains(t, output, `/t/test-slug/t/test-slug`) +} + +func TestRewriteTunnelCSSBodyPrefixesRootRelativeURLs(t *testing.T) { + t.Parallel() + + tunnel := tunnelRewriteTestSummary() + input := strings.Join([]string{ + `body { background: url(/images/bg.png); }`, + `.icon { mask-image: url('/icons/check.svg'); }`, + `.remote { background: url("https://example.com/bg.png"); }`, + `.cdn { background: url("//cdn.example.com/bg.png"); }`, + `.already { background: url(/t/test-slug/images/bg.png); }`, + }, "\n") + + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteCSS) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a change") + } + output := string(body) + + assertContains(t, output, `url(/t/test-slug/images/bg.png)`) + assertContains(t, output, `url('/t/test-slug/icons/check.svg')`) + assertContains(t, output, `url("https://example.com/bg.png")`) + assertContains(t, output, `url("//cdn.example.com/bg.png")`) + assertContains(t, output, `url(/t/test-slug/images/bg.png)`) + assertNotContains(t, output, `/t/test-slug/t/test-slug`) +} + +func TestParseTunnelPublicPathWithoutTrailingSlash(t *testing.T) { + t.Parallel() + + slug, ok := parseTunnelPublicPathWithoutTrailingSlash("/t/test-slug") + if !ok || slug != "test-slug" { + t.Fatalf("parseTunnelPublicPathWithoutTrailingSlash() = %q, %v", slug, ok) + } + + for _, path := range []string{"/t/test-slug/", "/t/test-slug/api", "/t/", "/api/test-slug"} { + if slug, ok := parseTunnelPublicPathWithoutTrailingSlash(path); ok { + t.Fatalf("parseTunnelPublicPathWithoutTrailingSlash(%q) = %q, true; want false", path, slug) + } + } +} + +func TestRewriteTunnelLocationPreservesQueryAndFragment(t *testing.T) { + t.Parallel() + + tunnel := tunnelRewriteTestSummary() + if got := rewriteTunnelLocation("/api/health?check=1#ready", tunnel); got != "/t/test-slug/api/health?check=1#ready" { + t.Fatalf("rewriteTunnelLocation root path = %q", got) + } + if got := rewriteTunnelLocation("http://127.0.0.1:3100/api/showcase#item", tunnel); got != "/t/test-slug/api/showcase#item" { + t.Fatalf("rewriteTunnelLocation absolute target = %q", got) + } + if got := rewriteTunnelLocation("https://example.com/api#item", tunnel); got != "https://example.com/api#item" { + t.Fatalf("rewriteTunnelLocation external = %q", got) + } +} + +func tunnelRewriteTestSummary() *gatewayv1.TunnelSummary { + return &gatewayv1.TunnelSummary{ + Slug: "test-slug", + TargetUrl: "http://127.0.0.1:3100", + } +} + +func assertContains(t *testing.T, value string, needle string) { + t.Helper() + if !strings.Contains(value, needle) { + t.Fatalf("expected output to contain %q, got:\n%s", needle, value) + } +} + +func assertNotContains(t *testing.T, value string, needle string) { + t.Helper() + if strings.Contains(value, needle) { + t.Fatalf("expected output not to contain %q, got:\n%s", needle, value) + } +} diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 56f87c440..0a7e43d30 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -431,6 +431,25 @@ func (c *websocketConnection) awaitAgentResponse( return awaitAgentUnaryResponse(ctx, c.sm, requestID, envelope) } +func (c *websocketConnection) sendToAgent(envelope *gatewayv1.GatewayEnvelope) error { + timeout := c.cfg.WebSocketWriteTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + go func() { + select { + case <-c.done: + cancel() + case <-ctx.Done(): + } + }() + + return c.sm.SendToAgentContext(ctx, envelope) +} + func (c *websocketConnection) writeResponse(requestID string, payload any) error { return c.writeEnvelope(websocketEnvelope{ ID: requestID, diff --git a/crates/agent-gateway/internal/server/websocket_chat_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_handlers.go index b050b3623..f647b32e6 100644 --- a/crates/agent-gateway/internal/server/websocket_chat_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_chat_handlers.go @@ -76,7 +76,7 @@ func (c *websocketConnection) handleChatStart(req websocketRequest) { defer c.releaseActiveChat(responseID) if created { - if err := c.sm.SendToAgent(&gatewayv1.GatewayEnvelope{ + if err := c.sendToAgent(&gatewayv1.GatewayEnvelope{ RequestId: sourceRequestID, Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_ChatRequest{ @@ -328,7 +328,7 @@ func (c *websocketConnection) handleChatCancel(req websocketRequest) { return } - if err := c.sm.SendToAgent(&gatewayv1.GatewayEnvelope{ + if err := c.sendToAgent(&gatewayv1.GatewayEnvelope{ RequestId: req.ID, Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_CancelChat{ diff --git a/crates/agent-gateway/internal/server/websocket_roundtrip.go b/crates/agent-gateway/internal/server/websocket_roundtrip.go index 3d7836aa6..c2723c57f 100644 --- a/crates/agent-gateway/internal/server/websocket_roundtrip.go +++ b/crates/agent-gateway/internal/server/websocket_roundtrip.go @@ -53,7 +53,7 @@ func awaitAgentUnaryResponse( } defer cleanup() - if err := sm.SendToAgent(envelope); err != nil { + if err := sm.SendToAgentContext(ctx, envelope); err != nil { return nil, err } diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 823244479..60ad324dd 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -51,6 +51,10 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "terminal.close": (*websocketConnection).handleTerminalRequest, "terminal.close_project": (*websocketConnection).handleTerminalRequest, "terminal.detach": (*websocketConnection).handleTerminalDetach, + "tunnel.list": (*websocketConnection).handleTunnelList, + "tunnel.create": (*websocketConnection).handleTunnelCreate, + "tunnel.update": (*websocketConnection).handleTunnelUpdate, + "tunnel.close": (*websocketConnection).handleTunnelClose, "git.status": (*websocketConnection).handleGitRequest, "git.branches": (*websocketConnection).handleGitRequest, "git.init": (*websocketConnection).handleGitRequest, diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go index e7e8a2d8e..3b275b626 100644 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ b/crates/agent-gateway/internal/server/websocket_routes_test.go @@ -1,6 +1,9 @@ package server -import "testing" +import ( + "encoding/json" + "testing" +) func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { t.Parallel() @@ -52,6 +55,10 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "terminal.close", "terminal.close_project", "terminal.detach", + "tunnel.list", + "tunnel.create", + "tunnel.update", + "tunnel.close", "git.status", "git.branches", "git.init", @@ -103,3 +110,50 @@ func TestDecodeWebSocketPayloadRejectsUnknownFields(t *testing.T) { t.Fatal("expected unknown payload field to be rejected") } } + +func TestTunnelTTLFromPayloadDefaultsOnlyWhenOmitted(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw json.RawMessage + camelValue uint32 + snakeValue uint32 + want uint32 + }{ + { + name: "omitted", + raw: json.RawMessage(`{"targetUrl":"http://localhost:3000"}`), + camelValue: 0, + snakeValue: 0, + want: websocketDefaultTunnelTTLSeconds, + }, + { + name: "camel explicit infinite", + raw: json.RawMessage(`{"targetUrl":"http://localhost:3000","ttlSeconds":0}`), + camelValue: 0, + snakeValue: 0, + want: 0, + }, + { + name: "snake explicit infinite", + raw: json.RawMessage(`{"target_url":"http://localhost:3000","ttl_seconds":0}`), + camelValue: 0, + snakeValue: 0, + want: 0, + }, + { + name: "camel finite", + raw: json.RawMessage(`{"targetUrl":"http://localhost:3000","ttlSeconds":900}`), + camelValue: 900, + snakeValue: 0, + want: 900, + }, + } + + for _, tt := range tests { + if got := tunnelTTLFromPayload(tt.raw, tt.camelValue, tt.snakeValue); got != tt.want { + t.Fatalf("%s: tunnelTTLFromPayload = %d, want %d", tt.name, got, tt.want) + } + } +} diff --git a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go new file mode 100644 index 000000000..f01e3867e --- /dev/null +++ b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go @@ -0,0 +1,322 @@ +package server + +import ( + "encoding/json" + "strings" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const websocketDefaultTunnelTTLSeconds = 3600 + +type websocketTunnelCreatePayload struct { + TargetURL string `json:"targetUrl"` + TargetUrl string `json:"target_url"` + Name string `json:"name"` + TTLSeconds uint32 `json:"ttlSeconds"` + TtlSeconds uint32 `json:"ttl_seconds"` + ProjectPathKey string `json:"projectPathKey"` + ProjectPathKeySnake string `json:"project_path_key"` +} + +func tunnelTTLFromPayload(raw json.RawMessage, camelValue uint32, snakeValue uint32) uint32 { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return websocketDefaultTunnelTTLSeconds + } + if _, ok := fields["ttlSeconds"]; ok { + return camelValue + } + if _, ok := fields["ttl_seconds"]; ok { + return snakeValue + } + return websocketDefaultTunnelTTLSeconds +} + +type websocketTunnelUpdatePayload struct { + ID string `json:"id"` + TunnelID string `json:"tunnelId"` + TunnelId string `json:"tunnel_id"` + Slug string `json:"slug"` + TargetURL string `json:"targetUrl"` + TargetUrl string `json:"target_url"` + Name string `json:"name"` + TTLSeconds uint32 `json:"ttlSeconds"` + TtlSeconds uint32 `json:"ttl_seconds"` + ProjectPathKey string `json:"projectPathKey"` + ProjectPathKeySnake string `json:"project_path_key"` +} + +type websocketTunnelClosePayload struct { + ID string `json:"id"` + TunnelID string `json:"tunnelId"` + TunnelId string `json:"tunnel_id"` + Slug string `json:"slug"` +} + +func (c *websocketConnection) handleTunnelList(req websocketRequest) { + _ = c.writeResponse(req.ID, map[string]any{ + "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + }) +} + +func (c *websocketConnection) handleTunnelCreate(req websocketRequest) { + if !c.sm.WebTunnelsEnabled() { + _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") + return + } + + var body websocketTunnelCreatePayload + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid tunnel.create payload") + return + } + targetURL := strings.TrimSpace(body.TargetURL) + if targetURL == "" { + targetURL = strings.TrimSpace(body.TargetUrl) + } + ttlSeconds := tunnelTTLFromPayload(req.Payload, body.TTLSeconds, body.TtlSeconds) + projectPathKey := strings.TrimSpace(body.ProjectPathKey) + if projectPathKey == "" { + projectPathKey = strings.TrimSpace(body.ProjectPathKeySnake) + } + prepared, err := c.sm.PrepareTunnelCreate(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: targetURL, + Name: strings.TrimSpace(body.Name), + TtlSeconds: ttlSeconds, + ProjectPathKey: projectPathKey, + }, c.publicBaseURL()) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ + RequestId: req.ID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ + TunnelControl: prepared, + }, + }) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + if errResp := response.GetError(); errResp != nil { + _ = c.writeError(req.ID, errResp.GetMessage()) + return + } + controlResp := response.GetTunnelControlResp() + if controlResp == nil { + _ = c.writeError(req.ID, "unexpected agent response") + return + } + if controlResp.GetErrorMessage() != "" { + _ = c.writeError(req.ID, controlResp.GetErrorMessage()) + return + } + targetOverride := "" + if tunnel := controlResp.GetTunnel(); tunnel != nil { + targetOverride = tunnel.GetTargetUrl() + } + tunnel, err := c.sm.StorePreparedTunnel(prepared, targetOverride) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + _ = c.writeResponse(req.ID, map[string]any{ + "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), + "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + }) +} + +func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { + if !c.sm.WebTunnelsEnabled() { + _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") + return + } + + var body websocketTunnelUpdatePayload + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid tunnel.update payload") + return + } + identifier := strings.TrimSpace(body.ID) + if identifier == "" { + identifier = strings.TrimSpace(body.TunnelID) + } + if identifier == "" { + identifier = strings.TrimSpace(body.TunnelId) + } + if identifier == "" { + identifier = strings.TrimSpace(body.Slug) + } + if identifier == "" { + _ = c.writeError(req.ID, "tunnel id is required") + return + } + targetURL := strings.TrimSpace(body.TargetURL) + if targetURL == "" { + targetURL = strings.TrimSpace(body.TargetUrl) + } + ttlSeconds := tunnelTTLFromPayload(req.Payload, body.TTLSeconds, body.TtlSeconds) + projectPathKey := strings.TrimSpace(body.ProjectPathKey) + if projectPathKey == "" { + projectPathKey = strings.TrimSpace(body.ProjectPathKeySnake) + } + prepared, err := c.sm.PrepareTunnelUpdate(&gatewayv1.TunnelControlRequest{ + Action: "update", + TunnelId: identifier, + TargetUrl: targetURL, + Name: strings.TrimSpace(body.Name), + TtlSeconds: ttlSeconds, + ProjectPathKey: projectPathKey, + }) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ + RequestId: req.ID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ + TunnelControl: prepared, + }, + }) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + if errResp := response.GetError(); errResp != nil { + _ = c.writeError(req.ID, errResp.GetMessage()) + return + } + controlResp := response.GetTunnelControlResp() + if controlResp == nil { + _ = c.writeError(req.ID, "unexpected agent response") + return + } + if controlResp.GetErrorMessage() != "" { + _ = c.writeError(req.ID, controlResp.GetErrorMessage()) + return + } + tunnel, err := c.sm.ApplyTunnelUpdate(controlResp.GetTunnel()) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + _ = c.writeResponse(req.ID, map[string]any{ + "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), + "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + }) +} + +func (c *websocketConnection) handleTunnelClose(req websocketRequest) { + if !c.sm.WebTunnelsEnabled() { + _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") + return + } + + var body websocketTunnelClosePayload + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid tunnel.close payload") + return + } + identifier := strings.TrimSpace(body.ID) + if identifier == "" { + identifier = strings.TrimSpace(body.TunnelID) + } + if identifier == "" { + identifier = strings.TrimSpace(body.TunnelId) + } + if identifier == "" { + identifier = strings.TrimSpace(body.Slug) + } + if identifier == "" { + _ = c.writeError(req.ID, "tunnel id is required") + return + } + + tunnel, err := c.sm.CloseTunnel(identifier) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + _ = c.sendToAgent(&gatewayv1.GatewayEnvelope{ + RequestId: "tunnel-close-" + tunnel.GetId(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ + TunnelControl: &gatewayv1.TunnelControlRequest{ + Action: "close", + TunnelId: tunnel.GetId(), + Slug: tunnel.GetSlug(), + }, + }, + }) + + _ = c.writeResponse(req.ID, map[string]any{ + "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), + "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + }) +} + +func (c *websocketConnection) publicBaseURL() string { + return publicBaseURLFromHTTPRequest(c.conn.Request()) +} + +func websocketTunnelSummariesPayload( + summaries []*gatewayv1.TunnelSummary, + publicBaseURL string, +) []map[string]any { + payload := make([]map[string]any, 0, len(summaries)) + for _, summary := range summaries { + if item := websocketTunnelSummaryPayload(summary, publicBaseURL); item != nil { + payload = append(payload, item) + } + } + return payload +} + +func websocketTunnelSummaryPayload( + summary *gatewayv1.TunnelSummary, + publicBaseURL string, +) map[string]any { + if summary == nil { + return nil + } + publicURL := strings.TrimSpace(summary.GetPublicUrl()) + if publicURL == "" { + publicURL = buildPublicTunnelURL(publicBaseURL, summary.GetSlug()) + } + return map[string]any{ + "id": strings.TrimSpace(summary.GetId()), + "slug": strings.TrimSpace(summary.GetSlug()), + "name": strings.TrimSpace(summary.GetName()), + "targetUrl": strings.TrimSpace(summary.GetTargetUrl()), + "target_url": strings.TrimSpace(summary.GetTargetUrl()), + "publicUrl": publicURL, + "public_url": publicURL, + "createdAt": summary.GetCreatedAt(), + "created_at": summary.GetCreatedAt(), + "expiresAt": summary.GetExpiresAt(), + "expires_at": summary.GetExpiresAt(), + "activeConnections": summary.GetActiveConnections(), + "active_connections": summary.GetActiveConnections(), + "status": strings.TrimSpace(summary.GetStatus()), + "projectPathKey": strings.TrimSpace(summary.GetProjectPathKey()), + "project_path_key": strings.TrimSpace(summary.GetProjectPathKey()), + } +} + +func buildPublicTunnelURL(publicBaseURL string, slug string) string { + publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") + slug = strings.TrimSpace(slug) + if publicBaseURL == "" || slug == "" { + return "" + } + return publicBaseURL + "/t/" + slug + "/" +} diff --git a/crates/agent-gateway/internal/session/agent_session.go b/crates/agent-gateway/internal/session/agent_session.go index 790e6ae90..5bee6705c 100644 --- a/crates/agent-gateway/internal/session/agent_session.go +++ b/crates/agent-gateway/internal/session/agent_session.go @@ -1,6 +1,7 @@ package session import ( + "context" "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" @@ -13,13 +14,37 @@ func NewAgentSession(auth AuthSnapshot) *AgentSession { SessionID: auth.SessionID, ConnectedAt: time.Now(), LastPing: time.Now(), - toAgent: make(chan *gatewayv1.GatewayEnvelope, 64), + toAgent: make(chan *OutboundEnvelope, 64), done: make(chan struct{}), streams: make(map[string]*agentStream), } } -func (s *AgentSession) Outbound() <-chan *gatewayv1.GatewayEnvelope { +type OutboundEnvelope struct { + *gatewayv1.GatewayEnvelope + + ctx context.Context + result chan error +} + +func (e *OutboundEnvelope) Context() context.Context { + if e == nil || e.ctx == nil { + return context.Background() + } + return e.ctx +} + +func (e *OutboundEnvelope) Ack(err error) { + if e == nil || e.result == nil { + return + } + select { + case e.result <- err: + default: + } +} + +func (s *AgentSession) Outbound() <-chan *OutboundEnvelope { return s.toAgent } @@ -41,6 +66,34 @@ func (s *AgentSession) Close() { } func (s *AgentSession) SendToAgent(env *gatewayv1.GatewayEnvelope) error { + return s.enqueueToAgent(context.Background(), env, nil) +} + +func (s *AgentSession) SendToAgentContext(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { + if ctx == nil { + ctx = context.Background() + } + result := make(chan error, 1) + if err := s.enqueueToAgent(ctx, env, result); err != nil { + return err + } + + select { + case err := <-result: + return err + case <-ctx.Done(): + s.Close() + return ctx.Err() + case <-s.done: + return ErrAgentOffline + } +} + +func (s *AgentSession) enqueueToAgent( + ctx context.Context, + env *gatewayv1.GatewayEnvelope, + result chan error, +) error { s.streamsMu.Lock() closed := s.closed s.streamsMu.Unlock() @@ -49,9 +102,15 @@ func (s *AgentSession) SendToAgent(env *gatewayv1.GatewayEnvelope) error { } select { + case <-ctx.Done(): + return ctx.Err() case <-s.done: return ErrAgentOffline - case s.toAgent <- env: + case s.toAgent <- &OutboundEnvelope{ + GatewayEnvelope: env, + ctx: ctx, + result: result, + }: return nil } } @@ -73,7 +132,7 @@ func (s *AgentSession) TrySendToAgent(env *gatewayv1.GatewayEnvelope) (bool, err select { case <-s.done: return false, ErrAgentOffline - case s.toAgent <- env: + case s.toAgent <- &OutboundEnvelope{GatewayEnvelope: env}: return true, nil default: return false, nil diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index c46bf6b1a..b40db5b98 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -10,6 +10,10 @@ import ( var ErrAgentOffline = errors.New("agent offline") var ErrChatRunNotFound = errors.New("chat run not found") +var ErrTunnelNotFound = errors.New("tunnel not found") +var ErrTunnelExpired = errors.New("tunnel expired") +var ErrTunnelOverLimit = errors.New("tunnel connection limit exceeded") +var ErrTunnelLimitExceeded = errors.New("tunnel limit exceeded") const ( maxBufferedChatRunEvents = 50000 @@ -29,6 +33,7 @@ type Manager struct { registry *sessionRegistry syncHub *syncHub chatStore *chatRunStore + tunnels *tunnelStore } type AgentSession struct { @@ -38,7 +43,7 @@ type AgentSession struct { ConnectedAt time.Time LastPing time.Time - toAgent chan *gatewayv1.GatewayEnvelope + toAgent chan *OutboundEnvelope done chan struct{} closeOnce sync.Once @@ -117,5 +122,6 @@ func NewManager() *Manager { registry: newSessionRegistry(), syncHub: newSyncHub(), chatStore: newChatRunStore(), + tunnels: newTunnelStore(), } } diff --git a/crates/agent-gateway/internal/session/manager_chat_runs.go b/crates/agent-gateway/internal/session/manager_chat_runs.go index ef8eb3e35..53cf22653 100644 --- a/crates/agent-gateway/internal/session/manager_chat_runs.go +++ b/crates/agent-gateway/internal/session/manager_chat_runs.go @@ -491,6 +491,16 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent return } + if tunnelFrame := env.GetTunnelFrame(); tunnelFrame != nil { + m.dispatchTunnelFrame(tunnelFrame) + return + } + + if tunnelControl := env.GetTunnelControl(); tunnelControl != nil { + m.handleAgentTunnelControl(session, env.GetRequestId(), tunnelControl) + return + } + session.dispatch(env) } diff --git a/crates/agent-gateway/internal/session/manager_registry.go b/crates/agent-gateway/internal/session/manager_registry.go index 09609a19c..925bc2838 100644 --- a/crates/agent-gateway/internal/session/manager_registry.go +++ b/crates/agent-gateway/internal/session/manager_registry.go @@ -1,6 +1,7 @@ package session import ( + "context" "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" @@ -114,6 +115,17 @@ func (m *Manager) SendToAgent(env *gatewayv1.GatewayEnvelope) error { return session.SendToAgent(env) } +func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { + m.registry.mu.RLock() + session := m.registry.session + m.registry.mu.RUnlock() + if session == nil { + return ErrAgentOffline + } + + return session.SendToAgentContext(ctx, env) +} + func (m *Manager) currentSessionEpoch() uint64 { m.registry.mu.RLock() defer m.registry.mu.RUnlock() diff --git a/crates/agent-gateway/internal/session/manager_tunnel.go b/crates/agent-gateway/internal/session/manager_tunnel.go new file mode 100644 index 000000000..aaf2c1712 --- /dev/null +++ b/crates/agent-gateway/internal/session/manager_tunnel.go @@ -0,0 +1,843 @@ +package session + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const ( + MaxTunnelsPerAgent = 5 + MaxTunnelConnections = 20 + defaultTunnelTTLSeconds = 3600 + tunnelSlugEntropyBytes = 24 + tunnelStreamChannelDepth = 256 + tunnelAgentSendTimeout = 10 * time.Second +) + +type tunnelStore struct { + mu sync.Mutex + tunnelsByID map[string]*tunnelRecord + tunnelIDBySlug map[string]string + streams map[string]*tunnelStream +} + +type tunnelRecord struct { + id string + slug string + name string + targetURL string + publicURL string + projectPathKey string + createdAt time.Time + expiresAt time.Time + activeConnections int + closed bool +} + +type tunnelStream struct { + streamID string + tunnelID string + ch chan *gatewayv1.TunnelFrame + done chan struct{} + once sync.Once +} + +type TunnelStreamLease struct { + manager *Manager + stream *tunnelStream + tunnel *gatewayv1.TunnelSummary + once sync.Once +} + +func newTunnelStore() *tunnelStore { + return &tunnelStore{ + tunnelsByID: make(map[string]*tunnelRecord), + tunnelIDBySlug: make(map[string]string), + streams: make(map[string]*tunnelStream), + } +} + +func (l *TunnelStreamLease) Tunnel() *gatewayv1.TunnelSummary { + if l == nil || l.tunnel == nil { + return nil + } + return cloneTunnelSummary(l.tunnel) +} + +func (l *TunnelStreamLease) TunnelID() string { + if l == nil || l.stream == nil { + return "" + } + return l.stream.tunnelID +} + +func (l *TunnelStreamLease) StreamID() string { + if l == nil || l.stream == nil { + return "" + } + return l.stream.streamID +} + +func (l *TunnelStreamLease) Frames() <-chan *gatewayv1.TunnelFrame { + if l == nil || l.stream == nil { + return nil + } + return l.stream.ch +} + +func (l *TunnelStreamLease) Done() <-chan struct{} { + if l == nil || l.stream == nil { + return nil + } + return l.stream.done +} + +func (l *TunnelStreamLease) Release() { + if l == nil { + return + } + l.once.Do(func() { + l.manager.releaseTunnelStream(l.stream) + }) +} + +func (s *tunnelStream) close() { + if s == nil { + return + } + s.once.Do(func() { + close(s.done) + }) +} + +func (s *tunnelStream) send(frame *gatewayv1.TunnelFrame) bool { + select { + case <-s.done: + return false + case s.ch <- frame: + return true + } +} + +func (m *Manager) WebTunnelsEnabled() bool { + m.syncHub.settingsSnapshotMu.RLock() + defer m.syncHub.settingsSnapshotMu.RUnlock() + + remote, ok := m.syncHub.settingsSnapshot["remote"].(map[string]any) + if !ok { + return false + } + enabled, ok := remote["enableWebTunnels"].(bool) + return ok && enabled +} + +func (m *Manager) ListTunnels() []*gatewayv1.TunnelSummary { + now := time.Now() + online := m.IsOnline() + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + summaries := make([]*gatewayv1.TunnelSummary, 0, len(m.tunnels.tunnelsByID)) + for _, record := range m.tunnels.tunnelsByID { + if record == nil || record.closed { + continue + } + summaries = append(summaries, tunnelSummaryLocked(record, now, online)) + } + sortTunnelSummaries(summaries) + return summaries +} + +func (m *Manager) PrepareTunnelCreate( + input *gatewayv1.TunnelControlRequest, + publicBaseURL string, +) (*gatewayv1.TunnelControlRequest, error) { + if input == nil { + return nil, errors.New("tunnel create input is required") + } + ttlSeconds, err := normalizeTunnelTTL(input.GetTtlSeconds()) + if err != nil { + return nil, err + } + now := time.Now() + var expiresAt time.Time + if ttlSeconds > 0 { + expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) + } + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + activeCount := 0 + for _, record := range m.tunnels.tunnelsByID { + if record == nil || record.closed || isTunnelExpired(record, now) { + continue + } + activeCount += 1 + } + if activeCount >= MaxTunnelsPerAgent { + return nil, ErrTunnelLimitExceeded + } + + id := strings.TrimSpace(input.GetTunnelId()) + if id == "" { + id = generateTunnelID() + } + if _, exists := m.tunnels.tunnelsByID[id]; exists { + return nil, fmt.Errorf("tunnel id already exists") + } + slug := strings.TrimSpace(input.GetSlug()) + if slug == "" { + for { + generated, err := generateTunnelSlug() + if err != nil { + return nil, err + } + if _, exists := m.tunnels.tunnelIDBySlug[generated]; !exists { + slug = generated + break + } + } + } else if _, exists := m.tunnels.tunnelIDBySlug[slug]; exists { + return nil, fmt.Errorf("tunnel slug already exists") + } + + publicURL := normalizeTunnelPublicURL(input.GetPublicUrl()) + if publicURL == "" { + publicURL = buildTunnelPublicURL(publicBaseURL, slug) + } + + return &gatewayv1.TunnelControlRequest{ + Action: strings.TrimSpace(input.GetAction()), + TunnelId: id, + Slug: slug, + TargetUrl: strings.TrimSpace(input.GetTargetUrl()), + Name: strings.TrimSpace(input.GetName()), + TtlSeconds: ttlSeconds, + ExpiresAt: tunnelUnix(expiresAt), + PublicUrl: publicURL, + PublicBaseUrl: strings.TrimSpace(publicBaseURL), + ProjectPathKey: strings.TrimSpace(input.GetProjectPathKey()), + }, nil +} + +func (m *Manager) PrepareTunnelUpdate( + input *gatewayv1.TunnelControlRequest, +) (*gatewayv1.TunnelControlRequest, error) { + if input == nil { + return nil, errors.New("tunnel update input is required") + } + ttlSeconds, err := normalizeTunnelTTL(input.GetTtlSeconds()) + if err != nil { + return nil, err + } + now := time.Now() + var expiresAt time.Time + if input.GetExpiresAt() > 0 { + expiresAt = time.Unix(input.GetExpiresAt(), 0) + } else if ttlSeconds > 0 { + expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) + } + targetURL := strings.TrimSpace(input.GetTargetUrl()) + if targetURL == "" { + return nil, errors.New("target_url is required") + } + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + identifier := strings.TrimSpace(input.GetTunnelId()) + if identifier == "" { + identifier = strings.TrimSpace(input.GetSlug()) + } + if identifier == "" { + return nil, ErrTunnelNotFound + } + tunnelID := identifier + if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { + tunnelID = bySlug + } + record := m.tunnels.tunnelsByID[tunnelID] + if record == nil || record.closed { + return nil, ErrTunnelNotFound + } + if isTunnelExpired(record, now) { + return nil, ErrTunnelExpired + } + projectPathKey := strings.TrimSpace(input.GetProjectPathKey()) + if projectPathKey == "" { + projectPathKey = record.projectPathKey + } + + return &gatewayv1.TunnelControlRequest{ + Action: strings.TrimSpace(input.GetAction()), + TunnelId: record.id, + Slug: record.slug, + TargetUrl: targetURL, + Name: strings.TrimSpace(input.GetName()), + TtlSeconds: ttlSeconds, + ExpiresAt: tunnelUnix(expiresAt), + PublicUrl: record.publicURL, + ProjectPathKey: projectPathKey, + }, nil +} + +func (m *Manager) StorePreparedTunnel( + prepared *gatewayv1.TunnelControlRequest, + targetURLOverride string, +) (*gatewayv1.TunnelSummary, error) { + if prepared == nil { + return nil, errors.New("prepared tunnel is required") + } + now := time.Now() + targetURL := strings.TrimSpace(targetURLOverride) + if targetURL == "" { + targetURL = strings.TrimSpace(prepared.GetTargetUrl()) + } + if targetURL == "" { + return nil, errors.New("target_url is required") + } + var expiresAt time.Time + if prepared.GetExpiresAt() > 0 { + expiresAt = time.Unix(prepared.GetExpiresAt(), 0) + } else if prepared.GetTtlSeconds() > 0 { + ttlSeconds, err := normalizeTunnelTTL(prepared.GetTtlSeconds()) + if err != nil { + return nil, err + } + expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) + } + record := &tunnelRecord{ + id: strings.TrimSpace(prepared.GetTunnelId()), + slug: strings.TrimSpace(prepared.GetSlug()), + name: strings.TrimSpace(prepared.GetName()), + targetURL: targetURL, + publicURL: normalizeTunnelPublicURL(prepared.GetPublicUrl()), + projectPathKey: strings.TrimSpace(prepared.GetProjectPathKey()), + createdAt: now, + expiresAt: expiresAt, + } + if record.id == "" || record.slug == "" { + return nil, errors.New("prepared tunnel is missing id or slug") + } + if record.publicURL == "" { + record.publicURL = buildTunnelPublicURL(prepared.GetPublicBaseUrl(), record.slug) + } + + online := m.IsOnline() + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + if _, exists := m.tunnels.tunnelsByID[record.id]; exists { + return nil, fmt.Errorf("tunnel id already exists") + } + if _, exists := m.tunnels.tunnelIDBySlug[record.slug]; exists { + return nil, fmt.Errorf("tunnel slug already exists") + } + m.tunnels.tunnelsByID[record.id] = record + m.tunnels.tunnelIDBySlug[record.slug] = record.id + return tunnelSummaryLocked(record, now, online), nil +} + +func (m *Manager) CreateTunnelFromAgent( + input *gatewayv1.TunnelControlRequest, +) (*gatewayv1.TunnelSummary, error) { + prepared, err := m.PrepareTunnelCreate(input, input.GetPublicBaseUrl()) + if err != nil { + return nil, err + } + return m.StorePreparedTunnel(prepared, input.GetTargetUrl()) +} + +func (m *Manager) UpdateTunnelFromAgent( + input *gatewayv1.TunnelControlRequest, +) (*gatewayv1.TunnelSummary, error) { + prepared, err := m.PrepareTunnelUpdate(input) + if err != nil { + return nil, err + } + return m.ApplyTunnelUpdate(&gatewayv1.TunnelSummary{ + Id: prepared.GetTunnelId(), + Slug: prepared.GetSlug(), + Name: prepared.GetName(), + TargetUrl: prepared.GetTargetUrl(), + PublicUrl: prepared.GetPublicUrl(), + ExpiresAt: prepared.GetExpiresAt(), + ProjectPathKey: prepared.GetProjectPathKey(), + }) +} + +func (m *Manager) ApplyTunnelUpdate(summary *gatewayv1.TunnelSummary) (*gatewayv1.TunnelSummary, error) { + if summary == nil { + return nil, errors.New("tunnel update summary is required") + } + identifier := strings.TrimSpace(summary.GetId()) + if identifier == "" { + identifier = strings.TrimSpace(summary.GetSlug()) + } + if identifier == "" { + return nil, ErrTunnelNotFound + } + targetURL := strings.TrimSpace(summary.GetTargetUrl()) + if targetURL == "" { + return nil, errors.New("target_url is required") + } + now := time.Now() + online := m.IsOnline() + var expiresAt time.Time + if summary.GetExpiresAt() > 0 { + expiresAt = time.Unix(summary.GetExpiresAt(), 0) + } + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + tunnelID := identifier + if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { + tunnelID = bySlug + } + record := m.tunnels.tunnelsByID[tunnelID] + if record == nil || record.closed { + return nil, ErrTunnelNotFound + } + if isTunnelExpired(record, now) { + return nil, ErrTunnelExpired + } + record.name = strings.TrimSpace(summary.GetName()) + record.targetURL = targetURL + if publicURL := normalizeTunnelPublicURL(summary.GetPublicUrl()); publicURL != "" { + record.publicURL = publicURL + } + record.projectPathKey = strings.TrimSpace(summary.GetProjectPathKey()) + record.expiresAt = expiresAt + return tunnelSummaryLocked(record, now, online), nil +} + +func (m *Manager) AcquireTunnel(slug string, streamID string) (*TunnelStreamLease, error) { + slug = strings.TrimSpace(slug) + streamID = strings.TrimSpace(streamID) + if slug == "" || streamID == "" { + return nil, ErrTunnelNotFound + } + if !m.IsOnline() { + return nil, ErrAgentOffline + } + now := time.Now() + online := true + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + tunnelID := m.tunnels.tunnelIDBySlug[slug] + record := m.tunnels.tunnelsByID[tunnelID] + if record == nil || record.closed { + return nil, ErrTunnelNotFound + } + if isTunnelExpired(record, now) { + return nil, ErrTunnelExpired + } + if record.activeConnections >= MaxTunnelConnections { + return nil, ErrTunnelOverLimit + } + stream := &tunnelStream{ + streamID: streamID, + tunnelID: record.id, + ch: make(chan *gatewayv1.TunnelFrame, tunnelStreamChannelDepth), + done: make(chan struct{}), + } + if existing := m.tunnels.streams[streamID]; existing != nil { + existing.close() + } + m.tunnels.streams[streamID] = stream + record.activeConnections += 1 + + return &TunnelStreamLease{ + manager: m, + stream: stream, + tunnel: tunnelSummaryLocked(record, now, online), + }, nil +} + +func (m *Manager) CloseTunnel(identifier string) (*gatewayv1.TunnelSummary, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return nil, ErrTunnelNotFound + } + now := time.Now() + online := m.IsOnline() + + var summary *gatewayv1.TunnelSummary + var cancelFrames []*gatewayv1.TunnelFrame + m.tunnels.mu.Lock() + tunnelID := identifier + if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { + tunnelID = bySlug + } + record := m.tunnels.tunnelsByID[tunnelID] + if record == nil || record.closed { + m.tunnels.mu.Unlock() + return nil, ErrTunnelNotFound + } + record.closed = true + summary = tunnelSummaryLocked(record, now, online) + delete(m.tunnels.tunnelsByID, record.id) + delete(m.tunnels.tunnelIDBySlug, record.slug) + for streamID, stream := range m.tunnels.streams { + if stream == nil || stream.tunnelID != record.id { + continue + } + delete(m.tunnels.streams, streamID) + stream.close() + cancelFrames = append(cancelFrames, &gatewayv1.TunnelFrame{ + StreamId: stream.streamID, + TunnelId: record.id, + Slug: record.slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + }) + } + m.tunnels.mu.Unlock() + + for _, frame := range cancelFrames { + _ = m.SendTunnelFrameToAgent(frame) + } + return summary, nil +} + +func (m *Manager) ResumeTunnel(input *gatewayv1.TunnelControlRequest) (*gatewayv1.TunnelSummary, error) { + if input == nil { + return nil, errors.New("resume tunnel input is required") + } + now := time.Now() + online := m.IsOnline() + id := strings.TrimSpace(input.GetTunnelId()) + slug := strings.TrimSpace(input.GetSlug()) + if id == "" && slug == "" { + return nil, ErrTunnelNotFound + } + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + if id == "" { + id = m.tunnels.tunnelIDBySlug[slug] + } + record := m.tunnels.tunnelsByID[id] + if record == nil || record.closed { + return nil, ErrTunnelNotFound + } + if slug != "" && record.slug != slug { + return nil, ErrTunnelNotFound + } + if isTunnelExpired(record, now) { + return nil, ErrTunnelExpired + } + if targetURL := strings.TrimSpace(input.GetTargetUrl()); targetURL != "" { + record.targetURL = targetURL + } + if name := strings.TrimSpace(input.GetName()); name != "" { + record.name = name + } + if projectPathKey := strings.TrimSpace(input.GetProjectPathKey()); projectPathKey != "" { + record.projectPathKey = projectPathKey + } + return tunnelSummaryLocked(record, now, online), nil +} + +func (m *Manager) SendTunnelFrameToAgent(frame *gatewayv1.TunnelFrame) error { + if frame == nil { + return errors.New("tunnel frame is required") + } + ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) + defer cancel() + return m.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: fmt.Sprintf("tunnel-frame-%s", strings.TrimSpace(frame.GetStreamId())), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelFrame{ + TunnelFrame: frame, + }, + }) +} + +func (m *Manager) dispatchTunnelFrame(frame *gatewayv1.TunnelFrame) { + if frame == nil { + return + } + streamID := strings.TrimSpace(frame.GetStreamId()) + if streamID == "" { + return + } + m.tunnels.mu.Lock() + stream := m.tunnels.streams[streamID] + m.tunnels.mu.Unlock() + if stream == nil { + return + } + stream.send(frame) +} + +func (m *Manager) handleAgentTunnelControl( + session *AgentSession, + requestID string, + request *gatewayv1.TunnelControlRequest, +) { + if session == nil || request == nil { + return + } + response := m.handleAgentTunnelControlInner(request) + ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) + defer cancel() + _ = session.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: requestID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelControlResp{ + TunnelControlResp: response, + }, + }) +} + +func (m *Manager) handleAgentTunnelControlInner( + request *gatewayv1.TunnelControlRequest, +) *gatewayv1.TunnelControlResponse { + action := strings.ToLower(strings.TrimSpace(request.GetAction())) + if action == "" { + return tunnelControlError("invalid_action", "tunnel action is required") + } + switch action { + case "list": + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnels: m.ListTunnels(), + } + case "create": + tunnel, err := m.CreateTunnelFromAgent(request) + if err != nil { + return tunnelControlErrorFor(action, err) + } + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnel: tunnel, + Tunnels: m.ListTunnels(), + } + case "update": + tunnel, err := m.UpdateTunnelFromAgent(request) + if err != nil { + return tunnelControlErrorFor(action, err) + } + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnel: tunnel, + Tunnels: m.ListTunnels(), + } + case "close": + identifier := request.GetTunnelId() + if strings.TrimSpace(identifier) == "" { + identifier = request.GetSlug() + } + tunnel, err := m.CloseTunnel(identifier) + if err != nil { + return tunnelControlErrorFor(action, err) + } + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnel: tunnel, + Tunnels: m.ListTunnels(), + } + case "resume": + tunnel, err := m.ResumeTunnel(request) + if err != nil { + return tunnelControlErrorFor(action, err) + } + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnel: tunnel, + Tunnels: m.ListTunnels(), + } + default: + return tunnelControlError("invalid_action", "unsupported tunnel action") + } +} + +func (m *Manager) releaseTunnelStream(stream *tunnelStream) { + if stream == nil { + return + } + m.tunnels.mu.Lock() + if existing := m.tunnels.streams[stream.streamID]; existing == stream { + delete(m.tunnels.streams, stream.streamID) + } + if record := m.tunnels.tunnelsByID[stream.tunnelID]; record != nil && record.activeConnections > 0 { + record.activeConnections -= 1 + } + stream.close() + m.tunnels.mu.Unlock() +} + +func normalizeTunnelTTL(input uint32) (uint32, error) { + switch input { + case 0: + return 0, nil + case 900, 3600, 14400: + return input, nil + default: + return 0, errors.New("ttl_seconds must be one of 0, 900, 3600, or 14400") + } +} + +func tunnelUnix(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.Unix() +} + +func generateTunnelID() string { + return "tun_" + strings.ReplaceAll(time.Now().UTC().Format("20060102150405.000000000"), ".", "") + "_" + randomURLToken(8) +} + +func generateTunnelSlug() (string, error) { + token := randomURLToken(tunnelSlugEntropyBytes) + if token == "" { + return "", errors.New("generate tunnel slug failed") + } + return token, nil +} + +func randomURLToken(byteCount int) string { + if byteCount <= 0 { + return "" + } + buf := make([]byte, byteCount) + if _, err := rand.Read(buf); err != nil { + return "" + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +func normalizeTunnelPublicURL(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "" + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + parsed.RawQuery = "" + parsed.Fragment = "" + if !strings.HasSuffix(parsed.Path, "/") { + parsed.Path += "/" + } + return parsed.String() +} + +func buildTunnelPublicURL(publicBaseURL string, slug string) string { + base := strings.TrimSpace(publicBaseURL) + if base == "" || strings.TrimSpace(slug) == "" { + return "" + } + parsed, err := url.Parse(base) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/t/" + strings.TrimSpace(slug) + "/" + return parsed.String() +} + +func isTunnelExpired(record *tunnelRecord, now time.Time) bool { + return record == nil || (!record.expiresAt.IsZero() && !record.expiresAt.After(now)) +} + +func tunnelSummaryLocked(record *tunnelRecord, now time.Time, online bool) *gatewayv1.TunnelSummary { + if record == nil { + return &gatewayv1.TunnelSummary{Status: "expired"} + } + status := "active" + if record.closed || isTunnelExpired(record, now) { + status = "expired" + } else if !online { + status = "offline" + } + activeConnections := uint32(0) + if record.activeConnections > 0 { + activeConnections = uint32(record.activeConnections) + } + return &gatewayv1.TunnelSummary{ + Id: record.id, + Slug: record.slug, + Name: record.name, + TargetUrl: record.targetURL, + PublicUrl: record.publicURL, + CreatedAt: record.createdAt.Unix(), + ExpiresAt: tunnelUnix(record.expiresAt), + ActiveConnections: activeConnections, + Status: status, + ProjectPathKey: record.projectPathKey, + } +} + +func cloneTunnelSummary(summary *gatewayv1.TunnelSummary) *gatewayv1.TunnelSummary { + if summary == nil { + return nil + } + return &gatewayv1.TunnelSummary{ + Id: summary.GetId(), + Slug: summary.GetSlug(), + Name: summary.GetName(), + TargetUrl: summary.GetTargetUrl(), + PublicUrl: summary.GetPublicUrl(), + CreatedAt: summary.GetCreatedAt(), + ExpiresAt: summary.GetExpiresAt(), + ActiveConnections: summary.GetActiveConnections(), + Status: summary.GetStatus(), + ProjectPathKey: strings.TrimSpace(summary.GetProjectPathKey()), + } +} + +func sortTunnelSummaries(summaries []*gatewayv1.TunnelSummary) { + for i := 1; i < len(summaries); i++ { + current := summaries[i] + j := i - 1 + for j >= 0 && summaries[j].GetCreatedAt() > current.GetCreatedAt() { + summaries[j+1] = summaries[j] + j-- + } + summaries[j+1] = current + } +} + +func tunnelControlError(code string, message string) *gatewayv1.TunnelControlResponse { + return &gatewayv1.TunnelControlResponse{ + ErrorCode: strings.TrimSpace(code), + ErrorMessage: strings.TrimSpace(message), + } +} + +func tunnelControlErrorFor(action string, err error) *gatewayv1.TunnelControlResponse { + code := "failed" + switch { + case errors.Is(err, ErrTunnelNotFound): + code = "not_found" + case errors.Is(err, ErrTunnelExpired): + code = "expired" + case errors.Is(err, ErrTunnelLimitExceeded): + code = "limit_exceeded" + case errors.Is(err, ErrTunnelOverLimit): + code = "over_limit" + case errors.Is(err, ErrAgentOffline): + code = "agent_offline" + } + return &gatewayv1.TunnelControlResponse{ + Action: strings.TrimSpace(action), + ErrorCode: code, + ErrorMessage: err.Error(), + } +} diff --git a/crates/agent-gateway/internal/session/manager_tunnel_test.go b/crates/agent-gateway/internal/session/manager_tunnel_test.go new file mode 100644 index 000000000..1e147a0b3 --- /dev/null +++ b/crates/agent-gateway/internal/session/manager_tunnel_test.go @@ -0,0 +1,265 @@ +package session + +import ( + "errors" + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func onlineTunnelTestManager() *Manager { + manager := NewManager() + manager.SetSession(NewAgentSession(AuthSnapshot{ + AgentID: "agent-a", + AgentVersion: "test", + SessionID: "session-a", + })) + return manager +} + +func createTestTunnel(t *testing.T, manager *Manager, name string) *gatewayv1.TunnelSummary { + t.Helper() + tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: "http://localhost:3000/app", + Name: name, + TtlSeconds: 3600, + PublicBaseUrl: "https://gateway.example", + }) + if err != nil { + t.Fatalf("CreateTunnelFromAgent: %v", err) + } + if tunnel.GetSlug() == "" || tunnel.GetPublicUrl() == "" { + t.Fatalf("created tunnel missing slug/public URL: %+v", tunnel) + } + return tunnel +} + +func TestTunnelRegistryCreateLimitListAndClose(t *testing.T) { + manager := onlineTunnelTestManager() + + var first *gatewayv1.TunnelSummary + for i := 0; i < MaxTunnelsPerAgent; i++ { + tunnel := createTestTunnel(t, manager, "app") + if i == 0 { + first = tunnel + } + } + + if _, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: "http://localhost:3001", + TtlSeconds: 3600, + PublicBaseUrl: "https://gateway.example", + }); !errors.Is(err, ErrTunnelLimitExceeded) { + t.Fatalf("expected ErrTunnelLimitExceeded, got %v", err) + } + + if got := len(manager.ListTunnels()); got != MaxTunnelsPerAgent { + t.Fatalf("ListTunnels returned %d tunnels, want %d", got, MaxTunnelsPerAgent) + } + + closed, err := manager.CloseTunnel(first.GetId()) + if err != nil { + t.Fatalf("CloseTunnel: %v", err) + } + if closed.GetStatus() != "expired" { + t.Fatalf("closed tunnel summary status = %q, want expired", closed.GetStatus()) + } + if got := len(manager.ListTunnels()); got != MaxTunnelsPerAgent-1 { + t.Fatalf("ListTunnels after close returned %d tunnels, want %d", got, MaxTunnelsPerAgent-1) + } +} + +func TestTunnelAcquireConnectionLimitAndRelease(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel := createTestTunnel(t, manager, "app") + + leases := make([]*TunnelStreamLease, 0, MaxTunnelConnections) + for i := 0; i < MaxTunnelConnections; i++ { + lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-"+string(rune('a'+i))) + if err != nil { + t.Fatalf("AcquireTunnel %d: %v", i, err) + } + leases = append(leases, lease) + } + if _, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-over-limit"); !errors.Is(err, ErrTunnelOverLimit) { + t.Fatalf("expected ErrTunnelOverLimit, got %v", err) + } + + leases[0].Release() + lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-after-release") + if err != nil { + t.Fatalf("AcquireTunnel after release: %v", err) + } + lease.Release() + for _, item := range leases[1:] { + item.Release() + } + + summaries := manager.ListTunnels() + if len(summaries) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) + } + if got := summaries[0].GetActiveConnections(); got != 0 { + t.Fatalf("active connections after release = %d, want 0", got) + } +} + +func TestTunnelExpiredCannotBeAcquired(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel := createTestTunnel(t, manager, "app") + + manager.tunnels.mu.Lock() + manager.tunnels.tunnelsByID[tunnel.GetId()].expiresAt = time.Now().Add(-time.Second) + manager.tunnels.mu.Unlock() + + if _, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-expired"); !errors.Is(err, ErrTunnelExpired) { + t.Fatalf("expected ErrTunnelExpired, got %v", err) + } + summaries := manager.ListTunnels() + if len(summaries) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) + } + if summaries[0].GetStatus() != "expired" { + t.Fatalf("expired tunnel status = %q, want expired", summaries[0].GetStatus()) + } +} + +func TestTunnelInfiniteTTLCreatesNonExpiringTunnel(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: "http://localhost:3000/app", + Name: "app", + TtlSeconds: 0, + PublicBaseUrl: "https://gateway.example", + }) + if err != nil { + t.Fatalf("CreateTunnelFromAgent with infinite TTL: %v", err) + } + if tunnel.GetExpiresAt() != 0 { + t.Fatalf("infinite tunnel expiresAt = %d, want 0", tunnel.GetExpiresAt()) + } + if tunnel.GetStatus() != "active" { + t.Fatalf("infinite tunnel status = %q, want active", tunnel.GetStatus()) + } + + manager.tunnels.mu.Lock() + manager.tunnels.tunnelsByID[tunnel.GetId()].expiresAt = time.Time{} + manager.tunnels.mu.Unlock() + + lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-infinite") + if err != nil { + t.Fatalf("AcquireTunnel for infinite tunnel: %v", err) + } + lease.Release() +} + +func TestTunnelUpdateChangesTargetNameScopeAndTTL(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel := createTestTunnel(t, manager, "app") + + updated, err := manager.UpdateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "update", + TunnelId: tunnel.GetId(), + TargetUrl: "http://127.0.0.1:4000/dashboard", + Name: "dashboard", + TtlSeconds: 0, + ProjectPathKey: "project:/tmp/liveagent", + }) + if err != nil { + t.Fatalf("UpdateTunnelFromAgent: %v", err) + } + if updated.GetName() != "dashboard" { + t.Fatalf("updated name = %q, want dashboard", updated.GetName()) + } + if updated.GetTargetUrl() != "http://127.0.0.1:4000/dashboard" { + t.Fatalf("updated target = %q", updated.GetTargetUrl()) + } + if updated.GetExpiresAt() != 0 { + t.Fatalf("updated expiresAt = %d, want 0", updated.GetExpiresAt()) + } + if updated.GetProjectPathKey() != "project:/tmp/liveagent" { + t.Fatalf("updated projectPathKey = %q", updated.GetProjectPathKey()) + } + + listed := manager.ListTunnels() + if len(listed) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(listed)) + } + if listed[0].GetId() != tunnel.GetId() || listed[0].GetTargetUrl() != updated.GetTargetUrl() { + t.Fatalf("ListTunnels did not include updated tunnel: %+v", listed[0]) + } +} + +func TestTunnelInfiniteTTLStaysActiveAndVisible(t *testing.T) { + manager := onlineTunnelTestManager() + + tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: "http://localhost:3000/app", + Name: "app", + TtlSeconds: 0, + PublicBaseUrl: "https://gateway.example", + ProjectPathKey: "/workspace/app", + }) + if err != nil { + t.Fatalf("CreateTunnelFromAgent: %v", err) + } + if tunnel.GetExpiresAt() != 0 { + t.Fatalf("infinite tunnel expires_at = %d, want 0", tunnel.GetExpiresAt()) + } + if tunnel.GetProjectPathKey() != "/workspace/app" { + t.Fatalf("project_path_key = %q, want /workspace/app", tunnel.GetProjectPathKey()) + } + + summaries := manager.ListTunnels() + if len(summaries) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) + } + if summaries[0].GetStatus() != "active" { + t.Fatalf("infinite tunnel status = %q, want active", summaries[0].GetStatus()) + } + if summaries[0].GetExpiresAt() != 0 { + t.Fatalf("listed infinite tunnel expires_at = %d, want 0", summaries[0].GetExpiresAt()) + } +} + +func TestTunnelUpdateChangesTargetNameTTLAndKeepsProjectScope(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel := createTestTunnel(t, manager, "app") + + updated, err := manager.UpdateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "update", + TunnelId: tunnel.GetId(), + TargetUrl: "http://localhost:3000/next", + Name: "next", + TtlSeconds: 0, + ProjectPathKey: "/workspace/app", + }) + if err != nil { + t.Fatalf("UpdateTunnelFromAgent: %v", err) + } + if updated.GetTargetUrl() != "http://localhost:3000/next" { + t.Fatalf("target_url = %q, want http://localhost:3000/next", updated.GetTargetUrl()) + } + if updated.GetName() != "next" { + t.Fatalf("name = %q, want next", updated.GetName()) + } + if updated.GetExpiresAt() != 0 { + t.Fatalf("updated expires_at = %d, want 0", updated.GetExpiresAt()) + } + if updated.GetProjectPathKey() != "/workspace/app" { + t.Fatalf("project_path_key = %q, want /workspace/app", updated.GetProjectPathKey()) + } + + listed := manager.ListTunnels() + if len(listed) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(listed)) + } + if listed[0].GetTargetUrl() != "http://localhost:3000/next" { + t.Fatalf("listed target_url = %q, want http://localhost:3000/next", listed[0].GetTargetUrl()) + } +} diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index 94a0df3c6..e187c39be 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -63,6 +63,9 @@ message GatewayEnvelope { GitRequest git_request = 61; FsReadEditableTextRequest fs_read_editable_text = 62; FsReadWorkspaceImageRequest fs_read_workspace_image = 63; + TunnelControlRequest tunnel_control = 67; + TunnelControlResponse tunnel_control_resp = 68; + TunnelFrame tunnel_frame = 69; } } @@ -110,6 +113,9 @@ message AgentEnvelope { GitResponse git_response = 64; FsReadEditableTextResponse fs_read_editable_text_resp = 65; FsReadWorkspaceImageResponse fs_read_workspace_image_resp = 66; + TunnelControlRequest tunnel_control = 67; + TunnelControlResponse tunnel_control_resp = 68; + TunnelFrame tunnel_frame = 69; ErrorResponse error = 99; } } @@ -160,6 +166,75 @@ message UploadedImagePreviewResponse { string data = 2; } +message TunnelControlRequest { + string action = 1; + string tunnel_id = 2; + string slug = 3; + string target_url = 4; + string name = 5; + uint32 ttl_seconds = 6; + int64 expires_at = 7; + string public_url = 8; + string public_base_url = 9; + string project_path_key = 10; +} + +message TunnelControlResponse { + string action = 1; + repeated TunnelSummary tunnels = 2; + TunnelSummary tunnel = 3; + string error_code = 4; + string error_message = 5; +} + +message TunnelSummary { + string id = 1; + string slug = 2; + string name = 3; + string target_url = 4; + string public_url = 5; + int64 created_at = 6; + int64 expires_at = 7; + uint32 active_connections = 8; + string status = 9; + string project_path_key = 10; +} + +message TunnelHeader { + string name = 1; + string value = 2; +} + +enum TunnelFrameKind { + TUNNEL_FRAME_KIND_UNSPECIFIED = 0; + TUNNEL_FRAME_KIND_HTTP_REQUEST_START = 1; + TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY = 2; + TUNNEL_FRAME_KIND_HTTP_REQUEST_END = 3; + TUNNEL_FRAME_KIND_HTTP_RESPONSE_START = 4; + TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY = 5; + TUNNEL_FRAME_KIND_HTTP_RESPONSE_END = 6; + TUNNEL_FRAME_KIND_WS_OPEN = 7; + TUNNEL_FRAME_KIND_WS_FRAME = 8; + TUNNEL_FRAME_KIND_WS_CLOSE = 9; + TUNNEL_FRAME_KIND_ERROR = 10; + TUNNEL_FRAME_KIND_CANCEL = 11; +} + +message TunnelFrame { + string stream_id = 1; + string tunnel_id = 2; + string slug = 3; + TunnelFrameKind kind = 4; + string method = 5; + string path = 6; + repeated TunnelHeader headers = 7; + uint32 status_code = 8; + bytes body = 9; + bool end_stream = 10; + string error = 11; + string ws_message_type = 12; +} + message MemoryManageRequest { string command = 1; string args_json = 2; diff --git a/crates/agent-gateway/test/session/manager_test.go b/crates/agent-gateway/test/session/manager_test.go index 53d5e645c..4def205b9 100644 --- a/crates/agent-gateway/test/session/manager_test.go +++ b/crates/agent-gateway/test/session/manager_test.go @@ -1,6 +1,7 @@ package session_test import ( + "context" "errors" "fmt" "strings" @@ -158,6 +159,28 @@ func TestSendToAgentUnblocksWhenSessionCloses(t *testing.T) { } } +func TestSendToAgentContextReturnsWhenOutboundQueueIsFull(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sess := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(sess) + + for i := 0; i < 64; i += 1 { + if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { + t.Fatalf("prime outbound queue: %v", err) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + err := sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{RequestId: "blocked"}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("SendToAgentContext with full queue = %v, want context deadline exceeded", err) + } +} + func TestRemoveChatRunByConversationReleasesBufferedRun(t *testing.T) { t.Parallel() diff --git a/crates/agent-gateway/test/upload/import_readable_files_test.go b/crates/agent-gateway/test/upload/import_readable_files_test.go index 7b26d803a..d25c08480 100644 --- a/crates/agent-gateway/test/upload/import_readable_files_test.go +++ b/crates/agent-gateway/test/upload/import_readable_files_test.go @@ -65,7 +65,8 @@ func TestImportReadableFilesForwardsMultipartToAgent(t *testing.T) { var outbound *gatewayv1.GatewayEnvelope select { - case outbound = <-agentSession.Outbound(): + case delivered := <-agentSession.Outbound(): + outbound = delivered.GatewayEnvelope case <-time.After(time.Second): t.Fatalf("timed out waiting for upload request to reach agent") } diff --git a/crates/agent-gateway/test/websocket/chat_bridge_test.go b/crates/agent-gateway/test/websocket/chat_bridge_test.go index 194c9c0bc..f20742cac 100644 --- a/crates/agent-gateway/test/websocket/chat_bridge_test.go +++ b/crates/agent-gateway/test/websocket/chat_bridge_test.go @@ -90,7 +90,8 @@ func readOutboundEnvelope(t *testing.T, agentSession *session.AgentSession) *gat t.Helper() select { case outbound := <-agentSession.Outbound(): - return outbound + outbound.Ack(nil) + return outbound.GatewayEnvelope case <-time.After(time.Second): t.Fatalf("timed out waiting for gateway request to reach agent") return nil @@ -184,7 +185,9 @@ func TestWebSocketChatStartForwardsNormalizedRequestAndStreamsEvents(t *testing. var outbound *gatewayv1.GatewayEnvelope select { - case outbound = <-agentSession.Outbound(): + case delivered := <-agentSession.Outbound(): + delivered.Ack(nil) + outbound = delivered.GatewayEnvelope case <-time.After(time.Second): t.Fatalf("timed out waiting for chat request to reach agent") } @@ -278,6 +281,46 @@ func TestWebSocketChatStartForwardsNormalizedRequestAndStreamsEvents(t *testing. } } +func TestWebSocketChatStartClearsRunWhenAgentDeliveryStalls(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + WebSocketWriteTimeout: 50 * time.Millisecond, + }, sm) + conn, cleanup := dialGatewayWebSocket(t, handler) + defer cleanup() + + authWebSocket(t, conn, "ws-token") + sendEnvelope(t, conn, "chat-stalled", "chat.start", map[string]any{ + "conversation_id": "conversation-stalled", + "message": "hello gateway", + }) + + select { + case outbound := <-agentSession.Outbound(): + if outbound.GetChatRequest() == nil { + t.Fatalf("outbound payload = %T, want ChatRequest", outbound.GetPayload()) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for chat request to be enqueued") + } + + env := receiveEnvelope(t, conn) + if env.ID != "chat-stalled" || env.Type != "error" || env.Error != "request timed out" { + t.Fatalf("stalled delivery response = %#v, want timeout error", env) + } + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("active chat runs after stalled delivery = %#v, want empty", got) + } +} + func TestWebSocketChatStartDedupesClientRequestID(t *testing.T) { t.Parallel() diff --git a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs index 43e511971..53f864fa9 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -1618,6 +1618,208 @@ test("Gateway SharedWorker forwards history share requests", async () => { globalThis.onconnect = previousOnConnect; }); +test("Gateway SharedWorker forwards tunnel requests", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); + const clientInstances = []; + + class MockGatewayWebSocketClient { + calls = []; + + constructor(token) { + this.token = token; + clientInstances.push(this); + } + + subscribeStatus() { + return () => {}; + } + + subscribeHistory() { + return () => {}; + } + + subscribeConversation() { + return () => {}; + } + + subscribeSettings() { + return () => {}; + } + + subscribeTerminal() { + return () => {}; + } + + listTunnels() { + this.calls.push(["listTunnels"]); + return [ + { + id: "tun-1", + slug: "slug-1", + name: "App", + targetUrl: "http://localhost:3000", + publicUrl: "https://gateway.example/t/slug-1/", + createdAt: 10, + expiresAt: 3700, + activeConnections: 0, + status: "active", + }, + ]; + } + + createTunnel(input) { + this.calls.push(["createTunnel", input]); + return { + id: "tun-2", + slug: "slug-2", + name: input.name ?? "", + targetUrl: input.targetUrl, + publicUrl: "https://gateway.example/t/slug-2/", + createdAt: 20, + expiresAt: 920, + activeConnections: 0, + status: "active", + }; + } + + updateTunnel(input) { + this.calls.push(["updateTunnel", input]); + return { + id: input.id, + slug: "slug-2", + name: input.name ?? "", + targetUrl: input.targetUrl, + publicUrl: "https://gateway.example/t/slug-2/", + createdAt: 20, + expiresAt: input.ttlSeconds === 0 ? 0 : 920, + activeConnections: 0, + status: "active", + projectPathKey: input.projectPathKey ?? "", + }; + } + + closeTunnel(id) { + this.calls.push(["closeTunnel", id]); + return { + id, + slug: "slug-2", + name: "Closed", + targetUrl: "http://localhost:3000", + publicUrl: "https://gateway.example/t/slug-2/", + createdAt: 20, + expiresAt: 920, + activeConnections: 0, + status: "expired", + }; + } + + dispose() {} + } + + const workerLoader = createWebModuleLoader({ + mocks: { + [gatewaySocketPath]: { + GatewayWebSocketClient: MockGatewayWebSocketClient, + }, + }, + }); + + const previousOnConnect = globalThis.onconnect; + workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); + + const port = new FakeMessagePort(); + globalThis.onconnect({ ports: [port] }); + port.emit({ type: "connect", connection_id: "connection-1", token: " token " }); + assert.equal(clientInstances.length, 1); + + port.emit({ + type: "request", + connection_id: "connection-1", + request_id: "tunnel-list", + method: "tunnel.list", + payload: {}, + }); + await waitFor( + () => port.messages.some((message) => message.request_id === "tunnel-list"), + "shared worker tunnel list response", + ); + assert.deepEqual(clientInstances[0].calls.at(-1), ["listTunnels"]); + assert.equal(port.messages.at(-1).payload.tunnels[0].id, "tun-1"); + + port.emit({ + type: "request", + connection_id: "connection-1", + request_id: "tunnel-create", + method: "tunnel.create", + payload: { + targetUrl: "http://localhost:3000/app", + ttlSeconds: 900, + name: "App", + }, + }); + await waitFor( + () => port.messages.some((message) => message.request_id === "tunnel-create"), + "shared worker tunnel create response", + ); + assert.deepEqual(clientInstances[0].calls.at(-1), [ + "createTunnel", + { + targetUrl: "http://localhost:3000/app", + ttlSeconds: 900, + name: "App", + }, + ]); + assert.equal(port.messages.at(-1).payload.tunnel.id, "tun-2"); + + port.emit({ + type: "request", + connection_id: "connection-1", + request_id: "tunnel-update-infinite", + method: "tunnel.update", + payload: { + id: "tun-2", + targetUrl: "http://localhost:4000/dashboard", + ttlSeconds: 0, + name: "Dashboard", + projectPathKey: "project:/tmp/liveagent", + }, + }); + await waitFor( + () => port.messages.some((message) => message.request_id === "tunnel-update-infinite"), + "shared worker tunnel update response", + ); + assert.deepEqual(clientInstances[0].calls.at(-1), [ + "updateTunnel", + { + id: "tun-2", + targetUrl: "http://localhost:4000/dashboard", + ttlSeconds: 0, + name: "Dashboard", + projectPathKey: "project:/tmp/liveagent", + }, + ]); + assert.equal(port.messages.at(-1).payload.tunnel.expiresAt, 0); + assert.equal(port.messages.at(-1).payload.tunnel.projectPathKey, "project:/tmp/liveagent"); + + port.emit({ + type: "request", + connection_id: "connection-1", + request_id: "tunnel-close", + method: "tunnel.close", + payload: { id: "tun-2" }, + }); + await waitFor( + () => port.messages.some((message) => message.request_id === "tunnel-close"), + "shared worker tunnel close response", + ); + assert.deepEqual(clientInstances[0].calls.at(-1), ["closeTunnel", "tun-2"]); + assert.equal(port.messages.at(-1).payload.tunnel.status, "expired"); + + globalThis.onconnect = previousOnConnect; +}); + test("Gateway SharedWorker forwards chat.attach streams to the requesting port", async () => { installBrowser(); const loader = createWebModuleLoader(); diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 5f28d7086..0532fa4bd 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -198,6 +198,10 @@ test("loadWebSettings forces current gateway URL/token over stale persisted remo openProjectPathKeys: ["/stale/project"], openVersion: 1, }; + stale.customSettings.projectToolsTunnel = { + openProjectPathKeys: ["/stale/project"], + openVersion: 1, + }; store.set("liveagent.gateway.webui.settings.v1", JSON.stringify(stale)); const loaded = webSettings.loadWebSettings(" new-token "); @@ -207,6 +211,7 @@ test("loadWebSettings forces current gateway URL/token over stale persisted remo assert.equal(loaded.remote.enabled, true); assert.deepEqual(loaded.customSettings.projectToolsFileTree.openProjectPathKeys, []); assert.deepEqual(loaded.customSettings.projectToolsGitReview.openProjectPathKeys, []); + assert.deepEqual(loaded.customSettings.projectToolsTunnel.openProjectPathKeys, []); }); test("gateway settings sync keeps remote connection local and syncs web terminal setting", () => { @@ -249,6 +254,7 @@ test("gateway settings sync keeps remote connection local and syncs web terminal assert.deepEqual(payload.remote, { enableWebTerminal: synced.remote.enableWebTerminal, enableWebGit: synced.remote.enableWebGit, + enableWebTunnels: synced.remote.enableWebTunnels, }); assert.deepEqual(payload.chatRuntimeControls, synced.chatRuntimeControls); }); @@ -338,48 +344,108 @@ test("gateway settings sync preserves active workspace project by path when ids assert.equal(synced.system.activeWorkspaceProjectId, "desktop-project-a"); }); -test("gateway settings sync keeps newer git review tab open state", () => { +test("gateway settings sync keeps newer project tool tab open state", () => { installWindow(); const current = settings.normalizeSettings({ customSettings: { + projectToolsPanel: { + width: 612, + activeTab: "gitReview", + tabOrders: { + "/web/project": ["__git_review__", "__file_tree__"], + }, + }, projectToolsGitReview: { openProjectPathKeys: ["/web/project"], openVersion: 2, }, + projectToolsTunnel: { + openProjectPathKeys: ["/web/project"], + openVersion: 2, + }, }, }); const staleSynced = settingsSync.applyGatewaySettingsSyncPayload(current, { customSettings: { + projectToolsPanel: { + width: 360, + activeTab: "terminal", + tabOrders: { + "/desktop/project": ["terminal-1", "__file_tree__"], + }, + }, projectToolsGitReview: { openProjectPathKeys: [], openVersion: 1, }, + projectToolsTunnel: { + openProjectPathKeys: [], + openVersion: 1, + }, }, }); assert.deepEqual(staleSynced.customSettings.projectToolsGitReview.openProjectPathKeys, [ "/web/project", ]); assert.equal(staleSynced.customSettings.projectToolsGitReview.openVersion, 2); + assert.deepEqual(staleSynced.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/web/project", + ]); + assert.equal(staleSynced.customSettings.projectToolsTunnel.openVersion, 2); + assert.equal(staleSynced.customSettings.projectToolsPanel.width, 612); + assert.equal(staleSynced.customSettings.projectToolsPanel.activeTab, "terminal"); + assert.deepEqual(staleSynced.customSettings.projectToolsPanel.tabOrders, { + "/web/project": ["__git_review__", "__file_tree__"], + }); const newerSynced = settingsSync.applyGatewaySettingsSyncPayload(staleSynced, { customSettings: { + projectToolsPanel: { + width: 360, + activeTab: "tunnel", + tabOrders: { + "/desktop/project": ["terminal-1", "__tunnel__"], + }, + }, projectToolsGitReview: { openProjectPathKeys: ["/desktop/project"], openVersion: 3, }, + projectToolsTunnel: { + openProjectPathKeys: ["/desktop/project"], + openVersion: 3, + }, }, }); assert.deepEqual(newerSynced.customSettings.projectToolsGitReview.openProjectPathKeys, [ "/desktop/project", ]); assert.equal(newerSynced.customSettings.projectToolsGitReview.openVersion, 3); + assert.deepEqual(newerSynced.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/desktop/project", + ]); + assert.equal(newerSynced.customSettings.projectToolsTunnel.openVersion, 3); + assert.equal(newerSynced.customSettings.projectToolsPanel.width, 612); + assert.equal(newerSynced.customSettings.projectToolsPanel.activeTab, "tunnel"); + assert.deepEqual(newerSynced.customSettings.projectToolsPanel.tabOrders, { + "/web/project": ["__git_review__", "__file_tree__"], + }); const payload = settingsSync.buildGatewaySettingsSyncPayload(newerSynced); + assert.deepEqual(payload.customSettings.projectToolsPanel, { + activeTab: "tunnel", + }); + assert.equal(Object.hasOwn(payload.customSettings.projectToolsPanel, "width"), false); + assert.equal(Object.hasOwn(payload.customSettings.projectToolsPanel, "tabOrders"), false); assert.deepEqual(payload.customSettings.projectToolsGitReview, { openProjectPathKeys: ["/desktop/project"], openVersion: 3, }); + assert.deepEqual(payload.customSettings.projectToolsTunnel, { + openProjectPathKeys: ["/desktop/project"], + openVersion: 3, + }); }); test("gateway settings sync keeps newer project conversation activity", () => { diff --git a/crates/agent-gateway/web/src/App.tsx b/crates/agent-gateway/web/src/App.tsx index ba683e931..1c9e2ae2e 100644 --- a/crates/agent-gateway/web/src/App.tsx +++ b/crates/agent-gateway/web/src/App.tsx @@ -69,6 +69,7 @@ import { isAgentDevMode, isProjectToolsFileTreeOpen, isProjectToolsGitReviewOpen, + isProjectToolsTunnelOpen, normalizeChatRuntimeControlsForProvider, normalizeSettings, removeProjectToolsProjectState, @@ -79,6 +80,7 @@ import { updateProjectToolsFileTreeProjectState, updateProjectToolsFileTreeOpen, updateProjectToolsGitReviewOpen, + updateProjectToolsTunnelOpen, updateProjectToolsPanelTabOrder, type AppSettings, type ChatRuntimeControls, @@ -477,6 +479,38 @@ function isTerminalChatEvent(event: ChatEvent) { return event.type === "done" || event.type === "error"; } +type TunnelManagerToolChange = { + action: "create" | "close"; + projectPathKey: string; +}; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readTunnelManagerToolChange(event: ChatEvent): TunnelManagerToolChange | null { + if (event.type !== "tool_result" || event.isError === true) { + return null; + } + const details = asRecord(event.details); + if (details.kind !== "tunnel_manager") { + return null; + } + const action = typeof details.action === "string" ? details.action.trim() : ""; + if (action !== "create" && action !== "close") { + return null; + } + const tunnel = asRecord(details.tunnel); + const projectPathKey = + (typeof tunnel.projectPathKey === "string" ? tunnel.projectPathKey.trim() : "") || + (typeof tunnel.project_path_key === "string" ? tunnel.project_path_key.trim() : "") || + event.workdir?.trim() || + ""; + return { action, projectPathKey }; +} + function buildGatewaySelectedModel( selectedModel: SelectedModel | undefined, providers: ModelProviderSource[], @@ -829,6 +863,7 @@ export default function App() { const [isFileDropActive, setIsFileDropActive] = useState(false); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false); + const [tunnelRefreshToken, setTunnelRefreshToken] = useState(0); const previousProjectToolsFileTreeOpenRef = useRef(false); const [workspaceEditorMounted, setWorkspaceEditorMounted] = useState(false); const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); @@ -2035,6 +2070,42 @@ export default function App() { [queueSettingsSave], ); + const openTunnelToolPanel = useCallback( + (projectPathKey?: string) => { + const targetProjectPathKey = + workspaceProjectPathKey(projectPathKey) || + workspaceProjectPathKey(activeWorkspaceProjectPath); + if (!targetProjectPathKey) return; + setActiveView("chat"); + setProjectToolsPanelOpen(true); + setSettings((prev) => + updateProjectToolsTunnelOpen( + updateCustomSettings(prev, { + projectToolsPanel: { + ...prev.customSettings.projectToolsPanel, + activeTab: "tunnel", + }, + }), + targetProjectPathKey, + true, + ), + ); + }, + [activeWorkspaceProjectPath, setSettings], + ); + + const handleTunnelManagerChatEvent = useCallback( + (event: ChatEvent) => { + const change = readTunnelManagerToolChange(event); + if (!change) return; + setTunnelRefreshToken((current) => current + 1); + if (change.action === "create") { + openTunnelToolPanel(change.projectPathKey); + } + }, + [openTunnelToolPanel], + ); + const persistProjectConversationActivity = useCallback( (activity: ReadonlyMap) => { if (activity.size === 0) { @@ -2828,6 +2899,7 @@ export default function App() { liveStore.appendEvent(event, { flush: event.type === "done" || event.type === "error", }); + handleTunnelManagerChatEvent(event); if (event.type === "done" || event.type === "error") { terminalEventSeen = true; @@ -2884,6 +2956,7 @@ export default function App() { commitTerminalConversationLiveStream, getConversationAbortController, getConversationLiveStreamStore, + handleTunnelManagerChatEvent, markCompletedLiveStream, markLiveConversationStreamActive, recoverUnavailableConversationStream, @@ -3090,6 +3163,7 @@ export default function App() { clearConversationLiveStream, getConversationAbortController, getHistoryPositionLockedConversationIds, + handleTunnelManagerChatEvent, hasRecentlyCompletedLiveStream, hasRetainedConversationLiveStream, isAgentMode, @@ -3122,14 +3196,17 @@ export default function App() { return; } + const visibleBroadcastConversationId = resolveVisibleConversationId( + selectedHistoryIdRef.current, + conversationIdRef.current, + ); const isTerminalEvent = isTerminalChatEvent(event); if (!isTerminalEvent && !isChatStreamNotAvailableEvent(event)) { setRemoteConversationRunningState(targetConversationId, true, { workdir: event.workdir, }); if ( - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - targetConversationId && + visibleBroadcastConversationId === targetConversationId && !isConversationLiveStreamAttached(targetConversationId) ) { attachVisibleConversationLiveStream(targetConversationId, api); @@ -3177,6 +3254,9 @@ export default function App() { liveStore.appendEvent(event, { flush: isTerminalEvent, }); + if (visibleBroadcastConversationId === targetConversationId) { + handleTunnelManagerChatEvent(event); + } if (isTerminalEvent) { markCompletedLiveStream(targetConversationId); commitTerminalConversationLiveStream(targetConversationId); @@ -3197,6 +3277,7 @@ export default function App() { commitTerminalConversationLiveStream, getConversationAbortController, getConversationLiveStreamStore, + handleTunnelManagerChatEvent, isConversationLiveStreamAttached, markCompletedLiveStream, markLiveConversationStreamActive, @@ -4143,6 +4224,7 @@ export default function App() { getConversationLiveStreamStore(activeConversationId)?.appendEvent(event, { flush: event.type === "done" || event.type === "error", }); + handleTunnelManagerChatEvent(event); if (event.type === "done" || event.type === "error") { terminalEventSeen = true; markCompletedLiveStream(activeConversationId); @@ -5530,6 +5612,10 @@ export default function App() { settings.customSettings, terminalProjectPathKey, ); + const projectToolsTunnelOpen = isProjectToolsTunnelOpen( + settings.customSettings, + terminalProjectPathKey, + ); const projectToolsDisabledMessage = !settingsSyncReady ? "Syncing desktop settings..." : !isAgentMode @@ -5545,6 +5631,29 @@ export default function App() { const gitDisabledMessage = !settings.remote.enableWebGit ? "WebUI Git is disabled in desktop Remote settings." : undefined; + const tunnelEnabled = + settingsSyncReady && settings.remote.enableWebTunnels === true && status?.online === true; + const tunnelDisabledMessage = !settingsSyncReady + ? translate("chat.runtime.tunnelSettingsSyncing", settings.locale) + : !settings.remote.enableWebTunnels + ? translate("projectTools.tunnelWebDisabled", settings.locale) + : status?.online !== true + ? translate("projectTools.tunnelRemoteOffline", settings.locale) + : undefined; + const tunnelManagerToolAvailable = + settingsSyncReady && + isAgentMode && + settings.remote.enableWebTunnels === true && + status?.online === true; + const tunnelManagerToolDisabledMessage = !settingsSyncReady + ? translate("chat.runtime.tunnelSettingsSyncing", settings.locale) + : !isAgentMode + ? translate("chat.runtime.tunnelAgentModeRequired", settings.locale) + : !settings.remote.enableWebTunnels + ? translate("chat.runtime.tunnelWebDisabled", settings.locale) + : status?.online !== true + ? translate("chat.runtime.tunnelRemoteOffline", settings.locale) + : undefined; const handleOpenWorkspaceFile = useCallback( (path: string) => { if (!terminalProjectPath || !terminalProjectPathKey) return; @@ -5574,6 +5683,11 @@ export default function App() { }, [terminalProjectPath, terminalProjectPathKey], ); + + const handleOpenTunnelToolPanel = useCallback(() => { + if (!tunnelManagerToolAvailable) return; + openTunnelToolPanel(); + }, [openTunnelToolPanel, tunnelManagerToolAvailable]); const requestWorkspaceEditorClose = useCallback(() => { setWorkspaceEditorCloseRequestId((current) => current + 1); }, []); @@ -6433,6 +6547,8 @@ export default function App() { gitClient={gitClient} gitWriteEnabled={settings.remote.enableWebGit} gitDisabledMessage={gitDisabledMessage} + tunnelToolAvailable={tunnelManagerToolAvailable} + tunnelToolDisabledMessage={tunnelManagerToolDisabledMessage} onGitChanged={(gitWorkdir) => window.dispatchEvent( new CustomEvent("liveagent:git-changed", { @@ -6440,6 +6556,7 @@ export default function App() { }), ) } + onOpenTunnelToolPanel={handleOpenTunnelToolPanel} onSend={() => { if ( submitInFlightRef.current || @@ -6657,10 +6774,15 @@ export default function App() { settings.customSettings, terminalProjectPathKey, )} + tunnelOpen={projectToolsTunnelOpen} client={terminalClient} gitClient={gitClient} gitWriteEnabled={settings.remote.enableWebGit} gitDisabledMessage={gitDisabledMessage} + tunnelClient={isAgentMode ? api : null} + tunnelEnabled={tunnelEnabled} + tunnelDisabledMessage={tunnelDisabledMessage} + tunnelRefreshToken={tunnelRefreshToken} onWidthChange={(nextWidth) => setSettings((prev) => updateCustomSettings(prev, { @@ -6701,6 +6823,9 @@ export default function App() { updateProjectToolsGitReviewOpen(prev, terminalProjectPathKey, open), ) } + onTunnelOpenChange={(open) => + setSettings((prev) => updateProjectToolsTunnelOpen(prev, terminalProjectPathKey, open)) + } onSessionsChange={handleProjectTerminalSessionsChange} onInsertFileMention={(path, kind) => { composerRef.current?.insertFileMention(path, kind); diff --git a/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx new file mode 100644 index 000000000..7bb03695b --- /dev/null +++ b/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx @@ -0,0 +1,783 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useLocale } from "@/i18n"; +import { cn } from "@/lib/shared/utils"; +import type { + TunnelCreateInput, + TunnelSummary as GatewayTunnelSummary, + TunnelUpdateInput, +} from "@/lib/gatewaySocket"; +import { + Check, + Clock3, + Copy, + Edit3, + ExternalLink, + Folder, + Globe, + Link2, + Loader2, + Save, + Trash2, + X, +} from "../icons"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +export type { TunnelCreateInput }; +export type TunnelSummary = Omit; + +export type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; + +export type LocalTunnelClient = { + listTunnels(): Promise; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; +}; + +type LocalTunnelPanelProps = { + client: LocalTunnelClient; + enabled?: boolean; + disabledMessage?: string; + projectPathKey?: string; + refreshToken?: number; +}; + +type TunnelScope = "project" | "global"; + +const TUNNEL_MANAGER_CHANGED_EVENT = "liveagent:tunnel-manager-changed"; + +const TUNNEL_SCOPE_OPTIONS: Array<{ + scope: TunnelScope; + labelKey: string; + titleKey: string; +}> = [ + { + scope: "project", + labelKey: "projectTools.tunnelScopeProject", + titleKey: "projectTools.tunnelScopeProjectTitle", + }, + { + scope: "global", + labelKey: "projectTools.tunnelScopeGlobal", + titleKey: "projectTools.tunnelScopeGlobalTitle", + }, +]; + +const TTL_OPTIONS: Array<{ value: TunnelTtlSeconds; labelKey: string }> = [ + { value: 900, labelKey: "projectTools.tunnelTtl15m" }, + { value: 3600, labelKey: "projectTools.tunnelTtl1h" }, + { value: 14400, labelKey: "projectTools.tunnelTtl4h" }, + { value: 0, labelKey: "projectTools.tunnelTtlInfinite" }, +]; + +function validateLocalHttpTarget(input: string) { + const value = input.trim(); + if (!value) return "projectTools.tunnelTargetRequired"; + try { + const url = new URL(value); + if (url.protocol !== "http:") { + return "projectTools.tunnelInvalidUrl"; + } + const hostname = url.hostname.toLowerCase(); + if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname)) { + return "projectTools.tunnelLocalhostOnly"; + } + if (url.username || url.password || url.hash) { + return "projectTools.tunnelInvalidUrl"; + } + } catch { + return "projectTools.tunnelInvalidUrl"; + } + return null; +} + +function asErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function formatRemaining(seconds: number) { + if (seconds <= 0) return "0m"; + const hours = Math.floor(seconds / 3600); + const minutes = Math.ceil((seconds % 3600) / 60); + if (hours <= 0) return `${minutes}m`; + if (minutes >= 60) return `${hours + 1}h`; + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function formatDateTime(seconds: number) { + if (!seconds) return ""; + return new Date(seconds * 1000).toLocaleString(); +} + +function writeTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text).then( + () => true, + () => fallbackWriteTextToClipboard(text), + ); + } + return Promise.resolve(fallbackWriteTextToClipboard(text)); +} + +function fallbackWriteTextToClipboard(text: string) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } +} + +function displayTunnelName(tunnel: TunnelSummary) { + return tunnel.name.trim() || tunnel.targetUrl; +} + +function tunnelStatusKey(status: TunnelSummary["status"]) { + if (status === "expired") return "projectTools.tunnelStatusExpired"; + if (status === "offline") return "projectTools.tunnelStatusOffline"; + return "projectTools.tunnelStatusActive"; +} + +function normalizeProjectPathKey(value: string | undefined) { + return value?.trim() ?? ""; +} + +function ttlFromTunnel(tunnel: TunnelSummary, nowSeconds: number): TunnelTtlSeconds { + if (!tunnel.expiresAt) return 0; + const remaining = Math.max(0, tunnel.expiresAt - nowSeconds); + if (remaining <= 900) return 900; + if (remaining <= 3600) return 3600; + return 14400; +} + +export function LocalTunnelPanel({ + client, + enabled = true, + disabledMessage, + projectPathKey, + refreshToken, +}: LocalTunnelPanelProps) { + const { t } = useLocale(); + const normalizedProjectPathKey = useMemo( + () => normalizeProjectPathKey(projectPathKey), + [projectPathKey], + ); + const [scope, setScope] = useState(() => + normalizeProjectPathKey(projectPathKey) ? "project" : "global", + ); + const [targetUrl, setTargetUrl] = useState("http://localhost:3000"); + const [name, setName] = useState(""); + const [ttlSeconds, setTtlSeconds] = useState(3600); + const [editingId, setEditingId] = useState(""); + const [editTargetUrl, setEditTargetUrl] = useState(""); + const [editName, setEditName] = useState(""); + const [editTtlSeconds, setEditTtlSeconds] = useState(3600); + const [tunnels, setTunnels] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [savingId, setSavingId] = useState(""); + const [closingId, setClosingId] = useState(""); + const [copiedId, setCopiedId] = useState(""); + const [error, setError] = useState(null); + const [nowSeconds, setNowSeconds] = useState(() => Math.floor(Date.now() / 1000)); + const refreshTokenRef = useRef(refreshToken); + const targetValidationKey = useMemo(() => validateLocalHttpTarget(targetUrl), [targetUrl]); + const editTargetValidationKey = useMemo( + () => (editingId ? validateLocalHttpTarget(editTargetUrl) : null), + [editTargetUrl, editingId], + ); + + const refresh = useCallback( + (options?: { showLoading?: boolean }) => { + const showLoading = options?.showLoading ?? true; + if (showLoading) { + setLoading(true); + } + setError(null); + return client + .listTunnels() + .then((items) => setTunnels(items)) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => { + if (showLoading) { + setLoading(false); + } + }); + }, + [client], + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (refreshTokenRef.current === refreshToken) return; + refreshTokenRef.current = refreshToken; + void refresh({ showLoading: false }); + }, [refresh, refreshToken]); + + useEffect(() => { + const handleTunnelManagerChanged = () => { + void refresh({ showLoading: false }); + }; + window.addEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); + return () => + window.removeEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); + }, [refresh]); + + useEffect(() => { + if (!normalizedProjectPathKey && scope === "project") { + setScope("global"); + setError(null); + } + }, [normalizedProjectPathKey, scope]); + + useEffect(() => { + const timer = window.setInterval(() => { + setNowSeconds(Math.floor(Date.now() / 1000)); + }, 1000); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + if (!copiedId) return; + const timer = window.setTimeout(() => setCopiedId(""), 1600); + return () => window.clearTimeout(timer); + }, [copiedId]); + + const createTunnel = useCallback(() => { + const validationKey = validateLocalHttpTarget(targetUrl); + if (validationKey) { + setError(t(validationKey)); + return; + } + if (!enabled || creating) return; + const input: TunnelCreateInput = { + targetUrl: targetUrl.trim(), + name: name.trim() || undefined, + ttlSeconds, + }; + if (scope === "project" && normalizedProjectPathKey) { + input.projectPathKey = normalizedProjectPathKey; + } + setCreating(true); + setError(null); + void client + .createTunnel(input) + .then((created) => { + setTunnels((current) => [ + created, + ...current.filter((item) => item.id !== created.id && item.slug !== created.slug), + ]); + setName(""); + void refresh({ showLoading: false }); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setCreating(false)); + }, [ + client, + creating, + enabled, + name, + normalizedProjectPathKey, + refresh, + scope, + t, + targetUrl, + ttlSeconds, + ]); + + const beginEdit = useCallback( + (tunnel: TunnelSummary) => { + setEditingId(tunnel.id); + setEditTargetUrl(tunnel.targetUrl); + setEditName(tunnel.name); + setEditTtlSeconds(ttlFromTunnel(tunnel, nowSeconds)); + setError(null); + }, + [nowSeconds], + ); + + const cancelEdit = useCallback(() => { + setEditingId(""); + setEditTargetUrl(""); + setEditName(""); + setEditTtlSeconds(3600); + setError(null); + }, []); + + const updateTunnel = useCallback( + (tunnel: TunnelSummary) => { + const validationKey = validateLocalHttpTarget(editTargetUrl); + if (validationKey) { + setError(t(validationKey)); + return; + } + if (!enabled || savingId) return; + const input: TunnelUpdateInput = { + id: tunnel.id, + targetUrl: editTargetUrl.trim(), + name: editName.trim() || undefined, + ttlSeconds: editTtlSeconds, + }; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + if (tunnelProjectPathKey) { + input.projectPathKey = tunnelProjectPathKey; + } + setSavingId(tunnel.id); + setError(null); + void client + .updateTunnel(input) + .then((updated) => { + setTunnels((current) => current.map((item) => (item.id === updated.id ? updated : item))); + cancelEdit(); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setSavingId((current) => (current === tunnel.id ? "" : current))); + }, + [cancelEdit, client, editName, editTargetUrl, editTtlSeconds, enabled, savingId, t], + ); + + const closeTunnel = useCallback( + (id: string) => { + if (!enabled || closingId) return; + setClosingId(id); + setError(null); + void client + .closeTunnel(id) + .then((closed) => { + setTunnels((current) => + current + .filter((item) => item.id !== id) + .concat(closed.status === "active" ? [closed] : []), + ); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setClosingId((current) => (current === id ? "" : current))); + }, + [client, closingId, enabled], + ); + + const copyLink = useCallback((tunnel: TunnelSummary) => { + if (!tunnel.publicUrl) return; + void writeTextToClipboard(tunnel.publicUrl) + .then((copied) => { + if (copied) { + setCopiedId(tunnel.id); + } + }) + .catch(() => {}); + }, []); + + const openLink = useCallback((tunnel: TunnelSummary) => { + if (!tunnel.publicUrl) return; + window.open(tunnel.publicUrl, "_blank", "noopener,noreferrer"); + }, []); + + const scopedTunnels = useMemo( + () => + tunnels.filter((tunnel) => { + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + if (scope === "project") { + return ( + Boolean(normalizedProjectPathKey) && tunnelProjectPathKey === normalizedProjectPathKey + ); + } + return true; + }), + [normalizedProjectPathKey, scope, tunnels], + ); + const sortedTunnels = useMemo( + () => [...scopedTunnels].sort((a, b) => b.createdAt - a.createdAt), + [scopedTunnels], + ); + const canCreate = + enabled && + !creating && + !targetValidationKey && + (scope !== "project" || Boolean(normalizedProjectPathKey)); + const showCreateForm = scope === "project" && Boolean(normalizedProjectPathKey); + + return ( +
+
+
+
+ +
+
+
+ {t("projectTools.tunnelTitle")} +
+
+ {t("projectTools.tunnelDescription")} +
+
+
+
+ {TUNNEL_SCOPE_OPTIONS.map((option) => { + const active = scope === option.scope; + const disabled = option.scope === "project" && !normalizedProjectPathKey; + const Icon = option.scope === "project" ? Folder : Globe; + return ( + + ); + })} +
+
+
+
+ + {disabledMessage || showCreateForm ? ( +
+ {disabledMessage ? ( +
+ {disabledMessage} +
+ ) : null} + {showCreateForm ? ( +
+
+ + setTargetUrl(event.target.value)} + placeholder={t("projectTools.tunnelTargetPlaceholder")} + disabled={!enabled || creating} + className="h-8 min-w-0 text-xs" + /> + {targetValidationKey ? ( +
{t(targetValidationKey)}
+ ) : null} +
+
+
+ + setName(event.target.value)} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={!enabled || creating} + className="h-8 min-w-0 text-xs" + /> +
+
+ +
+ {TTL_OPTIONS.map((option) => ( + + ))} +
+
+
+ +
+ ) : null} +
+ ) : null} + +
+ {error ? ( +
+ {error} +
+ ) : null} + {loading && sortedTunnels.length === 0 ? ( +
+ + {t("projectTools.tunnelLoading")} +
+ ) : sortedTunnels.length === 0 ? ( +
+
+ +
+
{t("projectTools.tunnelEmpty")}
+
+ ) : ( +
+ {sortedTunnels.map((tunnel) => { + const hasExpiry = tunnel.expiresAt > 0; + const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; + const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); + const isEditing = editingId === tunnel.id; + const updating = savingId === tunnel.id; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + return ( +
+
+
+ +
+
+
+
+ {displayTunnelName(tunnel)} +
+ + {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} + +
+ {isEditing ? ( +
+ setEditTargetUrl(event.target.value)} + disabled={!enabled || updating} + className="h-8 min-w-0 text-xs" + aria-label={t("projectTools.tunnelTargetUrl")} + /> + {editTargetValidationKey ? ( +
+ {t(editTargetValidationKey)} +
+ ) : null} +
+ setEditName(event.target.value)} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={!enabled || updating} + className="h-8 min-w-0 text-xs" + aria-label={t("projectTools.tunnelName")} + /> +
+ +
+ {TTL_OPTIONS.map((option) => ( + + ))} +
+
+
+
+ ) : ( + <> +
+ {t("projectTools.tunnelTarget")}: {tunnel.targetUrl} +
+
+ + + {tunnel.publicUrl} + +
+
+ + + + {!hasExpiry + ? t("projectTools.tunnelTtlInfinite") + : expired + ? t("projectTools.tunnelExpired") + : t("projectTools.tunnelExpiresIn").replace( + "{time}", + formatRemaining(remaining), + )} + + + {scope === "global" ? ( + + {t( + tunnelProjectPathKey + ? "projectTools.tunnelScopeProjectBadge" + : "projectTools.tunnelScopeGlobalBadge", + )} + + ) : null} + {hasExpiry ? {formatDateTime(tunnel.expiresAt)} : null} +
+ + )} +
+
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + + + )} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx index c01212a2b..baf973971 100644 --- a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx @@ -34,6 +34,7 @@ import { ChevronRight, FolderTree, GitBranch, + Globe, GripVertical, Plus, Terminal, @@ -54,6 +55,7 @@ import { type GitCommitContextPayload, type GitFileContextPayload, } from "./GitReviewPanel"; +import { LocalTunnelPanel, type LocalTunnelClient } from "./LocalTunnelPanel"; import { ProjectFileTreePanel } from "./ProjectFileTreePanel"; const MIN_PANEL_WIDTH = 320; @@ -64,6 +66,7 @@ const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const FILE_TREE_TAB_ID = "__file_tree__"; const GIT_REVIEW_TAB_ID = "__git_review__"; +const TUNNEL_TAB_ID = "__tunnel__"; const PROJECT_TOOLS_RESIZE_END_EVENT = "liveagent:project-tools-resize-end"; type ProjectToolsPanelProps = { @@ -81,16 +84,22 @@ type ProjectToolsPanelProps = { fileTreeOpen: boolean; fileTreeState: ProjectToolsFileTreeProjectState; gitReviewOpen: boolean; + tunnelOpen?: boolean; client: TerminalClient; gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; + tunnelClient?: LocalTunnelClient | null; + tunnelEnabled?: boolean; + tunnelDisabledMessage?: string; + tunnelRefreshToken?: number; onWidthChange: (width: number) => void; onActiveTabChange: (tab: ProjectToolsPanelTab) => void; onTabOrderChange?: (tabOrder: string[]) => void; onFileTreeOpenChange: (open: boolean) => void; onFileTreeStateChange: (patch: ProjectToolsFileTreeStatePatch) => void; onGitReviewOpenChange: (open: boolean) => void; + onTunnelOpenChange?: (open: boolean) => void; onSessionsChange?: (sessions: TerminalSession[]) => void; onInsertFileMention?: (path: string, kind: "file" | "dir") => void; onOpenFile?: (path: string) => void; @@ -200,6 +209,10 @@ type ProjectToolsTab = | { id: typeof GIT_REVIEW_TAB_ID; kind: "gitReview"; + } + | { + id: typeof TUNNEL_TAB_ID; + kind: "tunnel"; }; type TabDragState = { @@ -727,16 +740,22 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { fileTreeOpen, fileTreeState, gitReviewOpen, + tunnelOpen = false, client, gitClient, gitWriteEnabled = false, gitDisabledMessage, + tunnelClient, + tunnelEnabled = false, + tunnelDisabledMessage, + tunnelRefreshToken, onWidthChange, onActiveTabChange, onTabOrderChange, onFileTreeOpenChange, onFileTreeStateChange, onGitReviewOpenChange, + onTunnelOpenChange, onSessionsChange, onInsertFileMention, onOpenFile, @@ -790,10 +809,15 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { const isControlled = externalSessions !== undefined; const fileTreeInitialized = Boolean(projectPathKey && fileTreeOpen); const gitReviewInitialized = Boolean(projectPathKey && gitReviewOpen); + const tunnelInitialized = Boolean(tunnelOpen && tunnelClient); + const tunnelAvailable = Boolean(tunnelClient); const previousFileTreeInitializedRef = useRef(fileTreeInitialized); const previousGitReviewInitializedRef = useRef(gitReviewInitialized); + const previousTunnelInitializedRef = useRef(tunnelInitialized); const currentActiveTab: ProjectToolsPanelTab = - activeTab === "gitReview" && gitReviewInitialized + activeTab === "tunnel" && tunnelInitialized + ? "tunnel" + : activeTab === "gitReview" && gitReviewInitialized ? "gitReview" : activeTab === "fileTree" && fileTreeInitialized ? "fileTree" @@ -822,8 +846,11 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { if (gitReviewInitialized) { nextTabs.push({ id: GIT_REVIEW_TAB_ID, kind: "gitReview" }); } + if (tunnelInitialized) { + nextTabs.push({ id: TUNNEL_TAB_ID, kind: "tunnel" }); + } return nextTabs; - }, [fileTreeInitialized, gitReviewInitialized, sessions]); + }, [fileTreeInitialized, gitReviewInitialized, sessions, tunnelInitialized]); const effectiveTabOrder = draftTabOrder ?? tabOrder; const orderedProjectTabs = useMemo( () => orderProjectToolsTabs(visibleTabs, effectiveTabOrder), @@ -914,6 +941,18 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, [activeTab, gitReviewInitialized, onActiveTabChange]); + useEffect(() => { + const previousTunnelInitialized = previousTunnelInitializedRef.current; + previousTunnelInitializedRef.current = tunnelInitialized; + if (tunnelInitialized && !previousTunnelInitialized) { + onActiveTabChange("tunnel"); + return; + } + if (!tunnelInitialized && previousTunnelInitialized && activeTab === "tunnel") { + onActiveTabChange("terminal"); + } + }, [activeTab, onActiveTabChange, tunnelInitialized]); + const publishSessions = useCallback( (nextSessions: TerminalSession[], options?: { notifyParent?: boolean }) => { const sorted = sortSessions(nextSessions); @@ -1047,7 +1086,14 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { if (!isOpen) return; const element = tabsScrollRef.current; if (!element) return; - const targetTabId = currentActiveTab === "fileTree" ? FILE_TREE_TAB_ID : activeSession?.id; + const targetTabId = + currentActiveTab === "fileTree" + ? FILE_TREE_TAB_ID + : currentActiveTab === "gitReview" + ? GIT_REVIEW_TAB_ID + : currentActiveTab === "tunnel" + ? TUNNEL_TAB_ID + : activeSession?.id; if (!targetTabId) return; const target = Array.from( element.querySelectorAll("[data-project-tools-tab-id]"), @@ -1540,8 +1586,12 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, []); + const showDisabledMessage = Boolean(disabledMessage && !tunnelAvailable && !tunnelInitialized); const showProjectToolsChooser = - projectReady && currentActiveTab === "terminal" && !activeSession; + !showDisabledMessage && + (projectReady || tunnelAvailable) && + currentActiveTab === "terminal" && + !activeSession; const startFileTree = useCallback(() => { setFileTreeInitialized(true); @@ -1598,6 +1648,19 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, [activeTab, onActiveTabChange, onGitReviewOpenChange]); + const startTunnel = useCallback(() => { + if (!tunnelClient) return; + onTunnelOpenChange?.(true); + onActiveTabChange("tunnel"); + }, [onActiveTabChange, onTunnelOpenChange, tunnelClient]); + + const closeTunnelTab = useCallback(() => { + onTunnelOpenChange?.(false); + if (activeTab === "tunnel") { + onActiveTabChange("terminal"); + } + }, [activeTab, onActiveTabChange, onTunnelOpenChange]); + const renderCreateTerminalMenuItem = () => { if (shellOptions.length > 1) { return ( @@ -1807,6 +1870,63 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { ); } + if (tab.kind === "tunnel") { + return ( +
+ +
+ ); + } + const session = tab.session; const isPendingClose = pendingCloseSessionId === session.id; const isClosing = closingSessionId === session.id; @@ -1914,7 +2034,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { + {loading ? (
@@ -2104,6 +2254,22 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { />
) : null} + {tunnelInitialized && tunnelClient ? ( +
+ +
+ ) : null} {sessions.length > 0 ? (
> = { "chat.runtime.webSearchOn": "联网搜索已开启", "chat.runtime.webSearchOff": "联网搜索已关闭", "chat.runtime.webSearchTooltip": "联网搜索", + "chat.runtime.tunnelToolAvailable": "内网穿透工具已可用", + "chat.runtime.tunnelToolUnavailable": "内网穿透工具不可用", + "chat.runtime.tunnelAgentModeRequired": "Agent 模式下可使用内网穿透工具", + "chat.runtime.tunnelWebDisabled": "Remote 设置未允许 WebUI 内网穿透", + "chat.runtime.tunnelRemoteOffline": "Remote Gateway 未连接", + "chat.runtime.tunnelSettingsSyncing": "正在同步桌面端设置", "chat.runtime.reasoning": "思考程度", "chat.emptyRound": "(无回复)", "chat.inputHint": "输入消息,@ 引用文件,Enter 发送,Shift+Enter 换行", @@ -220,6 +226,7 @@ export const translations: Record> = { "projectTools.terminalTitle": "终端", "projectTools.fileTreeTitle": "文件树", "projectTools.gitReviewTitle": "Git 审查", + "projectTools.tunnelTitle": "内网穿透", "projectTools.resizePanel": "调整项目工具栏宽度", "projectTools.getStarted": "开始使用", "projectTools.getStartedHint": "选择一个工具开始", @@ -229,6 +236,13 @@ export const translations: Record> = { "projectTools.fileTreeDescription": "浏览和管理项目文件", "projectTools.newGitReview": "新建审查", "projectTools.gitReviewDescription": "查看代码变更和提交历史", + "projectTools.newTunnel": "新建内网穿透", + "projectTools.tunnelDescription": "通过 Gateway 暴露本机 HTTP 服务", + "projectTools.tunnelScopeGroup": "切换内网穿透视角", + "projectTools.tunnelScopeProject": "当前项目", + "projectTools.tunnelScopeGlobal": "全局", + "projectTools.tunnelScopeProjectTitle": "管理当前项目内网穿透", + "projectTools.tunnelScopeGlobalTitle": "管理全局内网穿透", "projectTools.newProjectTool": "新建项目工具", "projectTools.closePanel": "关闭项目工具栏", "projectTools.close": "关闭", @@ -238,6 +252,41 @@ export const translations: Record> = { "projectTools.closeRunningTerminal": "关闭正在运行的终端「{title}」?", "projectTools.closeFileTree": "关闭文件树", "projectTools.closeGitReview": "关闭 Git 审查", + "projectTools.closeTunnelTab": "关闭内网穿透", + "projectTools.tunnelTargetUrl": "本地服务地址", + "projectTools.tunnelTargetPlaceholder": "http://localhost:3000", + "projectTools.tunnelName": "名称", + "projectTools.tunnelNamePlaceholder": "可选", + "projectTools.tunnelTtl": "有效期", + "projectTools.tunnelTtl15m": "15m", + "projectTools.tunnelTtl1h": "1h", + "projectTools.tunnelTtl4h": "4h", + "projectTools.tunnelTtlInfinite": "无限", + "projectTools.tunnelCreate": "创建临时链接", + "projectTools.tunnelCreating": "创建中...", + "projectTools.tunnelEdit": "编辑链接", + "projectTools.tunnelSave": "保存修改", + "projectTools.tunnelUpdating": "保存中...", + "projectTools.tunnelCancelEdit": "取消编辑", + "projectTools.tunnelLoading": "正在加载内网穿透...", + "projectTools.tunnelEmpty": "还没有内网穿透链接", + "projectTools.tunnelTargetRequired": "请输入本地 HTTP 服务地址。", + "projectTools.tunnelInvalidUrl": "请输入有效的 http://localhost 地址,不能包含账号、密码或片段。", + "projectTools.tunnelLocalhostOnly": "仅支持 localhost、127.0.0.1 或 [::1]。", + "projectTools.tunnelRemoteOffline": "Remote Gateway 未连接,连接后才能创建或关闭 tunnel。", + "projectTools.tunnelWebDisabled": "桌面端 Remote 设置未允许 WebUI 创建或关闭内网穿透。", + "projectTools.tunnelStatusActive": "运行中", + "projectTools.tunnelStatusExpired": "已过期", + "projectTools.tunnelStatusOffline": "离线", + "projectTools.tunnelTarget": "目标", + "projectTools.tunnelExpiresIn": "剩余 {time}", + "projectTools.tunnelExpired": "已过期", + "projectTools.tunnelScopeProjectBadge": "项目", + "projectTools.tunnelScopeGlobalBadge": "全局", + "projectTools.tunnelCopyLink": "复制链接", + "projectTools.tunnelCopied": "已复制", + "projectTools.tunnelOpenLink": "打开链接", + "projectTools.tunnelClose": "删除链接", "projectTools.gitReview.viewChanges": "查看更改", "projectTools.gitReview.discardChanges": "放弃更改", "projectTools.gitReview.stageChanges": "暂存更改", @@ -909,6 +958,9 @@ export const translations: Record> = { "settings.remoteWebGit": "允许 WebUI Git", "settings.remoteWebGitHint": "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", + "settings.remoteWebTunnels": "允许 WebUI 内网穿透", + "settings.remoteWebTunnelsHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可为本机 localhost HTTP 服务创建和关闭临时访问链接。", "settings.remoteHeartbeat": "心跳间隔", "settings.remoteHeartbeatUnit": "秒", "settings.remoteHeartbeatHint": "与 Gateway 之间的心跳检测间隔,用于维持连接和检测在线状态", @@ -1243,6 +1295,12 @@ export const translations: Record> = { "chat.runtime.webSearchOn": "Web search enabled", "chat.runtime.webSearchOff": "Web search disabled", "chat.runtime.webSearchTooltip": "Toggle web search", + "chat.runtime.tunnelToolAvailable": "Tunnel tool available", + "chat.runtime.tunnelToolUnavailable": "Tunnel tool unavailable", + "chat.runtime.tunnelAgentModeRequired": "Tunnel tool requires Agent mode", + "chat.runtime.tunnelWebDisabled": "Remote WebUI tunnels are not allowed", + "chat.runtime.tunnelRemoteOffline": "Remote Gateway is offline", + "chat.runtime.tunnelSettingsSyncing": "Syncing desktop settings", "chat.runtime.reasoning": "Thinking effort", "chat.emptyRound": "(No reply)", "chat.inputHint": "Type a message, @ to mention files, Enter to send, Shift+Enter for newline", @@ -1389,6 +1447,7 @@ export const translations: Record> = { "projectTools.terminalTitle": "Terminal", "projectTools.fileTreeTitle": "File Tree", "projectTools.gitReviewTitle": "Git Review", + "projectTools.tunnelTitle": "Tunnel", "projectTools.resizePanel": "Resize project tools panel", "projectTools.getStarted": "Get Started", "projectTools.getStartedHint": "Choose a tool to begin", @@ -1398,6 +1457,13 @@ export const translations: Record> = { "projectTools.fileTreeDescription": "Browse and manage project files", "projectTools.newGitReview": "New Review", "projectTools.gitReviewDescription": "Review code changes and commit history", + "projectTools.newTunnel": "New Tunnel", + "projectTools.tunnelDescription": "Expose local HTTP services through Gateway", + "projectTools.tunnelScopeGroup": "Switch tunnel scope", + "projectTools.tunnelScopeProject": "Current Project", + "projectTools.tunnelScopeGlobal": "Global", + "projectTools.tunnelScopeProjectTitle": "Manage current project tunnels", + "projectTools.tunnelScopeGlobalTitle": "Manage global tunnels", "projectTools.newProjectTool": "New project tool", "projectTools.closePanel": "Close project tools panel", "projectTools.close": "Close", @@ -1407,6 +1473,44 @@ export const translations: Record> = { "projectTools.closeRunningTerminal": 'Close running terminal "{title}"?', "projectTools.closeFileTree": "Close File Tree", "projectTools.closeGitReview": "Close Git Review", + "projectTools.closeTunnelTab": "Close Tunnel", + "projectTools.tunnelTargetUrl": "Local service URL", + "projectTools.tunnelTargetPlaceholder": "http://localhost:3000", + "projectTools.tunnelName": "Name", + "projectTools.tunnelNamePlaceholder": "Optional", + "projectTools.tunnelTtl": "TTL", + "projectTools.tunnelTtl15m": "15m", + "projectTools.tunnelTtl1h": "1h", + "projectTools.tunnelTtl4h": "4h", + "projectTools.tunnelTtlInfinite": "Unlimited", + "projectTools.tunnelCreate": "Create temporary link", + "projectTools.tunnelCreating": "Creating...", + "projectTools.tunnelEdit": "Edit link", + "projectTools.tunnelSave": "Save changes", + "projectTools.tunnelUpdating": "Saving...", + "projectTools.tunnelCancelEdit": "Cancel edit", + "projectTools.tunnelLoading": "Loading tunnels...", + "projectTools.tunnelEmpty": "No tunnel links yet", + "projectTools.tunnelTargetRequired": "Enter a local HTTP service URL.", + "projectTools.tunnelInvalidUrl": + "Enter a valid http://localhost URL without credentials or fragments.", + "projectTools.tunnelLocalhostOnly": "Only localhost, 127.0.0.1, or [::1] are supported.", + "projectTools.tunnelRemoteOffline": + "Remote Gateway is not connected. Connect it before creating or closing tunnels.", + "projectTools.tunnelWebDisabled": + "Desktop Remote settings do not allow WebUI tunnel create or close.", + "projectTools.tunnelStatusActive": "Active", + "projectTools.tunnelStatusExpired": "Expired", + "projectTools.tunnelStatusOffline": "Offline", + "projectTools.tunnelTarget": "Target", + "projectTools.tunnelExpiresIn": "{time} left", + "projectTools.tunnelExpired": "Expired", + "projectTools.tunnelScopeProjectBadge": "Project", + "projectTools.tunnelScopeGlobalBadge": "Global", + "projectTools.tunnelCopyLink": "Copy link", + "projectTools.tunnelCopied": "Copied", + "projectTools.tunnelOpenLink": "Open link", + "projectTools.tunnelClose": "Delete link", "projectTools.gitReview.viewChanges": "View Changes", "projectTools.gitReview.discardChanges": "Discard Changes", "projectTools.gitReview.stageChanges": "Stage Changes", @@ -2107,6 +2211,9 @@ export const translations: Record> = { "settings.remoteWebGit": "Allow WebUI Git", "settings.remoteWebGitHint": "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can run branch, stage, commit, and sync operations on local projects.", + "settings.remoteWebTunnels": "Allow WebUI Tunnels", + "settings.remoteWebTunnelsHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can create and close temporary links for local localhost HTTP services.", "settings.remoteHeartbeat": "Heartbeat Interval", "settings.remoteHeartbeatUnit": "seconds", "settings.remoteHeartbeatHint": diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index d81da0402..304cfac4b 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -154,6 +154,58 @@ export type UploadedImagePreviewResponse = { data: string; }; +export type TunnelCreateInput = { + targetUrl: string; + name?: string; + ttlSeconds: 0 | 900 | 3600 | 14400; + projectPathKey?: string; +}; + +export type TunnelUpdateInput = { + id: string; + targetUrl: string; + name?: string; + ttlSeconds: 0 | 900 | 3600 | 14400; + projectPathKey?: string; +}; + +export type TunnelSummary = { + id: string; + slug: string; + name: string; + targetUrl: string; + publicUrl: string; + createdAt: number; + expiresAt: number; + activeConnections: number; + status: "active" | "expired" | "offline"; + projectPathKey: string; +}; + +type RawTunnelSummary = { + id?: string; + slug?: string; + name?: string; + targetUrl?: string; + target_url?: string; + publicUrl?: string; + public_url?: string; + createdAt?: number; + created_at?: number; + expiresAt?: number; + expires_at?: number; + activeConnections?: number; + active_connections?: number; + status?: string; + projectPathKey?: string; + project_path_key?: string; +}; + +type RawTunnelResponse = { + tunnel?: RawTunnelSummary; + tunnels?: RawTunnelSummary[]; +}; + type HistoryGetOptions = { maxMessages?: number; }; @@ -443,6 +495,42 @@ function normalizeTerminalEvent(input: RawTerminalEvent): TerminalEvent | null { }; } +function normalizeTunnelStatus(input: unknown): TunnelSummary["status"] { + return input === "expired" || input === "offline" ? input : "active"; +} + +function fallbackTunnelPublicUrl(slug: string) { + const origin = getRuntimeOrigin().replace(/\/$/, ""); + return origin && slug ? `${origin}/t/${slug}/` : ""; +} + +function normalizeTunnelSummary(input: RawTunnelSummary): TunnelSummary { + const slug = input.slug?.trim() ?? ""; + return { + id: input.id?.trim() ?? "", + slug, + name: input.name?.trim() ?? "", + targetUrl: input.targetUrl ?? input.target_url ?? "", + publicUrl: input.publicUrl ?? input.public_url ?? fallbackTunnelPublicUrl(slug), + createdAt: Number(input.createdAt ?? input.created_at ?? 0), + expiresAt: Number(input.expiresAt ?? input.expires_at ?? 0), + activeConnections: Number(input.activeConnections ?? input.active_connections ?? 0), + status: normalizeTunnelStatus(input.status), + projectPathKey: (input.projectPathKey ?? input.project_path_key ?? "").trim(), + }; +} + +function normalizeTunnelListResponse(input: RawTunnelResponse): TunnelSummary[] { + return (input.tunnels ?? []).map(normalizeTunnelSummary); +} + +function normalizeTunnelResponse(input: RawTunnelResponse): TunnelSummary { + if (!input.tunnel) { + throw new Error("Tunnel response did not include a tunnel"); + } + return normalizeTunnelSummary(input.tunnel); +} + function normalizeOptionalOffset(value: unknown) { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) @@ -883,6 +971,49 @@ export class GatewayWebSocketClient { }); } + async listTunnels(): Promise { + return normalizeTunnelListResponse( + await this.requestWithRecovery("tunnel.list", {}), + ); + } + + async createTunnel(input: TunnelCreateInput): Promise { + const payload: Record = { + targetUrl: input.targetUrl, + ttlSeconds: input.ttlSeconds, + name: input.name, + }; + if (input.projectPathKey?.trim()) { + payload.projectPathKey = input.projectPathKey.trim(); + } + return normalizeTunnelResponse( + await this.request("tunnel.create", payload), + ); + } + + async updateTunnel(input: TunnelUpdateInput): Promise { + const payload: Record = { + id: input.id, + targetUrl: input.targetUrl, + ttlSeconds: input.ttlSeconds, + name: input.name, + }; + if (input.projectPathKey?.trim()) { + payload.projectPathKey = input.projectPathKey.trim(); + } + return normalizeTunnelResponse( + await this.request("tunnel.update", payload), + ); + } + + async closeTunnel(id: string): Promise { + return normalizeTunnelResponse( + await this.request("tunnel.close", { + id, + }), + ); + } + async listHistory( page: number, pageSize: number, @@ -1869,6 +2000,10 @@ export type GatewayWebSocketClientLike = { closeTerminal(sessionId: string, projectPathKey?: string): Promise; closeProjectTerminals(projectPathKey: string): Promise; detachTerminal(sessionId: string, projectPathKey?: string): Promise; + listTunnels(): Promise; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; listHistory(page: number, pageSize: number, filter?: HistoryListFilter): Promise; listHistoryWorkdirs(): Promise; listSharedHistory(page: number, pageSize: number): Promise; @@ -2429,6 +2564,47 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { }); } + async listTunnels(): Promise { + return normalizeTunnelListResponse(await this.request("tunnel.list", {})); + } + + async createTunnel(input: TunnelCreateInput): Promise { + const payload: Record = { + targetUrl: input.targetUrl, + ttlSeconds: input.ttlSeconds, + name: input.name, + }; + if (input.projectPathKey?.trim()) { + payload.projectPathKey = input.projectPathKey.trim(); + } + return normalizeTunnelResponse( + await this.request("tunnel.create", payload), + ); + } + + async updateTunnel(input: TunnelUpdateInput): Promise { + const payload: Record = { + id: input.id, + targetUrl: input.targetUrl, + ttlSeconds: input.ttlSeconds, + name: input.name, + }; + if (input.projectPathKey?.trim()) { + payload.projectPathKey = input.projectPathKey.trim(); + } + return normalizeTunnelResponse( + await this.request("tunnel.update", payload), + ); + } + + async closeTunnel(id: string): Promise { + return normalizeTunnelResponse( + await this.request("tunnel.close", { + id, + }), + ); + } + async listHistory( page: number, pageSize: number, diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts index ad95bd461..6b64cfc83 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts @@ -574,6 +574,71 @@ async function resolveRequest(client: GatewayWebSocketClient, method: string, pa String(body.project_path_key ?? ""), ); return undefined; + case "tunnel.list": + return { + tunnels: await client.listTunnels(), + }; + case "tunnel.create": { + const projectPathKey = + typeof body.projectPathKey === "string" + ? body.projectPathKey.trim() + : typeof body.project_path_key === "string" + ? body.project_path_key.trim() + : ""; + return { + tunnel: await client.createTunnel({ + targetUrl: String(body.targetUrl ?? body.target_url ?? ""), + ttlSeconds: + body.ttlSeconds === 0 || + body.ttlSeconds === 900 || + body.ttlSeconds === 3600 || + body.ttlSeconds === 14400 + ? body.ttlSeconds + : body.ttl_seconds === 0 || + body.ttl_seconds === 900 || + body.ttl_seconds === 3600 || + body.ttl_seconds === 14400 + ? body.ttl_seconds + : 3600, + name: typeof body.name === "string" ? body.name : undefined, + ...(projectPathKey ? { projectPathKey } : {}), + }), + }; + } + case "tunnel.update": { + const projectPathKey = + typeof body.projectPathKey === "string" + ? body.projectPathKey.trim() + : typeof body.project_path_key === "string" + ? body.project_path_key.trim() + : ""; + return { + tunnel: await client.updateTunnel({ + id: String(body.id ?? body.tunnelId ?? body.tunnel_id ?? body.slug ?? ""), + targetUrl: String(body.targetUrl ?? body.target_url ?? ""), + ttlSeconds: + body.ttlSeconds === 0 || + body.ttlSeconds === 900 || + body.ttlSeconds === 3600 || + body.ttlSeconds === 14400 + ? body.ttlSeconds + : body.ttl_seconds === 0 || + body.ttl_seconds === 900 || + body.ttl_seconds === 3600 || + body.ttl_seconds === 14400 + ? body.ttl_seconds + : 3600, + name: typeof body.name === "string" ? body.name : undefined, + ...(projectPathKey ? { projectPathKey } : {}), + }), + }; + } + case "tunnel.close": + return { + tunnel: await client.closeTunnel( + String(body.id ?? body.tunnelId ?? body.tunnel_id ?? body.slug ?? ""), + ), + }; case "provider.models": return client.getProviderModels( String(body.type ?? ""), diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 41d85387e..1242a2b3e 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -142,7 +142,7 @@ export type ChatSidebarSettings = { recentCollapsed: boolean; }; -export type ProjectToolsPanelTab = "terminal" | "fileTree" | "gitReview"; +export type ProjectToolsPanelTab = "terminal" | "fileTree" | "gitReview" | "tunnel"; export type ProjectToolsPanelSettings = { width: number; @@ -169,6 +169,11 @@ export type ProjectToolsGitReviewSettings = { openVersion: number; }; +export type ProjectToolsTunnelSettings = { + openProjectPathKeys: string[]; + openVersion: number; +}; + export type ProjectToolsFileTreeStatePatch = Partial & { bumpRevision?: boolean; bumpStateVersion?: boolean; @@ -180,6 +185,7 @@ export type CustomSettings = { projectToolsPanel: ProjectToolsPanelSettings; projectToolsFileTree: ProjectToolsFileTreeSettings; projectToolsGitReview: ProjectToolsGitReviewSettings; + projectToolsTunnel: ProjectToolsTunnelSettings; }; export type SystemSettings = { @@ -267,6 +273,7 @@ export type RemoteSettings = { heartbeatInterval: number; enableWebTerminal: boolean; enableWebGit: boolean; + enableWebTunnels: boolean; }; export type AppSettings = { @@ -1055,6 +1062,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { heartbeatInterval: normalizePositiveInteger(obj.heartbeatInterval, 30), enableWebTerminal: obj.enableWebTerminal === true, enableWebGit: obj.enableWebGit === true, + enableWebTunnels: obj.enableWebTunnels === true, }; } @@ -1545,6 +1553,23 @@ export function normalizeProjectToolsGitReviewSettings( }; } +export function normalizeProjectToolsTunnelSettings( + input: unknown, +): ProjectToolsTunnelSettings { + const obj = (input && typeof input === "object" ? input : {}) as Record; + const openProjectPathKeys = Array.from( + new Set( + (Array.isArray(obj.openProjectPathKeys) ? obj.openProjectPathKeys : []) + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean), + ), + ).sort(); + return { + openProjectPathKeys, + openVersion: normalizeIntegerInRange(obj.openVersion, 0, Number.MAX_SAFE_INTEGER, 0), + }; +} + export function normalizeProjectToolsPanelTabOrder(input: unknown): string[] { if (!Array.isArray(input)) return []; const order: string[] = []; @@ -1593,7 +1618,8 @@ export function normalizeCustomSettings( const projectToolsPanelActiveTab = projectToolsPanel.activeTab === "terminal" || projectToolsPanel.activeTab === "fileTree" || - projectToolsPanel.activeTab === "gitReview" + projectToolsPanel.activeTab === "gitReview" || + projectToolsPanel.activeTab === "tunnel" ? projectToolsPanel.activeTab : "fileTree"; const projectToolsFileTree = ( @@ -1606,6 +1632,11 @@ export function normalizeCustomSettings( ? obj.projectToolsGitReview : {} ) as unknown; + const projectToolsTunnel = ( + obj.projectToolsTunnel && typeof obj.projectToolsTunnel === "object" + ? obj.projectToolsTunnel + : {} + ) as unknown; return { conversationTitleModel: normalizeSelectedModelForProviders( normalizeSelectedModel(obj.conversationTitleModel), @@ -1627,6 +1658,7 @@ export function normalizeCustomSettings( }, projectToolsFileTree: normalizeProjectToolsFileTreeSettings(projectToolsFileTree), projectToolsGitReview: normalizeProjectToolsGitReviewSettings(projectToolsGitReview), + projectToolsTunnel: normalizeProjectToolsTunnelSettings(projectToolsTunnel), }; } @@ -1661,6 +1693,7 @@ export function getDefaultSettings(): AppSettings { heartbeatInterval: 30, enableWebTerminal: false, enableWebGit: false, + enableWebTunnels: false, }, memory: normalizeMemorySettings({}, customProviders), customSettings: normalizeCustomSettings({}, customProviders), @@ -1807,6 +1840,10 @@ function hasProjectToolsGitReviewSessionState(state: ProjectToolsGitReviewSettin return state.openVersion > 0 || state.openProjectPathKeys.length > 0; } +function hasProjectToolsTunnelSessionState(state: ProjectToolsTunnelSettings): boolean { + return state.openVersion > 0 || state.openProjectPathKeys.length > 0; +} + export function preserveProjectToolsSessionState( next: AppSettings, current: AppSettings, @@ -1817,6 +1854,9 @@ export function preserveProjectToolsSessionState( const currentGitReview = normalizeProjectToolsGitReviewSettings( current.customSettings.projectToolsGitReview, ); + const currentTunnel = normalizeProjectToolsTunnelSettings( + current.customSettings.projectToolsTunnel, + ); return normalizeSettings({ ...next, @@ -1828,6 +1868,9 @@ export function preserveProjectToolsSessionState( projectToolsGitReview: hasProjectToolsGitReviewSessionState(currentGitReview) ? currentGitReview : next.customSettings.projectToolsGitReview, + projectToolsTunnel: hasProjectToolsTunnelSessionState(currentTunnel) + ? currentTunnel + : next.customSettings.projectToolsTunnel, }, }); } @@ -1898,6 +1941,14 @@ export function removeProjectToolsProjectState( ); const removedGitReviewOpenProjectPathKey = nextGitReviewOpenProjectPathKeys.length !== gitReviewOpenProjectPathKeys.length; + const tunnelOpenProjectPathKeys = prev.customSettings.projectToolsTunnel.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean); + const nextTunnelOpenProjectPathKeys = tunnelOpenProjectPathKeys.filter( + (pathKey) => pathKey !== normalizedPathKey, + ); + const removedTunnelOpenProjectPathKey = + nextTunnelOpenProjectPathKeys.length !== tunnelOpenProjectPathKeys.length; const hasFileTreeProjectState = Object.prototype.hasOwnProperty.call( prev.customSettings.projectToolsFileTree.projects, normalizedPathKey, @@ -1908,6 +1959,7 @@ export function removeProjectToolsProjectState( !hasTabOrder && !removedOpenProjectPathKey && !removedGitReviewOpenProjectPathKey && + !removedTunnelOpenProjectPathKey && !hasFileTreeProjectState ) { return prev; @@ -1951,6 +2003,15 @@ export function removeProjectToolsProjectState( ? prev.customSettings.projectToolsGitReview.openVersion + 1 : prev.customSettings.projectToolsGitReview.openVersion, }, + projectToolsTunnel: { + ...prev.customSettings.projectToolsTunnel, + openProjectPathKeys: removedTunnelOpenProjectPathKey + ? nextTunnelOpenProjectPathKeys.sort() + : prev.customSettings.projectToolsTunnel.openProjectPathKeys, + openVersion: removedTunnelOpenProjectPathKey + ? prev.customSettings.projectToolsTunnel.openVersion + 1 + : prev.customSettings.projectToolsTunnel.openVersion, + }, }); } @@ -2042,6 +2103,44 @@ export function updateProjectToolsGitReviewOpen( }); } +export function isProjectToolsTunnelOpen( + customSettings: CustomSettings, + projectPathKey: string, +): boolean { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + return ( + normalizedPathKey !== "" && + customSettings.projectToolsTunnel.openProjectPathKeys.includes(normalizedPathKey) + ); +} + +export function updateProjectToolsTunnelOpen( + prev: AppSettings, + projectPathKey: string, + open: boolean, +): AppSettings { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + if (!normalizedPathKey) return prev; + const openProjectPathKeys = new Set( + prev.customSettings.projectToolsTunnel.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean), + ); + if (openProjectPathKeys.has(normalizedPathKey) === open) return prev; + if (open) { + openProjectPathKeys.add(normalizedPathKey); + } else { + openProjectPathKeys.delete(normalizedPathKey); + } + return updateCustomSettings(prev, { + projectToolsTunnel: { + ...prev.customSettings.projectToolsTunnel, + openProjectPathKeys: Array.from(openProjectPathKeys).sort(), + openVersion: prev.customSettings.projectToolsTunnel.openVersion + 1, + }, + }); +} + function projectToolsFileTreeProjectStateEqual( left: ProjectToolsFileTreeProjectState, right: ProjectToolsFileTreeProjectState, diff --git a/crates/agent-gateway/web/src/lib/settings/storage.ts b/crates/agent-gateway/web/src/lib/settings/storage.ts index 118099bbf..3f1899501 100644 --- a/crates/agent-gateway/web/src/lib/settings/storage.ts +++ b/crates/agent-gateway/web/src/lib/settings/storage.ts @@ -8,6 +8,7 @@ import { normalizeChatRuntimeControls, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, normalizeProjectToolsPanelTabOrders, type ChatRuntimeControls, normalizeSkillsSettings, @@ -56,6 +57,7 @@ function toPersistedLocalCustomSettings( ...customSettings, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }; } @@ -92,7 +94,8 @@ function readLocalUiSettings(): { const projectToolsPanelActiveTab = projectToolsPanel.activeTab === "terminal" || projectToolsPanel.activeTab === "fileTree" || - projectToolsPanel.activeTab === "gitReview" + projectToolsPanel.activeTab === "gitReview" || + projectToolsPanel.activeTab === "tunnel" ? projectToolsPanel.activeTab : "fileTree"; return toPersistedLocalCustomSettings({ @@ -110,6 +113,7 @@ function readLocalUiSettings(): { }, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }); } diff --git a/crates/agent-gateway/web/src/lib/settings/sync.ts b/crates/agent-gateway/web/src/lib/settings/sync.ts index 67d94940a..a7529a653 100644 --- a/crates/agent-gateway/web/src/lib/settings/sync.ts +++ b/crates/agent-gateway/web/src/lib/settings/sync.ts @@ -2,7 +2,9 @@ import { normalizeChatRuntimeControls, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, normalizeSettings, + type ProjectToolsPanelTab, workspaceProjectPathKey, type AppSettings, } from "./index"; @@ -14,6 +16,16 @@ export type GatewaySettingsSyncProvider = Omit< > & { apiKeyConfigured?: boolean; }; +export type GatewayProjectToolsPanelSync = Pick< + AppSettings["customSettings"]["projectToolsPanel"], + "activeTab" +>; +export type GatewaySettingsSyncCustomSettings = Omit< + Partial, + "projectToolsPanel" +> & { + projectToolsPanel?: GatewayProjectToolsPanelSync; +}; export type GatewaySettingsSyncPayload = { system: AppSettings["system"]; @@ -22,9 +34,12 @@ export type GatewaySettingsSyncPayload = { agents: AppSettings["agents"]; hooks: AppSettings["hooks"]; cron: AppSettings["cron"]; - remote?: Pick; + remote?: Pick< + AppSettings["remote"], + "enableWebTerminal" | "enableWebGit" | "enableWebTunnels" + >; memory: AppSettings["memory"]; - customSettings: Partial; + customSettings: GatewaySettingsSyncCustomSettings; skills: AppSettings["skills"]; chatRuntimeControls: AppSettings["chatRuntimeControls"]; selectedModel: AppSettings["selectedModel"] | null; @@ -85,11 +100,15 @@ function collectProviderApiKeyUpdates( return Object.keys(updates).length > 0 ? updates : undefined; } -function syncableCustomSettings(customSettings: AppSettings["customSettings"]) { - const syncable = { ...customSettings } as Partial; - delete syncable.projectToolsPanel; +function syncableCustomSettings( + customSettings: AppSettings["customSettings"], +): GatewaySettingsSyncCustomSettings { + const { projectToolsPanel: _projectToolsPanel, ...syncable } = customSettings; return { ...syncable, + projectToolsPanel: { + activeTab: customSettings.projectToolsPanel.activeTab, + }, chatSidebar: { projectsCollapsed: false, recentCollapsed: false, @@ -255,7 +274,8 @@ function mergeSyncedRemoteSettings( const source = asObject(incoming); if ( !Object.prototype.hasOwnProperty.call(source, "enableWebTerminal") && - !Object.prototype.hasOwnProperty.call(source, "enableWebGit") + !Object.prototype.hasOwnProperty.call(source, "enableWebGit") && + !Object.prototype.hasOwnProperty.call(source, "enableWebTunnels") ) { return current; } @@ -267,6 +287,9 @@ function mergeSyncedRemoteSettings( enableWebGit: Object.prototype.hasOwnProperty.call(source, "enableWebGit") ? source.enableWebGit === true : current.enableWebGit, + enableWebTunnels: Object.prototype.hasOwnProperty.call(source, "enableWebTunnels") + ? source.enableWebTunnels === true + : current.enableWebTunnels, }; } @@ -327,6 +350,46 @@ function mergeSyncedProjectToolsGitReviewSettings( }; } +function mergeSyncedProjectToolsTunnelSettings( + current: AppSettings["customSettings"]["projectToolsTunnel"], + incoming: unknown, +): AppSettings["customSettings"]["projectToolsTunnel"] { + const currentState = normalizeProjectToolsTunnelSettings(current); + const incomingState = normalizeProjectToolsTunnelSettings(incoming); + const openFromIncoming = incomingState.openVersion >= currentState.openVersion; + return { + openProjectPathKeys: openFromIncoming + ? incomingState.openProjectPathKeys + : currentState.openProjectPathKeys, + openVersion: Math.max(currentState.openVersion, incomingState.openVersion), + }; +} + +function isProjectToolsPanelTab(value: unknown): value is ProjectToolsPanelTab { + return ( + value === "terminal" || + value === "fileTree" || + value === "gitReview" || + value === "tunnel" + ); +} + +function mergeSyncedProjectToolsPanelSettings( + current: AppSettings["customSettings"]["projectToolsPanel"], + incoming: unknown, +): AppSettings["customSettings"]["projectToolsPanel"] { + const source = asObject(incoming); + const activeTab = isProjectToolsPanelTab(source.activeTab) + ? source.activeTab + : current.activeTab; + return activeTab === current.activeTab + ? current + : { + ...current, + activeTab, + }; +} + export function buildGatewaySettingsSyncPayload( settings: AppSettings, options: { includeProviderApiKeyUpdates?: boolean } = {}, @@ -341,6 +404,7 @@ export function buildGatewaySettingsSyncPayload( remote: { enableWebTerminal: settings.remote.enableWebTerminal, enableWebGit: settings.remote.enableWebGit, + enableWebTunnels: settings.remote.enableWebTunnels, }, memory: settings.memory, customSettings: syncableCustomSettings(settings.customSettings), @@ -374,9 +438,9 @@ export function applyGatewaySettingsSyncPayload( ? (source.memory as AppSettings["memory"] | null | undefined) ?? {} : current.memory; const customSettings = Object.prototype.hasOwnProperty.call(source, "customSettings") - ? (source.customSettings as AppSettings["customSettings"] | null | undefined) ?? {} + ? (source.customSettings as GatewaySettingsSyncCustomSettings | null | undefined) ?? {} : current.customSettings; - const incomingCustomSettings = customSettings as Partial; + const incomingCustomSettings = customSettings as GatewaySettingsSyncCustomSettings; return normalizeSettings({ ...current, @@ -413,8 +477,25 @@ export function applyGatewaySettingsSyncPayload( incomingCustomSettings.projectToolsGitReview, ) : current.customSettings.projectToolsGitReview, + projectToolsTunnel: Object.prototype.hasOwnProperty.call( + incomingCustomSettings, + "projectToolsTunnel", + ) + ? mergeSyncedProjectToolsTunnelSettings( + current.customSettings.projectToolsTunnel, + incomingCustomSettings.projectToolsTunnel, + ) + : current.customSettings.projectToolsTunnel, chatSidebar: current.customSettings.chatSidebar, - projectToolsPanel: current.customSettings.projectToolsPanel, + projectToolsPanel: Object.prototype.hasOwnProperty.call( + incomingCustomSettings, + "projectToolsPanel", + ) + ? mergeSyncedProjectToolsPanelSettings( + current.customSettings.projectToolsPanel, + incomingCustomSettings.projectToolsPanel, + ) + : current.customSettings.projectToolsPanel, }, skills: (source.skills as AppSettings["skills"] | undefined) ?? current.skills, chatRuntimeControls: Object.prototype.hasOwnProperty.call(source, "chatRuntimeControls") diff --git a/crates/agent-gateway/web/src/lib/webSettings.ts b/crates/agent-gateway/web/src/lib/webSettings.ts index e53b3d040..57b829f17 100644 --- a/crates/agent-gateway/web/src/lib/webSettings.ts +++ b/crates/agent-gateway/web/src/lib/webSettings.ts @@ -2,6 +2,7 @@ import { getDefaultSettings, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, normalizeSettings, type AppSettings, } from "@/lib/settings"; @@ -40,6 +41,7 @@ function stripSessionOnlyProjectToolsState(settings: AppSettings): AppSettings { ...settings.customSettings, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }, }; } diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index 5ecd963d5..2d053570c 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -6,7 +6,17 @@ import { type MutableRefObject, type ReactNode, } from "react"; -import { Brain, Globe2, Lightbulb, Loader2, Paperclip, Send, Square, X } from "../../components/icons"; +import { + Brain, + Globe2, + Lightbulb, + Link2, + Loader2, + Paperclip, + Send, + Square, + X, +} from "../../components/icons"; import { MentionComposer, @@ -72,7 +82,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; + tunnelToolAvailable?: boolean; + tunnelToolDisabledMessage?: string; onGitChanged?: (workdir: string) => void; + onOpenTunnelToolPanel?: () => void; onSend: () => void; onStop: () => void; onComposerBusyChange: (isBusy: boolean) => void; @@ -96,7 +109,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { gitClient, gitWriteEnabled = true, gitDisabledMessage, + tunnelToolAvailable = false, + tunnelToolDisabledMessage, onGitChanged, + onOpenTunnelToolPanel, onSend, onStop, onComposerBusyChange, @@ -126,6 +142,11 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { ? t("chat.runtime.thinkingUnavailable") : t("chat.runtime.thinkingTooltip"); const webSearchTooltip = t("chat.runtime.webSearchTooltip"); + const tunnelTooltip = + tunnelToolDisabledMessage ?? + (tunnelToolAvailable + ? t("chat.runtime.tunnelToolAvailable") + : t("chat.runtime.tunnelToolUnavailable")); useEffect(() => { if ( @@ -341,6 +362,31 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { + + + + {reasoningOptions.length > 0 ? ( setTargetUrl(event.target.value)} + placeholder={t("projectTools.tunnelTargetPlaceholder")} + disabled={!enabled || creating} + className="h-8 min-w-0 text-xs" + /> + {targetValidationKey ? ( +
{t(targetValidationKey)}
+ ) : null} +
+
+
+ + setName(event.target.value)} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={!enabled || creating} + className="h-8 min-w-0 text-xs" + /> +
+
+ +
+ {TTL_OPTIONS.map((option) => ( + + ))} +
+
+
+ + + ) : null} + + ) : null} + +
+ {error ? ( +
+ {error} +
+ ) : null} + {loading && sortedTunnels.length === 0 ? ( +
+ + {t("projectTools.tunnelLoading")} +
+ ) : sortedTunnels.length === 0 ? ( +
+
+ +
+
{t("projectTools.tunnelEmpty")}
+
+ ) : ( +
+ {sortedTunnels.map((tunnel) => { + const hasExpiry = tunnel.expiresAt > 0; + const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; + const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); + const isEditing = editingId === tunnel.id; + const updating = savingId === tunnel.id; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + return ( +
+
+
+ +
+
+
+
+ {displayTunnelName(tunnel)} +
+ + {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} + +
+ {isEditing ? ( +
+ setEditTargetUrl(event.target.value)} + disabled={!enabled || updating} + className="h-8 min-w-0 text-xs" + aria-label={t("projectTools.tunnelTargetUrl")} + /> + {editTargetValidationKey ? ( +
+ {t(editTargetValidationKey)} +
+ ) : null} +
+ setEditName(event.target.value)} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={!enabled || updating} + className="h-8 min-w-0 text-xs" + aria-label={t("projectTools.tunnelName")} + /> +
+ +
+ {TTL_OPTIONS.map((option) => ( + + ))} +
+
+
+
+ ) : ( + <> +
+ {t("projectTools.tunnelTarget")}: {tunnel.targetUrl} +
+
+ + + {tunnel.publicUrl} + +
+
+ + + + {!hasExpiry + ? t("projectTools.tunnelTtlInfinite") + : expired + ? t("projectTools.tunnelExpired") + : t("projectTools.tunnelExpiresIn").replace( + "{time}", + formatRemaining(remaining), + )} + + + {scope === "global" ? ( + + {t( + tunnelProjectPathKey + ? "projectTools.tunnelScopeProjectBadge" + : "projectTools.tunnelScopeGlobalBadge", + )} + + ) : null} + {hasExpiry ? {formatDateTime(tunnel.expiresAt)} : null} +
+ + )} +
+
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + + + )} +
+
+ ); + })} +
+ )} +
+ + ); +} diff --git a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx index 1a816234e..0e9b8a05e 100644 --- a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx @@ -33,6 +33,7 @@ import { ChevronRight, FolderTree, GitBranch, + Globe, GripVertical, Plus, Terminal, @@ -53,6 +54,7 @@ import { type GitCommitContextPayload, type GitFileContextPayload, } from "./GitReviewPanel"; +import { LocalTunnelPanel, type LocalTunnelClient } from "./LocalTunnelPanel"; import { ProjectFileTreePanel } from "./ProjectFileTreePanel"; const MIN_PANEL_WIDTH = 320; @@ -63,6 +65,7 @@ const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const FILE_TREE_TAB_ID = "__file_tree__"; const GIT_REVIEW_TAB_ID = "__git_review__"; +const TUNNEL_TAB_ID = "__tunnel__"; const PROJECT_TOOLS_RESIZE_END_EVENT = "liveagent:project-tools-resize-end"; type ProjectToolsPanelProps = { @@ -80,16 +83,22 @@ type ProjectToolsPanelProps = { fileTreeOpen: boolean; fileTreeState: ProjectToolsFileTreeProjectState; gitReviewOpen: boolean; + tunnelOpen?: boolean; client: TerminalClient; gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; + tunnelClient?: LocalTunnelClient | null; + tunnelEnabled?: boolean; + tunnelDisabledMessage?: string; + tunnelRefreshToken?: number; onWidthChange: (width: number) => void; onActiveTabChange: (tab: ProjectToolsPanelTab) => void; onTabOrderChange?: (tabOrder: string[]) => void; onFileTreeOpenChange: (open: boolean) => void; onFileTreeStateChange: (patch: ProjectToolsFileTreeStatePatch) => void; onGitReviewOpenChange: (open: boolean) => void; + onTunnelOpenChange?: (open: boolean) => void; onSessionsChange?: (sessions: TerminalSession[]) => void; onInsertFileMention?: (path: string, kind: "file" | "dir") => void; onOpenFile?: (path: string) => void; @@ -199,6 +208,10 @@ type ProjectToolsTab = | { id: typeof GIT_REVIEW_TAB_ID; kind: "gitReview"; + } + | { + id: typeof TUNNEL_TAB_ID; + kind: "tunnel"; }; type TabDragState = { @@ -648,16 +661,22 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { fileTreeOpen, fileTreeState, gitReviewOpen, + tunnelOpen = false, client, gitClient, gitWriteEnabled = true, gitDisabledMessage, + tunnelClient, + tunnelEnabled = true, + tunnelDisabledMessage, + tunnelRefreshToken, onWidthChange, onActiveTabChange, onTabOrderChange, onFileTreeOpenChange, onFileTreeStateChange, onGitReviewOpenChange, + onTunnelOpenChange, onSessionsChange, onInsertFileMention, onOpenFile, @@ -703,14 +722,19 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { const isControlled = externalSessions !== undefined; const fileTreeInitialized = Boolean(projectPathKey && fileTreeOpen); const gitReviewInitialized = Boolean(projectPathKey && gitReviewOpen); + const tunnelInitialized = Boolean(tunnelOpen && tunnelClient); + const tunnelAvailable = Boolean(tunnelClient); const previousFileTreeInitializedRef = useRef(fileTreeInitialized); const previousGitReviewInitializedRef = useRef(gitReviewInitialized); + const previousTunnelInitializedRef = useRef(tunnelInitialized); const currentActiveTab: ProjectToolsPanelTab = - activeTab === "gitReview" && gitReviewInitialized - ? "gitReview" - : activeTab === "fileTree" && fileTreeInitialized - ? "fileTree" - : "terminal"; + activeTab === "tunnel" && tunnelInitialized + ? "tunnel" + : activeTab === "gitReview" && gitReviewInitialized + ? "gitReview" + : activeTab === "fileTree" && fileTreeInitialized + ? "fileTree" + : "terminal"; const activeSession = useMemo( () => sessions.find((session) => session.id === activeSessionId) ?? sessions[0] ?? null, @@ -735,8 +759,11 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { if (gitReviewInitialized) { nextTabs.push({ id: GIT_REVIEW_TAB_ID, kind: "gitReview" }); } + if (tunnelInitialized) { + nextTabs.push({ id: TUNNEL_TAB_ID, kind: "tunnel" }); + } return nextTabs; - }, [fileTreeInitialized, gitReviewInitialized, sessions]); + }, [fileTreeInitialized, gitReviewInitialized, sessions, tunnelInitialized]); const effectiveTabOrder = draftTabOrder ?? tabOrder; const orderedProjectTabs = useMemo( () => orderProjectToolsTabs(visibleTabs, effectiveTabOrder), @@ -772,6 +799,18 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, [activeTab, gitReviewInitialized, onActiveTabChange]); + useEffect(() => { + const previousTunnelInitialized = previousTunnelInitializedRef.current; + previousTunnelInitializedRef.current = tunnelInitialized; + if (tunnelInitialized && !previousTunnelInitialized) { + onActiveTabChange("tunnel"); + return; + } + if (!tunnelInitialized && previousTunnelInitialized && activeTab === "tunnel") { + onActiveTabChange("terminal"); + } + }, [activeTab, onActiveTabChange, tunnelInitialized]); + const publishSessions = useCallback( (nextSessions: TerminalSession[], options?: { notifyParent?: boolean }) => { const sorted = sortSessions(nextSessions); @@ -1244,8 +1283,12 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { [clampedWidth, onWidthChange, panelWidth], ); + const showDisabledMessage = Boolean(disabledMessage && !tunnelAvailable && !tunnelInitialized); const showProjectToolsChooser = - projectReady && currentActiveTab === "terminal" && !activeSession; + !showDisabledMessage && + (projectReady || tunnelAvailable) && + currentActiveTab === "terminal" && + !activeSession; const startFileTree = useCallback(() => { setFileTreeInitialized(true); @@ -1302,6 +1345,19 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, [activeTab, onActiveTabChange, onGitReviewOpenChange]); + const startTunnel = useCallback(() => { + if (!tunnelClient) return; + onTunnelOpenChange?.(true); + onActiveTabChange("tunnel"); + }, [onActiveTabChange, onTunnelOpenChange, tunnelClient]); + + const closeTunnelTab = useCallback(() => { + onTunnelOpenChange?.(false); + if (activeTab === "tunnel") { + onActiveTabChange("terminal"); + } + }, [activeTab, onActiveTabChange, onTunnelOpenChange]); + const renderCreateTerminalMenuItem = () => { if (shellOptions.length > 1) { return ( @@ -1507,6 +1563,61 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { ); } + if (tab.kind === "tunnel") { + return ( +
+ +
+ ); + } + const session = tab.session; const isPendingClose = pendingCloseSessionId === session.id; const isClosing = closingSessionId === session.id; @@ -1593,7 +1704,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { + {loading ? (
@@ -1784,6 +1923,22 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { />
) : null} + {tunnelInitialized && tunnelClient ? ( +
+ +
+ ) : null} {sessions.length > 0 ? (
> = { "chat.runtime.webSearchOn": "联网搜索已开启", "chat.runtime.webSearchOff": "联网搜索已关闭", "chat.runtime.webSearchTooltip": "联网搜索", + "chat.runtime.tunnelToolAvailable": "内网穿透工具已可用", + "chat.runtime.tunnelToolUnavailable": "内网穿透工具不可用", + "chat.runtime.tunnelAgentModeRequired": "Agent 模式下可使用内网穿透工具", + "chat.runtime.tunnelWebDisabled": "Remote 设置未允许 WebUI 内网穿透", + "chat.runtime.tunnelRemoteOffline": "Remote Gateway 未连接", + "chat.runtime.tunnelSettingsSyncing": "正在同步桌面端设置", "chat.runtime.reasoning": "思考程度", "chat.emptyRound": "(无回复)", "chat.inputHint": "输入消息,@ 引用文件,Enter 发送,Shift+Enter 换行", @@ -232,6 +238,7 @@ export const translations: Record> = { "projectTools.terminalTitle": "终端", "projectTools.fileTreeTitle": "文件树", "projectTools.gitReviewTitle": "Git 审查", + "projectTools.tunnelTitle": "内网穿透", "projectTools.resizePanel": "调整项目工具栏宽度", "projectTools.getStarted": "开始使用", "projectTools.getStartedHint": "选择一个工具开始", @@ -241,6 +248,13 @@ export const translations: Record> = { "projectTools.fileTreeDescription": "浏览和管理项目文件", "projectTools.newGitReview": "新建审查", "projectTools.gitReviewDescription": "查看代码变更和提交历史", + "projectTools.newTunnel": "新建内网穿透", + "projectTools.tunnelDescription": "通过 Gateway 暴露本机 HTTP 服务", + "projectTools.tunnelScopeGroup": "切换内网穿透视角", + "projectTools.tunnelScopeProject": "当前项目", + "projectTools.tunnelScopeGlobal": "全局", + "projectTools.tunnelScopeProjectTitle": "管理当前项目内网穿透", + "projectTools.tunnelScopeGlobalTitle": "管理全局内网穿透", "projectTools.newProjectTool": "新建项目工具", "projectTools.closePanel": "关闭项目工具栏", "projectTools.close": "关闭", @@ -250,6 +264,42 @@ export const translations: Record> = { "projectTools.closeRunningTerminal": "关闭正在运行的终端「{title}」?", "projectTools.closeFileTree": "关闭文件树", "projectTools.closeGitReview": "关闭 Git 审查", + "projectTools.closeTunnelTab": "关闭内网穿透", + "projectTools.tunnelTargetUrl": "本地服务地址", + "projectTools.tunnelTargetPlaceholder": "http://localhost:3000", + "projectTools.tunnelName": "名称", + "projectTools.tunnelNamePlaceholder": "可选", + "projectTools.tunnelTtl": "有效期", + "projectTools.tunnelTtl15m": "15m", + "projectTools.tunnelTtl1h": "1h", + "projectTools.tunnelTtl4h": "4h", + "projectTools.tunnelTtlInfinite": "无限", + "projectTools.tunnelCreate": "创建临时链接", + "projectTools.tunnelCreating": "创建中...", + "projectTools.tunnelEdit": "编辑链接", + "projectTools.tunnelSave": "保存修改", + "projectTools.tunnelUpdating": "保存中...", + "projectTools.tunnelCancelEdit": "取消编辑", + "projectTools.tunnelLoading": "正在加载内网穿透...", + "projectTools.tunnelEmpty": "还没有内网穿透链接", + "projectTools.tunnelTargetRequired": "请输入本地 HTTP 服务地址。", + "projectTools.tunnelInvalidUrl": + "请输入有效的 http://localhost 地址,不能包含账号、密码或片段。", + "projectTools.tunnelLocalhostOnly": "仅支持 localhost、127.0.0.1 或 [::1]。", + "projectTools.tunnelRemoteOffline": "Remote Gateway 未连接,连接后才能创建或关闭 tunnel。", + "projectTools.tunnelWebDisabled": "桌面端 Remote 设置未允许 WebUI 创建或关闭内网穿透。", + "projectTools.tunnelStatusActive": "运行中", + "projectTools.tunnelStatusExpired": "已过期", + "projectTools.tunnelStatusOffline": "离线", + "projectTools.tunnelTarget": "目标", + "projectTools.tunnelExpiresIn": "剩余 {time}", + "projectTools.tunnelExpired": "已过期", + "projectTools.tunnelScopeProjectBadge": "项目", + "projectTools.tunnelScopeGlobalBadge": "全局", + "projectTools.tunnelCopyLink": "复制链接", + "projectTools.tunnelCopied": "已复制", + "projectTools.tunnelOpenLink": "打开链接", + "projectTools.tunnelClose": "删除链接", "projectTools.gitReview.viewChanges": "查看更改", "projectTools.gitReview.discardChanges": "放弃更改", "projectTools.gitReview.stageChanges": "暂存更改", @@ -951,6 +1001,9 @@ export const translations: Record> = { "settings.remoteWebGit": "允许 WebUI Git", "settings.remoteWebGitHint": "开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", + "settings.remoteWebTunnels": "允许 WebUI 内网穿透", + "settings.remoteWebTunnelsHint": + "开启后,已登录 WebUI 可为本机 localhost HTTP 服务创建和关闭临时访问链接。", "settings.remoteHeartbeat": "心跳间隔", "settings.remoteHeartbeatUnit": "秒", @@ -1296,6 +1349,12 @@ export const translations: Record> = { "chat.runtime.webSearchOn": "Web search enabled", "chat.runtime.webSearchOff": "Web search disabled", "chat.runtime.webSearchTooltip": "Toggle web search", + "chat.runtime.tunnelToolAvailable": "Tunnel tool available", + "chat.runtime.tunnelToolUnavailable": "Tunnel tool unavailable", + "chat.runtime.tunnelAgentModeRequired": "Tunnel tool requires Agent mode", + "chat.runtime.tunnelWebDisabled": "Remote WebUI tunnels are not allowed", + "chat.runtime.tunnelRemoteOffline": "Remote Gateway is offline", + "chat.runtime.tunnelSettingsSyncing": "Syncing desktop settings", "chat.runtime.reasoning": "Thinking effort", "chat.emptyRound": "(No reply)", "chat.inputHint": "Type a message, @ to mention files, Enter to send, Shift+Enter for newline", @@ -1442,6 +1501,7 @@ export const translations: Record> = { "projectTools.terminalTitle": "Terminal", "projectTools.fileTreeTitle": "File Tree", "projectTools.gitReviewTitle": "Git Review", + "projectTools.tunnelTitle": "Tunnel", "projectTools.resizePanel": "Resize project tools panel", "projectTools.getStarted": "Get Started", "projectTools.getStartedHint": "Choose a tool to begin", @@ -1451,6 +1511,13 @@ export const translations: Record> = { "projectTools.fileTreeDescription": "Browse and manage project files", "projectTools.newGitReview": "New Review", "projectTools.gitReviewDescription": "Review code changes and commit history", + "projectTools.newTunnel": "New Tunnel", + "projectTools.tunnelDescription": "Expose local HTTP services through Gateway", + "projectTools.tunnelScopeGroup": "Switch tunnel scope", + "projectTools.tunnelScopeProject": "Current Project", + "projectTools.tunnelScopeGlobal": "Global", + "projectTools.tunnelScopeProjectTitle": "Manage current project tunnels", + "projectTools.tunnelScopeGlobalTitle": "Manage global tunnels", "projectTools.newProjectTool": "New project tool", "projectTools.closePanel": "Close project tools panel", "projectTools.close": "Close", @@ -1460,6 +1527,44 @@ export const translations: Record> = { "projectTools.closeRunningTerminal": 'Close running terminal "{title}"?', "projectTools.closeFileTree": "Close File Tree", "projectTools.closeGitReview": "Close Git Review", + "projectTools.closeTunnelTab": "Close Tunnel", + "projectTools.tunnelTargetUrl": "Local service URL", + "projectTools.tunnelTargetPlaceholder": "http://localhost:3000", + "projectTools.tunnelName": "Name", + "projectTools.tunnelNamePlaceholder": "Optional", + "projectTools.tunnelTtl": "TTL", + "projectTools.tunnelTtl15m": "15m", + "projectTools.tunnelTtl1h": "1h", + "projectTools.tunnelTtl4h": "4h", + "projectTools.tunnelTtlInfinite": "Unlimited", + "projectTools.tunnelCreate": "Create temporary link", + "projectTools.tunnelCreating": "Creating...", + "projectTools.tunnelEdit": "Edit link", + "projectTools.tunnelSave": "Save changes", + "projectTools.tunnelUpdating": "Saving...", + "projectTools.tunnelCancelEdit": "Cancel edit", + "projectTools.tunnelLoading": "Loading tunnels...", + "projectTools.tunnelEmpty": "No tunnel links yet", + "projectTools.tunnelTargetRequired": "Enter a local HTTP service URL.", + "projectTools.tunnelInvalidUrl": + "Enter a valid http://localhost URL without credentials or fragments.", + "projectTools.tunnelLocalhostOnly": "Only localhost, 127.0.0.1, or [::1] are supported.", + "projectTools.tunnelRemoteOffline": + "Remote Gateway is not connected. Connect it before creating or closing tunnels.", + "projectTools.tunnelWebDisabled": + "Desktop Remote settings do not allow WebUI tunnel create or close.", + "projectTools.tunnelStatusActive": "Active", + "projectTools.tunnelStatusExpired": "Expired", + "projectTools.tunnelStatusOffline": "Offline", + "projectTools.tunnelTarget": "Target", + "projectTools.tunnelExpiresIn": "{time} left", + "projectTools.tunnelExpired": "Expired", + "projectTools.tunnelScopeProjectBadge": "Project", + "projectTools.tunnelScopeGlobalBadge": "Global", + "projectTools.tunnelCopyLink": "Copy link", + "projectTools.tunnelCopied": "Copied", + "projectTools.tunnelOpenLink": "Open link", + "projectTools.tunnelClose": "Delete link", "projectTools.gitReview.viewChanges": "View Changes", "projectTools.gitReview.discardChanges": "Discard Changes", "projectTools.gitReview.stageChanges": "Stage Changes", @@ -2196,6 +2301,9 @@ export const translations: Record> = { "settings.remoteWebGit": "Allow WebUI Git", "settings.remoteWebGitHint": "Allow authenticated WebUI clients to run branch, stage, commit, and sync operations on local projects.", + "settings.remoteWebTunnels": "Allow WebUI Tunnels", + "settings.remoteWebTunnelsHint": + "Allow authenticated WebUI clients to create and close temporary links for local localhost HTTP services.", "settings.remoteHeartbeat": "Heartbeat Interval", "settings.remoteHeartbeatUnit": "seconds", diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 58c37e658..3ce59bcd4 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -127,7 +127,7 @@ export type ChatSidebarSettings = { recentCollapsed: boolean; }; -export type ProjectToolsPanelTab = "terminal" | "fileTree" | "gitReview"; +export type ProjectToolsPanelTab = "terminal" | "fileTree" | "gitReview" | "tunnel"; export type ProjectToolsPanelSettings = { width: number; @@ -154,6 +154,11 @@ export type ProjectToolsGitReviewSettings = { openVersion: number; }; +export type ProjectToolsTunnelSettings = { + openProjectPathKeys: string[]; + openVersion: number; +}; + export type ProjectToolsFileTreeStatePatch = Partial & { bumpRevision?: boolean; bumpStateVersion?: boolean; @@ -165,6 +170,7 @@ export type CustomSettings = { projectToolsPanel: ProjectToolsPanelSettings; projectToolsFileTree: ProjectToolsFileTreeSettings; projectToolsGitReview: ProjectToolsGitReviewSettings; + projectToolsTunnel: ProjectToolsTunnelSettings; }; export type UpdateSettings = { @@ -256,6 +262,7 @@ export type RemoteSettings = { heartbeatInterval: number; enableWebTerminal: boolean; enableWebGit: boolean; + enableWebTunnels: boolean; }; export type AppSettings = { @@ -1047,6 +1054,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { heartbeatInterval: normalizePositiveInteger(obj.heartbeatInterval, 30), enableWebTerminal: obj.enableWebTerminal === true, enableWebGit: obj.enableWebGit === true, + enableWebTunnels: obj.enableWebTunnels === true, }; } @@ -1537,6 +1545,23 @@ export function normalizeProjectToolsGitReviewSettings( }; } +export function normalizeProjectToolsTunnelSettings( + input: unknown, +): ProjectToolsTunnelSettings { + const obj = (input && typeof input === "object" ? input : {}) as Record; + const openProjectPathKeys = Array.from( + new Set( + (Array.isArray(obj.openProjectPathKeys) ? obj.openProjectPathKeys : []) + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean), + ), + ).sort(); + return { + openProjectPathKeys, + openVersion: normalizeIntegerInRange(obj.openVersion, 0, Number.MAX_SAFE_INTEGER, 0), + }; +} + export function normalizeProjectToolsPanelTabOrder(input: unknown): string[] { if (!Array.isArray(input)) return []; const order: string[] = []; @@ -1585,7 +1610,8 @@ export function normalizeCustomSettings( const projectToolsPanelActiveTab = projectToolsPanel.activeTab === "terminal" || projectToolsPanel.activeTab === "fileTree" || - projectToolsPanel.activeTab === "gitReview" + projectToolsPanel.activeTab === "gitReview" || + projectToolsPanel.activeTab === "tunnel" ? projectToolsPanel.activeTab : "fileTree"; const projectToolsFileTree = ( @@ -1598,6 +1624,11 @@ export function normalizeCustomSettings( ? obj.projectToolsGitReview : {} ) as unknown; + const projectToolsTunnel = ( + obj.projectToolsTunnel && typeof obj.projectToolsTunnel === "object" + ? obj.projectToolsTunnel + : {} + ) as unknown; return { conversationTitleModel: normalizeSelectedModelForProviders( normalizeSelectedModel(obj.conversationTitleModel), @@ -1619,6 +1650,7 @@ export function normalizeCustomSettings( }, projectToolsFileTree: normalizeProjectToolsFileTreeSettings(projectToolsFileTree), projectToolsGitReview: normalizeProjectToolsGitReviewSettings(projectToolsGitReview), + projectToolsTunnel: normalizeProjectToolsTunnelSettings(projectToolsTunnel), }; } @@ -1660,6 +1692,7 @@ export function getDefaultSettings(): AppSettings { heartbeatInterval: 30, enableWebTerminal: false, enableWebGit: false, + enableWebTunnels: false, }, memory: normalizeMemorySettings({}, customProviders), customSettings: normalizeCustomSettings({}, customProviders), @@ -1799,6 +1832,10 @@ function hasProjectToolsGitReviewSessionState(state: ProjectToolsGitReviewSettin return state.openVersion > 0 || state.openProjectPathKeys.length > 0; } +function hasProjectToolsTunnelSessionState(state: ProjectToolsTunnelSettings): boolean { + return state.openVersion > 0 || state.openProjectPathKeys.length > 0; +} + export function preserveProjectToolsSessionState( next: AppSettings, current: AppSettings, @@ -1809,6 +1846,9 @@ export function preserveProjectToolsSessionState( const currentGitReview = normalizeProjectToolsGitReviewSettings( current.customSettings.projectToolsGitReview, ); + const currentTunnel = normalizeProjectToolsTunnelSettings( + current.customSettings.projectToolsTunnel, + ); return normalizeSettings({ ...next, @@ -1820,6 +1860,9 @@ export function preserveProjectToolsSessionState( projectToolsGitReview: hasProjectToolsGitReviewSessionState(currentGitReview) ? currentGitReview : next.customSettings.projectToolsGitReview, + projectToolsTunnel: hasProjectToolsTunnelSessionState(currentTunnel) + ? currentTunnel + : next.customSettings.projectToolsTunnel, }, }); } @@ -1881,15 +1924,22 @@ export function removeProjectToolsProjectState( (pathKey) => pathKey !== normalizedPathKey, ); const removedOpenProjectPathKey = nextOpenProjectPathKeys.length !== openProjectPathKeys.length; - const gitReviewOpenProjectPathKeys = - prev.customSettings.projectToolsGitReview.openProjectPathKeys - .map((pathKey) => workspaceProjectPathKey(pathKey)) - .filter(Boolean); + const gitReviewOpenProjectPathKeys = prev.customSettings.projectToolsGitReview.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean); const nextGitReviewOpenProjectPathKeys = gitReviewOpenProjectPathKeys.filter( (pathKey) => pathKey !== normalizedPathKey, ); const removedGitReviewOpenProjectPathKey = nextGitReviewOpenProjectPathKeys.length !== gitReviewOpenProjectPathKeys.length; + const tunnelOpenProjectPathKeys = prev.customSettings.projectToolsTunnel.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean); + const nextTunnelOpenProjectPathKeys = tunnelOpenProjectPathKeys.filter( + (pathKey) => pathKey !== normalizedPathKey, + ); + const removedTunnelOpenProjectPathKey = + nextTunnelOpenProjectPathKeys.length !== tunnelOpenProjectPathKeys.length; const hasFileTreeProjectState = Object.hasOwn( prev.customSettings.projectToolsFileTree.projects, normalizedPathKey, @@ -1900,6 +1950,7 @@ export function removeProjectToolsProjectState( !hasTabOrder && !removedOpenProjectPathKey && !removedGitReviewOpenProjectPathKey && + !removedTunnelOpenProjectPathKey && !hasFileTreeProjectState ) { return prev; @@ -1943,6 +1994,15 @@ export function removeProjectToolsProjectState( ? prev.customSettings.projectToolsGitReview.openVersion + 1 : prev.customSettings.projectToolsGitReview.openVersion, }, + projectToolsTunnel: { + ...prev.customSettings.projectToolsTunnel, + openProjectPathKeys: removedTunnelOpenProjectPathKey + ? nextTunnelOpenProjectPathKeys.sort() + : prev.customSettings.projectToolsTunnel.openProjectPathKeys, + openVersion: removedTunnelOpenProjectPathKey + ? prev.customSettings.projectToolsTunnel.openVersion + 1 + : prev.customSettings.projectToolsTunnel.openVersion, + }, }); } @@ -2034,6 +2094,44 @@ export function updateProjectToolsGitReviewOpen( }); } +export function isProjectToolsTunnelOpen( + customSettings: CustomSettings, + projectPathKey: string, +): boolean { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + return ( + normalizedPathKey !== "" && + customSettings.projectToolsTunnel.openProjectPathKeys.includes(normalizedPathKey) + ); +} + +export function updateProjectToolsTunnelOpen( + prev: AppSettings, + projectPathKey: string, + open: boolean, +): AppSettings { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + if (!normalizedPathKey) return prev; + const openProjectPathKeys = new Set( + prev.customSettings.projectToolsTunnel.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean), + ); + if (openProjectPathKeys.has(normalizedPathKey) === open) return prev; + if (open) { + openProjectPathKeys.add(normalizedPathKey); + } else { + openProjectPathKeys.delete(normalizedPathKey); + } + return updateCustomSettings(prev, { + projectToolsTunnel: { + ...prev.customSettings.projectToolsTunnel, + openProjectPathKeys: Array.from(openProjectPathKeys).sort(), + openVersion: prev.customSettings.projectToolsTunnel.openVersion + 1, + }, + }); +} + function projectToolsFileTreeProjectStateEqual( left: ProjectToolsFileTreeProjectState, right: ProjectToolsFileTreeProjectState, diff --git a/crates/agent-gui/src/lib/settings/storage.ts b/crates/agent-gui/src/lib/settings/storage.ts index e7361e923..1040aa0ad 100644 --- a/crates/agent-gui/src/lib/settings/storage.ts +++ b/crates/agent-gui/src/lib/settings/storage.ts @@ -8,6 +8,7 @@ import { normalizeChatRuntimeControls, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, normalizeProjectToolsPanelTabOrders, normalizeSelectedModel, normalizeSettings, @@ -58,6 +59,7 @@ function toPersistedLocalCustomSettings( ...customSettings, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }; } @@ -95,7 +97,8 @@ function readLocalUiSettings(): { const projectToolsPanelActiveTab = projectToolsPanel.activeTab === "terminal" || projectToolsPanel.activeTab === "fileTree" || - projectToolsPanel.activeTab === "gitReview" + projectToolsPanel.activeTab === "gitReview" || + projectToolsPanel.activeTab === "tunnel" ? projectToolsPanel.activeTab : "fileTree"; return toPersistedLocalCustomSettings({ @@ -113,6 +116,7 @@ function readLocalUiSettings(): { }, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }); } diff --git a/crates/agent-gui/src/lib/settings/sync.ts b/crates/agent-gui/src/lib/settings/sync.ts index 0d7282e8e..044f4a2f2 100644 --- a/crates/agent-gui/src/lib/settings/sync.ts +++ b/crates/agent-gui/src/lib/settings/sync.ts @@ -3,7 +3,9 @@ import { normalizeChatRuntimeControls, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, normalizeSettings, + type ProjectToolsPanelTab, workspaceProjectPathKey, } from "./index"; @@ -11,6 +13,16 @@ export type GatewayProviderApiKeyUpdates = Record; export type GatewaySettingsSyncProvider = Omit & { apiKeyConfigured?: boolean; }; +export type GatewayProjectToolsPanelSync = Pick< + AppSettings["customSettings"]["projectToolsPanel"], + "activeTab" +>; +export type GatewaySettingsSyncCustomSettings = Omit< + Partial, + "projectToolsPanel" +> & { + projectToolsPanel?: GatewayProjectToolsPanelSync; +}; export type GatewaySettingsSyncPayload = { system: AppSettings["system"]; @@ -19,9 +31,9 @@ export type GatewaySettingsSyncPayload = { agents: AppSettings["agents"]; hooks: AppSettings["hooks"]; cron: AppSettings["cron"]; - remote?: Pick; + remote?: Pick; memory: AppSettings["memory"]; - customSettings: Partial; + customSettings: GatewaySettingsSyncCustomSettings; skills: AppSettings["skills"]; chatRuntimeControls: AppSettings["chatRuntimeControls"]; selectedModel: AppSettings["selectedModel"] | null; @@ -82,11 +94,15 @@ function collectProviderApiKeyUpdates( return Object.keys(updates).length > 0 ? updates : undefined; } -function syncableCustomSettings(customSettings: AppSettings["customSettings"]) { - const syncable = { ...customSettings } as Partial; - delete syncable.projectToolsPanel; +function syncableCustomSettings( + customSettings: AppSettings["customSettings"], +): GatewaySettingsSyncCustomSettings { + const { projectToolsPanel: _projectToolsPanel, ...syncable } = customSettings; return { ...syncable, + projectToolsPanel: { + activeTab: customSettings.projectToolsPanel.activeTab, + }, chatSidebar: { projectsCollapsed: false, recentCollapsed: false, @@ -161,10 +177,7 @@ function mergeSyncedSystemSettings( } const incomingSystem = incoming as AppSettings["system"]; - const activeWorkspaceProjectId = resolveSyncedActiveWorkspaceProjectId( - current, - incomingSystem, - ); + const activeWorkspaceProjectId = resolveSyncedActiveWorkspaceProjectId(current, incomingSystem); if (!Array.isArray(incomingSystem.workspaceProjects)) { return { ...incomingSystem, @@ -247,7 +260,11 @@ function mergeSyncedRemoteSettings( incoming: unknown, ): AppSettings["remote"] { const source = asObject(incoming); - if (!Object.hasOwn(source, "enableWebTerminal") && !Object.hasOwn(source, "enableWebGit")) { + if ( + !Object.hasOwn(source, "enableWebTerminal") && + !Object.hasOwn(source, "enableWebGit") && + !Object.hasOwn(source, "enableWebTunnels") + ) { return current; } return { @@ -258,6 +275,9 @@ function mergeSyncedRemoteSettings( enableWebGit: Object.hasOwn(source, "enableWebGit") ? source.enableWebGit === true : current.enableWebGit, + enableWebTunnels: Object.hasOwn(source, "enableWebTunnels") + ? source.enableWebTunnels === true + : current.enableWebTunnels, }; } @@ -318,6 +338,46 @@ function mergeSyncedProjectToolsGitReviewSettings( }; } +function mergeSyncedProjectToolsTunnelSettings( + current: AppSettings["customSettings"]["projectToolsTunnel"], + incoming: unknown, +): AppSettings["customSettings"]["projectToolsTunnel"] { + const currentState = normalizeProjectToolsTunnelSettings(current); + const incomingState = normalizeProjectToolsTunnelSettings(incoming); + const openFromIncoming = incomingState.openVersion >= currentState.openVersion; + return { + openProjectPathKeys: openFromIncoming + ? incomingState.openProjectPathKeys + : currentState.openProjectPathKeys, + openVersion: Math.max(currentState.openVersion, incomingState.openVersion), + }; +} + +function isProjectToolsPanelTab(value: unknown): value is ProjectToolsPanelTab { + return ( + value === "terminal" || + value === "fileTree" || + value === "gitReview" || + value === "tunnel" + ); +} + +function mergeSyncedProjectToolsPanelSettings( + current: AppSettings["customSettings"]["projectToolsPanel"], + incoming: unknown, +): AppSettings["customSettings"]["projectToolsPanel"] { + const source = asObject(incoming); + const activeTab = isProjectToolsPanelTab(source.activeTab) + ? source.activeTab + : current.activeTab; + return activeTab === current.activeTab + ? current + : { + ...current, + activeTab, + }; +} + export function buildGatewaySettingsSyncPayload( settings: AppSettings, options: { includeProviderApiKeyUpdates?: boolean } = {}, @@ -332,6 +392,7 @@ export function buildGatewaySettingsSyncPayload( remote: { enableWebTerminal: settings.remote.enableWebTerminal, enableWebGit: settings.remote.enableWebGit, + enableWebTunnels: settings.remote.enableWebTunnels, }, memory: settings.memory, customSettings: syncableCustomSettings(settings.customSettings), @@ -365,9 +426,9 @@ export function applyGatewaySettingsSyncPayload( ? ((source.memory as AppSettings["memory"] | null | undefined) ?? {}) : current.memory; const customSettings = Object.hasOwn(source, "customSettings") - ? ((source.customSettings as AppSettings["customSettings"] | null | undefined) ?? {}) + ? ((source.customSettings as GatewaySettingsSyncCustomSettings | null | undefined) ?? {}) : current.customSettings; - const incomingCustomSettings = customSettings as Partial; + const incomingCustomSettings = customSettings as GatewaySettingsSyncCustomSettings; return normalizeSettings({ ...current, @@ -398,8 +459,19 @@ export function applyGatewaySettingsSyncPayload( incomingCustomSettings.projectToolsGitReview, ) : current.customSettings.projectToolsGitReview, + projectToolsTunnel: Object.hasOwn(incomingCustomSettings, "projectToolsTunnel") + ? mergeSyncedProjectToolsTunnelSettings( + current.customSettings.projectToolsTunnel, + incomingCustomSettings.projectToolsTunnel, + ) + : current.customSettings.projectToolsTunnel, chatSidebar: current.customSettings.chatSidebar, - projectToolsPanel: current.customSettings.projectToolsPanel, + projectToolsPanel: Object.hasOwn(incomingCustomSettings, "projectToolsPanel") + ? mergeSyncedProjectToolsPanelSettings( + current.customSettings.projectToolsPanel, + incomingCustomSettings.projectToolsPanel, + ) + : current.customSettings.projectToolsPanel, }, skills: (source.skills as AppSettings["skills"] | undefined) ?? current.skills, chatRuntimeControls: Object.hasOwn(source, "chatRuntimeControls") diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 17dfbcbbc..2ae91e9a6 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -34,6 +34,7 @@ import { createShellTools } from "./shellTools"; import type { SkillAccessPolicy } from "./skillAccessPolicy"; import { createSkillTools } from "./skillTools"; import { createTerminalTools } from "./terminalTools"; +import { createTunnelManagerTools } from "./tunnelManagerTools"; export type BuiltinToolRegistry = { tools: BuiltinToolBundle["tools"]; @@ -166,6 +167,23 @@ type BuildBuiltinBaseToolRegistryParams = { onMcpLoadError?: (message: string) => void; mcpLoadFailureMode?: "continue" | "throw"; memoryToolMode?: "rw" | "ro"; + remoteWebTunnelsEnabled?: boolean; + remoteGatewayOnline?: boolean; + tunnelProjectPathKey?: string; + onTunnelsChanged?: (change: { + action: "create" | "close"; + tunnel: { + id: string; + slug: string; + name: string; + targetUrl: string; + publicUrl: string; + createdAt: number; + expiresAt: number; + status: "active" | "expired" | "offline"; + projectPathKey?: string; + }; + }) => void | Promise; }; async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryParams) { @@ -217,6 +235,15 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP workdir: params.workdir, mode: params.memoryToolMode ?? "rw", }), + createTunnelManagerTools({ + enabled: + params.remoteWebTunnelsEnabled === true && + params.remoteGatewayOnline === true && + params.runtimeScope === "chat", + runtimeScope: params.runtimeScope, + projectPathKey: params.tunnelProjectPathKey, + onTunnelsChanged: params.onTunnelsChanged, + }), ...(params.runtimeScope === "chat" ? [ createTerminalTools({ diff --git a/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts b/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts new file mode 100644 index 000000000..310a0b98a --- /dev/null +++ b/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts @@ -0,0 +1,322 @@ +import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; +import { invoke } from "@tauri-apps/api/core"; +import { Type } from "typebox"; + +import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; + +type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; + +export const TUNNEL_MANAGER_CHANGED_EVENT = "liveagent:tunnel-manager-changed"; + +type TunnelSummary = { + id: string; + slug: string; + name: string; + targetUrl: string; + publicUrl: string; + createdAt: number; + expiresAt: number; + activeConnections: number; + status: "active" | "expired" | "offline"; + projectPathKey?: string; +}; + +type TunnelManagerTunnelSummary = Omit; + +export type TunnelChangeAction = "create" | "close"; + +export type TunnelManagerChange = { + action: TunnelChangeAction; + tunnel: TunnelManagerTunnelSummary; +}; + +type TunnelCreateInput = { + targetUrl: string; + name?: string; + ttlSeconds: TunnelTtlSeconds; + projectPathKey?: string; +}; + +type TunnelManagerAction = "list" | "create" | "close"; + +type TunnelManagerDetails = { + kind: "tunnel_manager"; + action: TunnelManagerAction; + tunnels?: TunnelManagerTunnelSummary[]; + tunnel?: TunnelManagerTunnelSummary; +}; + +const TUNNEL_MANAGER_TOOL: Tool = { + name: "TunnelManager", + description: + "Manage temporary Remote HTTP tunnels for localhost services through the Gateway. Use list to inspect active tunnels, create to expose a local http://localhost/127.0.0.1/[::1] service, and close to revoke a tunnel.", + parameters: Type.Object({ + action: Type.Union( + [Type.Literal("list"), Type.Literal("create"), Type.Literal("close")], + { + description: "Tunnel action to perform.", + }, + ), + targetUrl: Type.Optional( + Type.String({ + description: + "Required for action=create. Local HTTP target, e.g. http://localhost:3000 or http://127.0.0.1:5173/app.", + }), + ), + name: Type.Optional( + Type.String({ + description: "Optional display name for a created tunnel.", + }), + ), + ttlSeconds: Type.Optional( + Type.Union([Type.Literal(0), Type.Literal(900), Type.Literal(3600), Type.Literal(14400)], { + description: "Optional tunnel lifetime. Use 0 for unlimited. Defaults to 3600 seconds.", + }), + ), + id: Type.Optional( + Type.String({ + description: "Tunnel id for action=close. Preferred over slug when available.", + }), + ), + slug: Type.Optional( + Type.String({ + description: "Tunnel slug for action=close when id is not known.", + }), + ), + }), +}; + +function asErrorMessage(err: unknown) { + return err instanceof Error ? err.message : String(err); +} + +function asArgs(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function normalizeAction(value: unknown): TunnelManagerAction { + if (value === "list" || value === "create" || value === "close") { + return value; + } + throw new Error('TunnelManager.action must be "list", "create", or "close".'); +} + +function normalizeTtlSeconds(value: unknown): TunnelTtlSeconds { + if (value === undefined || value === null) { + return 3600; + } + if (value === 0 || value === 900 || value === 3600 || value === 14400) { + return value; + } + throw new Error("TunnelManager.ttlSeconds must be 0, 900, 3600, or 14400."); +} + +function normalizeOptionalText(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function formatRemaining(expiresAt: number) { + if (!expiresAt) return "unlimited"; + const seconds = Math.max(0, Math.floor(expiresAt - Date.now() / 1000)); + if (seconds <= 0) return "expired"; + const hours = Math.floor(seconds / 3600); + const minutes = Math.ceil((seconds % 3600) / 60); + if (hours <= 0) return `${minutes}m`; + return minutes > 0 && minutes < 60 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function stripConnectionCount(tunnel: TunnelSummary): TunnelManagerTunnelSummary { + const { activeConnections: _activeConnections, ...summary } = tunnel; + return summary; +} + +function formatTunnelLine(tunnel: TunnelManagerTunnelSummary) { + const name = tunnel.name.trim() || tunnel.targetUrl; + return [ + `- ${name}`, + ` id: ${tunnel.id}`, + ` slug: ${tunnel.slug}`, + ` target: ${tunnel.targetUrl}`, + ` public: ${tunnel.publicUrl}`, + ` status: ${tunnel.status}`, + ` ttl: ${formatRemaining(tunnel.expiresAt)}`, + ].join("\n"); +} + +function okResult(params: { + toolCall: ToolCall; + action: TunnelManagerAction; + text: string; + tunnels?: TunnelManagerTunnelSummary[]; + tunnel?: TunnelManagerTunnelSummary; +}): ToolResultMessage { + const details: TunnelManagerDetails = { + kind: "tunnel_manager", + action: params.action, + ...(params.tunnels ? { tunnels: params.tunnels } : {}), + ...(params.tunnel ? { tunnel: params.tunnel } : {}), + }; + return { + role: "toolResult", + toolCallId: params.toolCall.id, + toolName: params.toolCall.name, + content: [{ type: "text", text: params.text }], + details, + isError: false, + timestamp: Date.now(), + }; +} + +function errorResult( + toolCall: ToolCall, + message: string, + action: TunnelManagerAction = "list", +): ToolResultMessage { + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: `TunnelManager failed: ${message}` }], + details: { + kind: "tunnel_manager", + action, + errors: [message], + }, + isError: true, + timestamp: Date.now(), + }; +} + +async function listTunnels() { + return invoke("gateway_tunnel_list"); +} + +async function createTunnel(input: TunnelCreateInput) { + return invoke("gateway_tunnel_create", { input }); +} + +async function closeTunnel(id: string) { + return invoke("gateway_tunnel_close", { tunnel_id: id }); +} + +async function executeTunnelManager( + toolCall: ToolCall, + params: { + projectPathKey?: string; + onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; + }, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: "Cancelled" }], + details: {}, + isError: true, + timestamp: Date.now(), + }; + } + + try { + const args = asArgs(toolCall.arguments); + const action = normalizeAction(args.action); + + if (action === "list") { + const tunnels = (await listTunnels()).map(stripConnectionCount); + const text = + tunnels.length === 0 + ? "No Remote HTTP tunnels are currently registered." + : ["Remote HTTP tunnels:", ...tunnels.map(formatTunnelLine)].join("\n"); + return okResult({ toolCall, action, text, tunnels }); + } + + if (action === "create") { + const targetUrl = normalizeOptionalText(args.targetUrl); + if (!targetUrl) { + throw new Error("TunnelManager.targetUrl is required for action=create."); + } + const tunnel = await createTunnel({ + targetUrl, + name: normalizeOptionalText(args.name) || undefined, + ttlSeconds: normalizeTtlSeconds(args.ttlSeconds), + ...(params.projectPathKey?.trim() ? { projectPathKey: params.projectPathKey.trim() } : {}), + }); + const visibleTunnel = stripConnectionCount(tunnel); + await params.onTunnelsChanged?.({ action: "create", tunnel: visibleTunnel }); + return okResult({ + toolCall, + action, + text: ["Created Remote HTTP tunnel:", formatTunnelLine(visibleTunnel)].join("\n"), + tunnel: visibleTunnel, + }); + } + + const id = normalizeOptionalText(args.id); + const slug = normalizeOptionalText(args.slug); + if (!id && !slug) { + throw new Error("TunnelManager.id or TunnelManager.slug is required for action=close."); + } + + let tunnelId = id; + if (!tunnelId) { + const tunnels = await listTunnels(); + tunnelId = tunnels.find((tunnel) => tunnel.slug === slug)?.id ?? ""; + if (!tunnelId) { + throw new Error(`No tunnel found for slug "${slug}".`); + } + } + const tunnel = await closeTunnel(tunnelId); + const visibleTunnel = stripConnectionCount(tunnel); + await params.onTunnelsChanged?.({ action: "close", tunnel: visibleTunnel }); + return okResult({ + toolCall, + action, + text: ["Closed Remote HTTP tunnel:", formatTunnelLine(visibleTunnel)].join("\n"), + tunnel: visibleTunnel, + }); + } catch (err) { + const args = asArgs(toolCall.arguments); + const action = + args.action === "create" || args.action === "close" || args.action === "list" + ? args.action + : undefined; + return errorResult(toolCall, asErrorMessage(err), action); + } +} + +export function createTunnelManagerTools(params: { + enabled: boolean; + runtimeScope: "chat" | "cron_auto_prompt"; + projectPathKey?: string; + onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; +}): BuiltinToolBundle { + const tools = params.enabled && params.runtimeScope === "chat" ? [TUNNEL_MANAGER_TOOL] : []; + return { + groupId: "system", + tools, + executeToolCall: (toolCall, signal) => + executeTunnelManager( + toolCall, + { + projectPathKey: params.projectPathKey, + onTunnelsChanged: params.onTunnelsChanged, + }, + signal, + ), + metadataByName: createBuiltinMetadataMap( + tools.map((tool) => [ + tool.name, + { + groupId: "system" as const, + kind: "tunnel_manager", + isReadOnly: false, + displayCategory: "system" as const, + }, + ]), + ), + }; +} diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 10e0033c0..046e1b8c2 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -27,6 +27,12 @@ import { type NotifyItem, NotifyToast } from "../components/chat/NotifyToast"; import { SharedHistoryManagerModal } from "../components/chat/SharedHistoryManagerModal"; import { Ban, PanelRightClose, PanelRightOpen, Terminal, Upload } from "../components/icons"; import { ProjectToolsPanel } from "../components/project-tools/ProjectToolsPanel"; +import type { + LocalTunnelClient, + TunnelCreateInput, + TunnelSummary, + TunnelUpdateInput, +} from "../components/project-tools/LocalTunnelPanel"; import type { WorkspaceCodeEditorOpenRequest } from "../components/workspace-editor/WorkspaceCodeEditorOverlay"; import type { WorkspaceImagePreviewOpenRequest } from "../components/workspace-editor/WorkspaceImagePreviewOverlay"; import { isWorkspaceImagePath } from "../components/workspace-editor/workspaceImagePreview"; @@ -112,6 +118,7 @@ import { isAgentExecutionMode, isProjectToolsFileTreeOpen, isProjectToolsGitReviewOpen, + isProjectToolsTunnelOpen, normalizeChatRuntimeControlsForProvider, type ProviderId, type SelectedModel, @@ -125,6 +132,7 @@ import { updateProjectToolsFileTreeProjectState, updateProjectToolsFileTreeOpen, updateProjectToolsGitReviewOpen, + updateProjectToolsTunnelOpen, updateProjectToolsPanelTabOrder, updateChatRuntimeControlsForProvider, updateMcp, @@ -700,6 +708,7 @@ export function ChatPage(props: ChatPageProps) { const [sidebarOpen, setSidebarOpen] = useState(true); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false); + const [tunnelRefreshToken, setTunnelRefreshToken] = useState(0); const previousProjectToolsFileTreeOpenRef = useRef(false); const [workspaceEditorMounted, setWorkspaceEditorMounted] = useState(false); const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); @@ -717,6 +726,17 @@ export function ChatPage(props: ChatPageProps) { const [remoteRuntimeStatus, setRemoteRuntimeStatus] = useState(() => buildFallbackGatewayStatus(settings.remote), ); + const tauriTunnelClient = useMemo( + () => ({ + listTunnels: () => invoke("gateway_tunnel_list"), + createTunnel: (input: TunnelCreateInput) => + invoke("gateway_tunnel_create", { input }), + updateTunnel: (input: TunnelUpdateInput) => + invoke("gateway_tunnel_update", { input }), + closeTunnel: (id: string) => invoke("gateway_tunnel_close", { tunnel_id: id }), + }), + [], + ); const { historyItems, @@ -762,6 +782,15 @@ export function ChatPage(props: ChatPageProps) { remoteRuntimeStatus.online === true && remoteRuntimeStatus.enabled === true && remoteRuntimeStatus.configured === true; + const tunnelManagerToolAvailable = + isAgentMode && settings.remote.enableWebTunnels === true && canShareHistory; + const tunnelManagerToolDisabledMessage = !isAgentMode + ? t("chat.runtime.tunnelAgentModeRequired") + : !settings.remote.enableWebTunnels + ? t("chat.runtime.tunnelWebDisabled") + : !canShareHistory + ? t("chat.runtime.tunnelRemoteOffline") + : undefined; const refreshHistoryWorkdirs = useCallback(async () => { try { @@ -1088,6 +1117,31 @@ export function ChatPage(props: ChatPageProps) { [activateWorkspaceProject, checkWorkspaceProjectDirectory, setSettings], ); + const openTunnelToolPanel = useCallback((projectPathKey?: string) => { + const targetProjectPathKey = + workspaceProjectPathKey(projectPathKey) || workspaceProjectPathKey(activeWorkspaceProjectPath); + if (!targetProjectPathKey) return; + setActiveView("chat"); + setProjectToolsPanelOpen(true); + setSettings((prev) => + updateProjectToolsTunnelOpen( + updateCustomSettings(prev, { + projectToolsPanel: { + ...prev.customSettings.projectToolsPanel, + activeTab: "tunnel", + }, + }), + targetProjectPathKey, + true, + ), + ); + }, [activeWorkspaceProjectPath, setSettings]); + + const handleOpenTunnelToolPanel = useCallback(() => { + if (!tunnelManagerToolAvailable) return; + openTunnelToolPanel(); + }, [openTunnelToolPanel, tunnelManagerToolAvailable]); + const handleBrowseWorkspaceProjectInSystemFileManager = useCallback( async (project: WorkspaceProject) => { if (!(await checkWorkspaceProjectDirectory(project))) { @@ -1478,11 +1532,21 @@ export function ChatPage(props: ChatPageProps) { settings.customSettings, terminalProjectPathKey, ); + const projectToolsTunnelOpen = isProjectToolsTunnelOpen( + settings.customSettings, + terminalProjectPathKey, + ); const terminalDisabledMessage = !isAgentMode ? "Project tools require Agent project mode." : !terminalProjectPath ? "Select a project to use project tools." : undefined; + const tunnelEnabled = settings.remote.enableWebTunnels === true && remoteRuntimeStatus.online; + const tunnelDisabledMessage = !settings.remote.enableWebTunnels + ? t("projectTools.tunnelWebDisabled") + : !remoteRuntimeStatus.online + ? t("projectTools.tunnelRemoteOffline") + : undefined; const handleOpenWorkspaceFile = useCallback( (path: string) => { if (!terminalProjectPath || !terminalProjectPathKey) return; @@ -2649,7 +2713,14 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeEvents.emitError(message, conversationId); throw new Error(message); } - if (runtimeEntry.isSending) return; + if (runtimeEntry.isSending) { + if (gatewayBridgeRequest) { + const message = "Conversation is already sending."; + gatewayBridgeEvents.emitError(message, conversationId); + gatewayBridgeEvents.close(); + } + return; + } if (isImportingPastedTextRef.current && typeof overrides?.textOverride !== "string") { return; } @@ -2767,7 +2838,14 @@ export function ChatPage(props: ChatPageProps) { } const userMessage = createUserMessageWithUploads(text, uploadedFiles, Date.now()); - if (!userMessage) return; + if (!userMessage) { + if (gatewayBridgeRequest) { + const message = "Message is required."; + gatewayBridgeEvents.emitError(message, conversationId); + gatewayBridgeEvents.close(); + } + return; + } const pendingUserMessage = userMessage; const content = typeof pendingUserMessage.content === "string" ? pendingUserMessage.content : ""; @@ -3546,6 +3624,14 @@ export function ChatPage(props: ChatPageProps) { }, enabledMcpServerIds, selectableMcpServers, + remoteWebTunnelsEnabled: settings.remote.enableWebTunnels, + remoteGatewayOnline: canShareHistory, + onTunnelsChanged: (change) => { + setTunnelRefreshToken((current) => current + 1); + if (change.action === "create") { + openTunnelToolPanel(change.tunnel.projectPathKey); + } + }, sessionId, conversationId, conversationCwd, @@ -4441,6 +4527,8 @@ export function ChatPage(props: ChatPageProps) { chatRuntimeControls={chatRuntimeControlsForCurrentProvider} reasoningOptions={chatRuntimeReasoningOptions} gitClient={tauriGitClient} + tunnelToolAvailable={tunnelManagerToolAvailable} + tunnelToolDisabledMessage={tunnelManagerToolDisabledMessage} onGitChanged={(gitWorkdir) => window.dispatchEvent( new CustomEvent("liveagent:git-changed", { @@ -4448,6 +4536,7 @@ export function ChatPage(props: ChatPageProps) { }), ) } + onOpenTunnelToolPanel={handleOpenTunnelToolPanel} onSend={handleSend} onStop={handleStopSending} onComposerBusyChange={handleComposerBusyChange} @@ -4590,9 +4679,14 @@ export function ChatPage(props: ChatPageProps) { terminalProjectPathKey, )} gitReviewOpen={isProjectToolsGitReviewOpen(settings.customSettings, terminalProjectPathKey)} + tunnelOpen={projectToolsTunnelOpen} client={tauriTerminalClient} gitClient={tauriGitClient} gitWriteEnabled + tunnelClient={isAgentMode ? tauriTunnelClient : null} + tunnelEnabled={tunnelEnabled} + tunnelDisabledMessage={tunnelDisabledMessage} + tunnelRefreshToken={tunnelRefreshToken} onWidthChange={(nextWidth) => setSettings((prev) => updateCustomSettings(prev, { @@ -4629,6 +4723,9 @@ export function ChatPage(props: ChatPageProps) { onGitReviewOpenChange={(open) => setSettings((prev) => updateProjectToolsGitReviewOpen(prev, terminalProjectPathKey, open)) } + onTunnelOpenChange={(open) => + setSettings((prev) => updateProjectToolsTunnelOpen(prev, terminalProjectPathKey, open)) + } onSessionsChange={setProjectTerminalSessions} onInsertFileMention={(path, kind) => { composerRef.current?.insertFileMention(path, kind); diff --git a/crates/agent-gui/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gui/src/pages/chat/ChatComposerBar.tsx index 75872e50e..2a7966591 100644 --- a/crates/agent-gui/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gui/src/pages/chat/ChatComposerBar.tsx @@ -9,6 +9,7 @@ import { Brain, Globe2, Lightbulb, + Link2, Loader2, Paperclip, Send, @@ -77,7 +78,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; + tunnelToolAvailable?: boolean; + tunnelToolDisabledMessage?: string; onGitChanged?: (workdir: string) => void; + onOpenTunnelToolPanel?: () => void; onSend: () => void; onStop: () => void; onComposerBusyChange: (isBusy: boolean) => void; @@ -101,7 +105,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { gitClient, gitWriteEnabled = true, gitDisabledMessage, + tunnelToolAvailable = false, + tunnelToolDisabledMessage, onGitChanged, + onOpenTunnelToolPanel, onSend, onStop, onComposerBusyChange, @@ -129,6 +136,11 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { ? t("chat.runtime.thinkingUnavailable") : t("chat.runtime.thinkingTooltip"); const webSearchTooltip = t("chat.runtime.webSearchTooltip"); + const tunnelTooltip = + tunnelToolDisabledMessage ?? + (tunnelToolAvailable + ? t("chat.runtime.tunnelToolAvailable") + : t("chat.runtime.tunnelToolUnavailable")); useEffect(() => { if (reasoningOptions.length > 0 && reasoningOptions.includes(chatRuntimeControls.reasoning)) { @@ -302,6 +314,31 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { + + + + {reasoningOptions.length > 0 ? ( { + if (!settings.selectedModel) { + throw new Error("请先在左上角选择一个模型(或先去设置添加模型)。"); + } + + const { customProviderId, model } = settings.selectedModel; + const provider = settings.customProviders.find((item) => item.id === customProviderId); + if (!provider) { + throw new Error("所选供应商不存在,请重新选择模型。"); + } + if (!provider.activeModels.includes(model)) { + throw new Error("所选模型未启用,请重新选择模型。"); + } + + return { + selectedModel: settings.selectedModel, + provider, + providerId: provider.type, + model, + }; + }; + + if (!gatewaySelectedModel) { + return resolveLocalSelection(); + } + + const customProviderId = gatewaySelectedModel.customProviderId.trim(); + const model = gatewaySelectedModel.model.trim(); + const providerType = normalizeGatewayProviderType(gatewaySelectedModel.providerType); + if (!customProviderId || !model || !providerType) { + throw new Error("远程请求携带的模型配置无效,请在 WebUI 重新选择模型后重试。"); + } + + const provider = settings.customProviders.find((item) => item.id === customProviderId); + if (!provider) { + throw new Error("远程请求所选模型对应的供应商不存在,请同步桌面端设置后在 WebUI 重新选择模型。"); + } + if (provider.type !== providerType) { + throw new Error("远程请求所选模型的供应商类型与桌面端配置不一致,请同步桌面端设置后在 WebUI 重新选择模型。"); + } + if (!provider.activeModels.includes(model)) { + throw new Error("远程请求所选模型未在桌面端启用,请同步桌面端设置后在 WebUI 重新选择模型。"); + } + + return { + selectedModel: { + customProviderId: provider.id, + model, + }, + provider, + providerId: provider.type, + model, + }; +} diff --git a/crates/agent-gui/src/pages/chat/useGatewayBridgeBatcher.ts b/crates/agent-gui/src/pages/chat/useGatewayBridgeBatcher.ts index 03fb23797..f379e774d 100644 --- a/crates/agent-gui/src/pages/chat/useGatewayBridgeBatcher.ts +++ b/crates/agent-gui/src/pages/chat/useGatewayBridgeBatcher.ts @@ -10,11 +10,16 @@ type BatchableGatewayBridgeEvent = { type PendingGatewayBridgeEventBatch = BatchableGatewayBridgeEvent & { requestId: string; + workerId?: string; rafId: number | null; timeoutId: number | null; microtaskQueued: boolean; }; +type GatewayBridgeSendOptions = { + workerId?: string; +}; + const GATEWAY_BRIDGE_BATCH_MAX_DELAY_MS = 32; const GATEWAY_BRIDGE_BATCH_MAX_TEXT_LENGTH = 640; @@ -61,13 +66,15 @@ export function useGatewayBridgeBatcher() { ); const sendGatewayBridgeEventForRequest = useCallback( - (requestId: string, event: Record) => { + (requestId: string, event: Record, options?: GatewayBridgeSendOptions) => { + const workerId = options?.workerId?.trim() || undefined; gatewayEventChainRef.current = gatewayEventChainRef.current .catch(() => undefined) .then(() => invoke("gateway_send_chat_event", { request_id: requestId, event, + worker_id: workerId, } as any), ) .then(() => undefined) @@ -102,6 +109,8 @@ export function useGatewayBridgeBatcher() { text: pending.text, conversation_id: pending.conversationId, ...(pending.round !== null ? { round: pending.round } : {}), + }, { + workerId: pending.workerId, }); }, [sendGatewayBridgeEventForRequest], @@ -157,20 +166,22 @@ export function useGatewayBridgeBatcher() { ); const queueGatewayBridgeEventForRequest = useCallback( - (requestId: string, event: Record) => { + (requestId: string, event: Record, options?: GatewayBridgeSendOptions) => { const batchable = toBatchableGatewayBridgeEvent(event); if (!batchable) { flushGatewayBridgeEventBatchForRequest(requestId); - sendGatewayBridgeEventForRequest(requestId, event); + sendGatewayBridgeEventForRequest(requestId, event, options); return; } + const workerId = options?.workerId?.trim() || undefined; const existing = pendingGatewayBridgeEventBatchesRef.current.get(requestId); if ( existing && existing.type === batchable.type && existing.conversationId === batchable.conversationId && - existing.round === batchable.round + existing.round === batchable.round && + existing.workerId === workerId ) { existing.text += batchable.text; if (existing.text.length >= GATEWAY_BRIDGE_BATCH_MAX_TEXT_LENGTH) { @@ -184,6 +195,7 @@ export function useGatewayBridgeBatcher() { flushGatewayBridgeEventBatchForRequest(requestId); pendingGatewayBridgeEventBatchesRef.current.set(requestId, { requestId, + workerId, ...batchable, rafId: null, timeoutId: null, diff --git a/crates/agent-gui/src/pages/chat/useGatewayBridgeListeners.ts b/crates/agent-gui/src/pages/chat/useGatewayBridgeListeners.ts index e6eb8d5b0..2397024b5 100644 --- a/crates/agent-gui/src/pages/chat/useGatewayBridgeListeners.ts +++ b/crates/agent-gui/src/pages/chat/useGatewayBridgeListeners.ts @@ -15,15 +15,20 @@ import { import { type ActiveGatewayBridgeRequest, type GatewayBridgeRuntimeRefs, + type GatewayChatClaimedRequest, type GatewayChatCancelEvent, - type GatewayChatRequestEvent, + type GatewayChatRequestReadyEvent, type GatewayHistoryTruncatedEvent, normalizeGatewayExecutionMode, normalizeGatewayWorkdir, } from "./gatewayBridgeTypes"; type UseGatewayBridgeListenersParams = GatewayBridgeRuntimeRefs & { - queueGatewayBridgeEventForRequest: (requestId: string, event: Record) => void; + queueGatewayBridgeEventForRequest: ( + requestId: string, + event: Record, + options?: { workerId?: string }, + ) => void; isConversationRunning: (conversationId: string) => boolean; getConversationAbortController: (conversationId: string) => AbortController | null; syncVisibleConversationRuntime: (conversationId: string, entry: ConversationRuntimeEntry) => void; @@ -34,8 +39,22 @@ type GatewayBridgeRequestRegistry = { activeRequests: Map; pendingRequestIds: Set; pendingClientRequestIds: Set; + pendingConversationIds: Set; }; +type GatewayBridgeClaimResult = + | "claimed" + | "duplicate_request" + | "duplicate_client_request" + | "conversation_busy"; + +const GATEWAY_CHAT_RUNTIME_LEASE_MS = 15_000; +const GATEWAY_CHAT_RUNTIME_HEARTBEAT_MS = 5_000; +const GATEWAY_CHAT_RUNTIME_IDLE_POLL_MS = 5_000; +const GATEWAY_CHAT_RUNTIME_STATUS_HEARTBEAT_MS = 5_000; +const GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE = + "Another remote gateway chat request is already running."; + const gatewayBridgeRequestRegistry = (() => { const root = globalThis as typeof globalThis & { __LIVEAGENT_GATEWAY_BRIDGE_REQUESTS__?: GatewayBridgeRequestRegistry; @@ -44,7 +63,9 @@ const gatewayBridgeRequestRegistry = (() => { activeRequests: new Map(), pendingRequestIds: new Set(), pendingClientRequestIds: new Set(), + pendingConversationIds: new Set(), }; + root.__LIVEAGENT_GATEWAY_BRIDGE_REQUESTS__.pendingConversationIds ??= new Set(); return root.__LIVEAGENT_GATEWAY_BRIDGE_REQUESTS__; })(); @@ -58,6 +79,10 @@ function asErrorMessage(error: unknown, fallback: string) { return fallback; } +function isConversationAlreadyRunningError(message: string) { + return message.trim().startsWith("Conversation is already running:"); +} + export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParams) { const { currentConversationIdRef, @@ -76,21 +101,51 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam useEffect(() => { let disposed = false; - let unlistenChatRequest: (() => void) | null = null; + let unlistenChatRequestReady: (() => void) | null = null; let unlistenChatCancel: (() => void) | null = null; let unlistenHistoryTruncate: (() => void) | null = null; + let unlistenGatewayStatus: (() => void) | null = null; + let drainInFlight = false; + const workerId = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? `gateway-chat-runtime-${crypto.randomUUID()}` + : `gateway-chat-runtime-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const heartbeatTimers = new Map(); + + const activeRuntimeRequestCount = () => + gatewayBridgeRequestRegistry.activeRequests.size + + gatewayBridgeRequestRegistry.pendingRequestIds.size; + + const runtimeVisible = () => + typeof document === "undefined" ? true : document.visibilityState !== "hidden"; + + const publishRuntimeHeartbeat = (state?: "ready" | "draining" | "busy" | "suspended") => { + const activeRunCount = activeRuntimeRequestCount(); + const nextState = state ?? (activeRunCount > 0 ? "busy" : "ready"); + void invoke("gateway_chat_runtime_heartbeat", { + worker_id: workerId, + state: nextState, + visible: runtimeVisible(), + active_run_count: activeRunCount, + } as any).catch((error) => { + console.warn("gateway_chat_runtime_heartbeat failed", error); + }); + }; const setActiveGatewayBridgeRequest = (request: ActiveGatewayBridgeRequest) => { gatewayBridgeRequestRegistry.pendingRequestIds.delete(request.requestId); if (request.clientRequestId) { gatewayBridgeRequestRegistry.pendingClientRequestIds.delete(request.clientRequestId); } + gatewayBridgeRequestRegistry.pendingConversationIds.delete(request.conversationId); gatewayBridgeRequestRegistry.activeRequests.set(request.requestId, request); + publishRuntimeHeartbeat("busy"); return request; }; const clearActiveGatewayBridgeRequest = (requestId: string) => { gatewayBridgeRequestRegistry.activeRequests.delete(requestId.trim()); + publishRuntimeHeartbeat(); }; const getActiveGatewayBridgeRequestByRequestId = (requestId: string) => { @@ -125,42 +180,112 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam return null; }; - const claimGatewayBridgeRequest = (requestId: string, clientRequestId: string) => { + const claimGatewayBridgeRequest = ( + requestId: string, + clientRequestId: string, + conversationId: string, + ): GatewayBridgeClaimResult => { + const targetConversationId = conversationId.trim(); if ( gatewayBridgeRequestRegistry.pendingRequestIds.has(requestId) || gatewayBridgeRequestRegistry.activeRequests.has(requestId) ) { - return false; + return "duplicate_request"; } if ( clientRequestId && (gatewayBridgeRequestRegistry.pendingClientRequestIds.has(clientRequestId) || getActiveGatewayBridgeRequestByClientRequestId(clientRequestId)) ) { - return false; + return "duplicate_client_request"; + } + if ( + targetConversationId && + (gatewayBridgeRequestRegistry.pendingConversationIds.has(targetConversationId) || + getActiveGatewayBridgeRequestByConversationId(targetConversationId)) + ) { + return "conversation_busy"; } gatewayBridgeRequestRegistry.pendingRequestIds.add(requestId); if (clientRequestId) { gatewayBridgeRequestRegistry.pendingClientRequestIds.add(clientRequestId); } - return true; + if (targetConversationId) { + gatewayBridgeRequestRegistry.pendingConversationIds.add(targetConversationId); + } + publishRuntimeHeartbeat("busy"); + return "claimed"; }; const releaseGatewayBridgeRequestClaim = ( requestId: string, clientRequestId: string, + conversationId: string, request: ActiveGatewayBridgeRequest | null, ) => { gatewayBridgeRequestRegistry.pendingRequestIds.delete(requestId); if (clientRequestId) { gatewayBridgeRequestRegistry.pendingClientRequestIds.delete(clientRequestId); } + if (conversationId) { + gatewayBridgeRequestRegistry.pendingConversationIds.delete(conversationId); + } if (request) { clearActiveGatewayBridgeRequest(request.requestId); } + publishRuntimeHeartbeat(); + }; + + const stopHeartbeat = (requestId: string) => { + const timer = heartbeatTimers.get(requestId); + if (timer !== undefined) { + window.clearInterval(timer); + heartbeatTimers.delete(requestId); + } + }; + + const startHeartbeat = (requestId: string) => { + stopHeartbeat(requestId); + publishRuntimeHeartbeat("busy"); + void invoke("gateway_chat_heartbeat", { + request_id: requestId, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_heartbeat failed", error); + }); + heartbeatTimers.set( + requestId, + window.setInterval(() => { + void invoke("gateway_chat_heartbeat", { + request_id: requestId, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_heartbeat failed", error); + }); + }, GATEWAY_CHAT_RUNTIME_HEARTBEAT_MS), + ); + }; + + const failClaimedRequest = ( + requestId: string, + conversationId: string, + errorCode: string, + message: string, + ) => { + void invoke("gateway_chat_fail", { + request_id: requestId, + conversation_id: conversationId || undefined, + error_code: errorCode, + message, + terminal: true, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_fail failed", error); + }); }; - const handleGatewayChatRequest = async (payload: GatewayChatRequestEvent) => { + const handleGatewayChatRequest = async (claimed: GatewayChatClaimedRequest) => { + const payload = claimed.request; const requestId = payload.requestId.trim(); const clientRequestId = payload.clientRequestId?.trim() ?? ""; const message = payload.message.trim(); @@ -173,20 +298,102 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam if (!requestId) { return; } + startHeartbeat(requestId); if (!message && uploadedFiles.length === 0) { queueGatewayBridgeEventForRequest(requestId, { type: "error", message: "Remote chat message cannot be empty.", conversation_id: targetConversationId, + }, { + workerId, }); + failClaimedRequest( + requestId, + targetConversationId, + "empty_remote_message", + "Remote chat message cannot be empty.", + ); + stopHeartbeat(requestId); return; } - if (!claimGatewayBridgeRequest(requestId, clientRequestId)) { + const claimResult = claimGatewayBridgeRequest( + requestId, + clientRequestId, + targetConversationId, + ); + if (claimResult !== "claimed") { + if (claimResult === "conversation_busy") { + queueGatewayBridgeEventForRequest( + requestId, + { + type: "error", + message: GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + conversation_id: targetConversationId, + }, + { + workerId, + }, + ); + failClaimedRequest( + requestId, + targetConversationId, + "conversation_busy", + GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + ); + stopHeartbeat(requestId); + return; + } + void invoke("gateway_chat_release_lease", { + request_id: requestId, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_release_lease failed", error); + }); + stopHeartbeat(requestId); return; } claimedRequest = true; try { + const duplicateRequest = + getActiveGatewayBridgeRequestByRequestId(requestId) || + (clientRequestId + ? getActiveGatewayBridgeRequestByClientRequestId(clientRequestId) + : null); + if (duplicateRequest) { + void invoke("gateway_chat_release_lease", { + request_id: requestId, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_release_lease failed", error); + }); + return; + } + if ( + targetConversationId && + (isConversationRunning(targetConversationId) || + getConversationAbortController(targetConversationId)) + ) { + queueGatewayBridgeEventForRequest( + requestId, + { + type: "error", + message: GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + conversation_id: targetConversationId, + }, + { + workerId, + }, + ); + failClaimedRequest( + requestId, + targetConversationId, + "conversation_busy", + GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + ); + return; + } + resolvedConversationId = await ensureGatewayBridgeConversationReadyRef.current( targetConversationId, { @@ -205,9 +412,17 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam ) { queueGatewayBridgeEventForRequest(requestId, { type: "error", - message: "Another remote gateway chat request is already running.", + message: GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, conversation_id: runningRequest?.conversationId || resolvedConversationId, + }, { + workerId, }); + failClaimedRequest( + requestId, + runningRequest?.conversationId || resolvedConversationId, + "conversation_busy", + GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + ); return; } @@ -215,6 +430,7 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam requestId, conversationId: resolvedConversationId, clientRequestId: clientRequestId || undefined, + workerId, startedAt: Date.now(), selectedModelOverride: payload.selectedModel, runtimeControlsOverride: payload.runtimeControls @@ -233,67 +449,129 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam selectedSystemToolIdsOverride: gatewayBridgeRequest.selectedSystemToolIdsOverride, runtimeControlsOverride: gatewayBridgeRequest.runtimeControlsOverride, gatewayBridgeRequestOverride: gatewayBridgeRequest, + afterInitialHistoryPersist: async () => { + await invoke("gateway_chat_mark_started", { + request_id: requestId, + conversation_id: resolvedConversationId, + worker_id: workerId, + } as any); + }, }); + await invoke("gateway_chat_complete", { + request_id: requestId, + conversation_id: resolvedConversationId, + worker_id: workerId, + } as any); } catch (error) { + const rawMessage = asErrorMessage( + error, + "Failed to execute the remote gateway chat request.", + ); + const conversationBusy = isConversationAlreadyRunningError(rawMessage); + const message = conversationBusy + ? GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE + : rawMessage; queueGatewayBridgeEventForRequest(requestId, { type: "error", - message: asErrorMessage(error, "Failed to execute the remote gateway chat request."), + message, conversation_id: resolvedConversationId || targetConversationId || currentConversationIdRef.current, + }, { + workerId, }); + failClaimedRequest( + requestId, + resolvedConversationId || targetConversationId || currentConversationIdRef.current, + conversationBusy ? "conversation_busy" : "desktop_runtime_error", + message, + ); } finally { + stopHeartbeat(requestId); if (claimedRequest) { - releaseGatewayBridgeRequestClaim(requestId, clientRequestId, gatewayBridgeRequest); + releaseGatewayBridgeRequestClaim( + requestId, + clientRequestId, + resolvedConversationId || targetConversationId, + gatewayBridgeRequest, + ); } } }; - const takeAndHandleGatewayChatRequest = (requestId: string) => { - const normalizedRequestId = requestId.trim(); - if (!normalizedRequestId) { + const drainGatewayChatInbox = async () => { + if (drainInFlight || disposed) { return; } - void invoke("gateway_take_chat_request", { - request_id: normalizedRequestId, - }) - .then((payload) => { - if (!payload || disposed) { - return; - } - void handleGatewayChatRequest(payload); - }) - .catch((error) => { - queueGatewayBridgeEventForRequest(normalizedRequestId, { - type: "error", - message: asErrorMessage(error, "Failed to claim the remote gateway chat request."), - }); - }); - }; - - const drainPendingGatewayChatRequests = () => { - void invoke("gateway_take_pending_chat_requests") - .then((requests) => { + drainInFlight = true; + publishRuntimeHeartbeat("draining"); + try { + for (;;) { if (disposed) { return; } - for (const payload of requests) { - void handleGatewayChatRequest(payload); + const claimed = await invoke( + "gateway_chat_claim_next", + { + worker_id: workerId, + lease_ms: GATEWAY_CHAT_RUNTIME_LEASE_MS, + } as any, + ); + if (!claimed || disposed) { + return; } - }) - .catch((error) => { - console.warn("gateway_take_pending_chat_requests failed", error); - }); + void handleGatewayChatRequest(claimed); + } + } catch (error) { + console.warn("gateway_chat_claim_next failed", error); + } finally { + drainInFlight = false; + publishRuntimeHeartbeat(); + } + }; + + void listen("gateway:chat-request-ready", () => { + publishRuntimeHeartbeat("draining"); + void drainGatewayChatInbox(); + }).then((dispose) => { + if (disposed) { + dispose(); + return; + } + unlistenChatRequestReady = dispose; + publishRuntimeHeartbeat("ready"); + void drainGatewayChatInbox(); + }); + + const idlePollId = window.setInterval(() => { + publishRuntimeHeartbeat(); + void drainGatewayChatInbox(); + }, GATEWAY_CHAT_RUNTIME_IDLE_POLL_MS); + + const runtimeHeartbeatId = window.setInterval(() => { + publishRuntimeHeartbeat(); + }, GATEWAY_CHAT_RUNTIME_STATUS_HEARTBEAT_MS); + + const handleRuntimeWake = () => { + publishRuntimeHeartbeat("draining"); + void drainGatewayChatInbox(); }; - void listen("gateway:chat-request", (event) => { - takeAndHandleGatewayChatRequest(event.payload.requestId); + window.addEventListener("online", handleRuntimeWake); + window.addEventListener("focus", handleRuntimeWake); + window.addEventListener("pageshow", handleRuntimeWake); + document.addEventListener("visibilitychange", handleRuntimeWake); + document.addEventListener("resume", handleRuntimeWake); + + void listen>("gateway:status", (event) => { + if (event.payload?.online === true) { + handleRuntimeWake(); + } }).then((dispose) => { if (disposed) { dispose(); return; } - unlistenChatRequest = dispose; - drainPendingGatewayChatRequests(); + unlistenGatewayStatus = dispose; }); void listen("gateway:chat-cancel", (event) => { @@ -376,9 +654,21 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam return () => { disposed = true; - unlistenChatRequest?.(); + window.clearInterval(idlePollId); + window.clearInterval(runtimeHeartbeatId); + window.removeEventListener("online", handleRuntimeWake); + window.removeEventListener("focus", handleRuntimeWake); + window.removeEventListener("pageshow", handleRuntimeWake); + document.removeEventListener("visibilitychange", handleRuntimeWake); + document.removeEventListener("resume", handleRuntimeWake); + publishRuntimeHeartbeat("suspended"); + for (const requestId of heartbeatTimers.keys()) { + stopHeartbeat(requestId); + } + unlistenChatRequestReady?.(); unlistenChatCancel?.(); unlistenHistoryTruncate?.(); + unlistenGatewayStatus?.(); }; }, [ conversationRuntimeCacheRef, diff --git a/crates/agent-gui/src/pages/chat/usePendingUploads.ts b/crates/agent-gui/src/pages/chat/usePendingUploads.ts index 4ab5a7d21..005bc0b77 100644 --- a/crates/agent-gui/src/pages/chat/usePendingUploads.ts +++ b/crates/agent-gui/src/pages/chat/usePendingUploads.ts @@ -63,6 +63,11 @@ export function usePendingUploads(params: UsePendingUploadsParams) { const activeUploadTasksRef = useRef(0); const uploadContextRef = useRef<{ isAgentMode: boolean; workdir: string } | null>(null); const pendingUploadsByConversationRef = useRef(new Map()); + const pendingUploadedFilesRef = useRef(pendingUploadedFiles); + + useEffect(() => { + pendingUploadedFilesRef.current = pendingUploadedFiles; + }, [pendingUploadedFiles]); const beginUploadTask = useCallback(() => { activeUploadTasksRef.current += 1; @@ -81,35 +86,117 @@ export function usePendingUploads(params: UsePendingUploadsParams) { if (!previous) return; if (previous.isAgentMode === isAgentMode && previous.workdir === workdir) return; pendingUploadsByConversationRef.current.clear(); + pendingUploadedFilesRef.current = []; setPendingUploadedFiles([]); }, [isAgentMode, workdir]); + const getPendingUploadsForConversation = useCallback( + (conversationId: string) => { + const targetConversationId = conversationId.trim(); + if ( + !targetConversationId || + currentConversationIdRef.current.trim() === targetConversationId + ) { + return pendingUploadedFilesRef.current; + } + return pendingUploadsByConversationRef.current.get(targetConversationId) ?? []; + }, + [currentConversationIdRef], + ); + + const setPendingUploadsForConversation = useCallback( + (conversationId: string, nextFiles: PendingUploadedFile[]) => { + const targetConversationId = conversationId.trim(); + const normalizedFiles = nextFiles.slice(); + if (targetConversationId) { + if (normalizedFiles.length > 0) { + pendingUploadsByConversationRef.current.set(targetConversationId, normalizedFiles); + } else { + pendingUploadsByConversationRef.current.delete(targetConversationId); + } + } + if ( + !targetConversationId || + currentConversationIdRef.current.trim() === targetConversationId + ) { + pendingUploadedFilesRef.current = normalizedFiles; + setPendingUploadedFiles(normalizedFiles); + } + }, + [currentConversationIdRef], + ); + + const captureUploadTarget = useCallback(() => { + const targetConversationId = currentConversationIdRef.current.trim(); + if (!targetConversationId) { + setErrorMessage("请先选择或创建会话后再上传文件。"); + return null; + } + + const currentTargetUploads = getPendingUploadsForConversation(targetConversationId); + setPendingUploadsForConversation(targetConversationId, currentTargetUploads); + const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - currentTargetUploads.length); + if (remainingFileSlots === 0) { + addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + return null; + } + + return { + targetConversationId, + targetWorkdir: workdir, + remainingFileSlots, + }; + }, [ + addNotify, + currentConversationIdRef, + getPendingUploadsForConversation, + setErrorMessage, + setPendingUploadsForConversation, + workdir, + ]); + const appendImportedFiles = useCallback( - (result: SystemPickReadableFilesResponse, emptySelectionMessage: string) => { + ( + conversationId: string, + result: SystemPickReadableFilesResponse, + emptySelectionMessage: string, + ) => { + const targetConversationId = conversationId.trim(); if (result.files.length === 0 && result.skipped.length === 0) { return; } if (result.files.length > 0) { - setPendingUploadedFiles((prev) => { - const merged = mergePendingUploadedFiles(prev, result.files); - if (merged.length > MAX_UPLOAD_FILES) { - addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); - } - const next = merged.slice(0, MAX_UPLOAD_FILES); - pendingUploadsByConversationRef.current.set(currentConversationIdRef.current, next); - return next; - }); - composerRef.current?.focus(); + const previous = getPendingUploadsForConversation(targetConversationId); + const merged = mergePendingUploadedFiles(previous, result.files); + if (merged.length > MAX_UPLOAD_FILES) { + addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + } + const next = merged.slice(0, MAX_UPLOAD_FILES); + setPendingUploadsForConversation(targetConversationId, next); + if (currentConversationIdRef.current.trim() === targetConversationId) { + composerRef.current?.focus(); + } } if (result.files.length === 0 && result.skipped.length > 0) { - setErrorMessage(`${emptySelectionMessage}:\n${result.skipped.join("\n")}`); + if (currentConversationIdRef.current.trim() === targetConversationId) { + setErrorMessage(`${emptySelectionMessage}:\n${result.skipped.join("\n")}`); + } else { + addNotify("warning", `${emptySelectionMessage}:\n${result.skipped.join("\n")}`); + } return; } if (result.skipped.length > 0) { addNotify("warning", `以下文件已跳过:\n${result.skipped.join("\n")}`); } }, - [addNotify, composerRef, currentConversationIdRef, setErrorMessage], + [ + addNotify, + composerRef, + currentConversationIdRef, + getPendingUploadsForConversation, + setErrorMessage, + setPendingUploadsForConversation, + ], ); const pickReadableFiles = useCallback(async () => { @@ -126,22 +213,26 @@ export function usePendingUploads(params: UsePendingUploadsParams) { return; } - const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - pendingUploadedFiles.length); - if (remainingFileSlots === 0) { - addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + const uploadTarget = captureUploadTarget(); + if (!uploadTarget) { return; } + const { targetConversationId, targetWorkdir, remainingFileSlots } = uploadTarget; const finishUploadTask = beginUploadTask(); try { const result = await invoke("system_pick_readable_files", { - workdir, + workdir: targetWorkdir, maxFiles: remainingFileSlots, }); - appendImportedFiles(result, "所选文件均不受当前 Read 支持"); + appendImportedFiles(targetConversationId, result, "所选文件均不受当前 Read 支持"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - setErrorMessage(message || "导入文件失败"); + if (currentConversationIdRef.current.trim() === targetConversationId) { + setErrorMessage(message || "导入文件失败"); + } else { + addNotify("warning", message || "导入文件失败"); + } } finally { finishUploadTask(); } @@ -149,8 +240,9 @@ export function usePendingUploads(params: UsePendingUploadsParams) { addNotify, appendImportedFiles, beginUploadTask, + captureUploadTarget, + currentConversationIdRef, isAgentMode, - pendingUploadedFiles.length, setErrorMessage, workdir, ]); @@ -171,22 +263,26 @@ export function usePendingUploads(params: UsePendingUploadsParams) { return; } - const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - pendingUploadedFiles.length); - if (remainingFileSlots === 0) { - addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + const uploadTarget = captureUploadTarget(); + if (!uploadTarget) { return; } + const { targetConversationId, targetWorkdir, remainingFileSlots } = uploadTarget; const finishUploadTask = beginUploadTask(); try { const result = await invoke( "system_import_readable_file_paths", - { workdir, paths, maxFiles: remainingFileSlots }, + { workdir: targetWorkdir, paths, maxFiles: remainingFileSlots }, ); - appendImportedFiles(result, "拖入文件均不受当前 Read 支持"); + appendImportedFiles(targetConversationId, result, "拖入文件均不受当前 Read 支持"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - setErrorMessage(message || "导入文件失败"); + if (currentConversationIdRef.current.trim() === targetConversationId) { + setErrorMessage(message || "导入文件失败"); + } else { + addNotify("warning", message || "导入文件失败"); + } } finally { finishUploadTask(); } @@ -195,8 +291,9 @@ export function usePendingUploads(params: UsePendingUploadsParams) { addNotify, appendImportedFiles, beginUploadTask, + captureUploadTarget, + currentConversationIdRef, isAgentMode, - pendingUploadedFiles.length, setErrorMessage, workdir, ], @@ -218,11 +315,11 @@ export function usePendingUploads(params: UsePendingUploadsParams) { return; } - const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - pendingUploadedFiles.length); - if (remainingFileSlots === 0) { - addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + const uploadTarget = captureUploadTarget(); + if (!uploadTarget) { return; } + const { targetConversationId, targetWorkdir, remainingFileSlots } = uploadTarget; const importBatch = files.slice(0, remainingFileSlots); const ignoredForLimit = files.length - importBatch.length; @@ -231,9 +328,9 @@ export function usePendingUploads(params: UsePendingUploadsParams) { const uploadFiles = await Promise.all(importBatch.map(fileToUploadInput)); const result = await invoke( "system_import_uploaded_readable_files", - { workdir, files: uploadFiles, maxFiles: remainingFileSlots }, + { workdir: targetWorkdir, files: uploadFiles, maxFiles: remainingFileSlots }, ); - appendImportedFiles(result, "剪贴板文件均不受当前 Read 支持"); + appendImportedFiles(targetConversationId, result, "剪贴板文件均不受当前 Read 支持"); if (ignoredForLimit > 0) { addNotify( "warning", @@ -242,7 +339,11 @@ export function usePendingUploads(params: UsePendingUploadsParams) { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - setErrorMessage(message || "导入剪贴板文件失败"); + if (currentConversationIdRef.current.trim() === targetConversationId) { + setErrorMessage(message || "导入剪贴板文件失败"); + } else { + addNotify("warning", message || "导入剪贴板文件失败"); + } } finally { finishUploadTask(); } @@ -251,8 +352,9 @@ export function usePendingUploads(params: UsePendingUploadsParams) { addNotify, appendImportedFiles, beginUploadTask, + captureUploadTarget, + currentConversationIdRef, isAgentMode, - pendingUploadedFiles.length, setErrorMessage, workdir, ], @@ -260,17 +362,13 @@ export function usePendingUploads(params: UsePendingUploadsParams) { const removePendingUpload = useCallback( (relativePath: string) => { - setPendingUploadedFiles((prev) => { - const next = prev.filter((file) => file.relativePath !== relativePath); - if (next.length > 0) { - pendingUploadsByConversationRef.current.set(currentConversationIdRef.current, next); - } else { - pendingUploadsByConversationRef.current.delete(currentConversationIdRef.current); - } - return next; - }); + const targetConversationId = currentConversationIdRef.current.trim(); + const next = getPendingUploadsForConversation(targetConversationId).filter( + (file) => file.relativePath !== relativePath, + ); + setPendingUploadsForConversation(targetConversationId, next); }, - [currentConversationIdRef], + [currentConversationIdRef, getPendingUploadsForConversation, setPendingUploadsForConversation], ); return { diff --git a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs index d9c58b5e6..6b8595e42 100644 --- a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs +++ b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs @@ -12,9 +12,14 @@ function createController(options = {}) { const controller = createGatewayBridgeEventController({ conversationId: options.conversationId ?? "conversation-1", requestId: options.requestId ?? "request-1", + workerId: options.workerId, enabled: options.enabled ?? true, - sendEvent: (requestId, event) => { - sent.push({ requestId, event }); + sendEvent: (requestId, event, sendOptions) => { + const item = { requestId, event }; + if (sendOptions?.workerId) { + item.options = sendOptions; + } + sent.push(item); }, resolveErrorConversationId: options.resolveErrorConversationId, }); @@ -69,6 +74,42 @@ test("gateway bridge token forwarding tracks non-empty text only", () => { }); }); +test("gateway bridge started event is explicit and does not mark text forwarded", () => { + const { controller, sent } = createController(); + + controller.queueStarted(); + + assert.equal(controller.hasForwardedText(), false); + assert.deepEqual(sent, [ + { + requestId: "request-1", + event: { + type: "started", + conversation_id: "conversation-1", + }, + }, + ]); +}); + +test("gateway bridge events carry the remote worker lease owner", () => { + const { controller, sent } = createController({ workerId: "worker-1" }); + + controller.queueStarted(); + + assert.deepEqual(sent, [ + { + requestId: "request-1", + event: { + type: "started", + conversation_id: "conversation-1", + }, + options: { + workerId: "worker-1", + }, + }, + ]); +}); + test("gateway bridge tool status is normalized and de-duplicated", () => { const { controller, sent } = createController(); diff --git a/crates/agent-gui/test/chat/model-selection.test.mjs b/crates/agent-gui/test/chat/model-selection.test.mjs new file mode 100644 index 000000000..dbd627a4e --- /dev/null +++ b/crates/agent-gui/test/chat/model-selection.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const settings = loader.loadModule("src/lib/settings/index.ts"); +const modelSelection = loader.loadModule("src/pages/chat/modelSelection.ts"); + +function provider(overrides = {}) { + const id = overrides.id ?? "provider-1"; + const type = overrides.type ?? "codex"; + const models = overrides.models ?? ["gpt-5"]; + const activeModels = overrides.activeModels ?? models; + return { + id, + name: id, + type, + baseUrl: overrides.baseUrl ?? "https://api.example.com/v1", + apiKey: "key", + models, + activeModels, + requestFormat: type === "codex" ? "openai-responses" : undefined, + }; +} + +function appSettings(customProviders, selectedModel) { + return settings.normalizeSettings({ + customProviders, + selectedModel, + }); +} + +test("local chat model selection resolves only an enabled selected model", () => { + const app = appSettings( + [provider({ id: "openai-main", models: ["gpt-5", "gpt-5-mini"] })], + { customProviderId: "openai-main", model: "gpt-5" }, + ); + + const resolved = modelSelection.resolveEffectiveChatModelSelection(app); + + assert.equal(resolved.provider.id, "openai-main"); + assert.equal(resolved.providerId, "codex"); + assert.equal(resolved.model, "gpt-5"); + assert.deepEqual(resolved.selectedModel, { + customProviderId: "openai-main", + model: "gpt-5", + }); +}); + +test("remote chat model selection does not fall back to another provider with the same type", () => { + const app = appSettings( + [ + provider({ id: "openai-main", models: ["gpt-5"] }), + provider({ id: "openai-backup", models: ["gpt-5-mini"] }), + ], + { customProviderId: "openai-main", model: "gpt-5" }, + ); + + assert.throws( + () => + modelSelection.resolveEffectiveChatModelSelection(app, { + customProviderId: "missing-openai", + model: "gpt-5-mini", + providerType: "codex", + }), + /供应商不存在/, + ); +}); + +test("remote chat model selection rejects provider type drift", () => { + const app = appSettings( + [provider({ id: "anthropic-main", type: "claude_code", models: ["claude-sonnet"] })], + { customProviderId: "anthropic-main", model: "claude-sonnet" }, + ); + + assert.throws( + () => + modelSelection.resolveEffectiveChatModelSelection(app, { + customProviderId: "anthropic-main", + model: "claude-sonnet", + providerType: "codex", + }), + /供应商类型.*不一致/, + ); +}); + +test("remote chat model selection rejects models that are no longer enabled", () => { + const app = appSettings( + [ + provider({ + id: "openai-main", + models: ["gpt-5", "gpt-5-mini"], + activeModels: ["gpt-5"], + }), + ], + { customProviderId: "openai-main", model: "gpt-5" }, + ); + + assert.throws( + () => + modelSelection.resolveEffectiveChatModelSelection(app, { + customProviderId: "openai-main", + model: "gpt-5-mini", + providerType: "codex", + }), + /未在桌面端启用/, + ); +}); + +test("remote chat model selection accepts an exact enabled provider model", () => { + const app = appSettings( + [provider({ id: "gemini-main", type: "gemini", models: ["gemini-3.5-flash"] })], + { customProviderId: "gemini-main", model: "gemini-3.5-flash" }, + ); + + const resolved = modelSelection.resolveEffectiveChatModelSelection(app, { + customProviderId: "gemini-main", + model: "gemini-3.5-flash", + providerType: "gemini", + }); + + assert.equal(resolved.provider.id, "gemini-main"); + assert.equal(resolved.providerId, "gemini"); + assert.deepEqual(resolved.selectedModel, { + customProviderId: "gemini-main", + model: "gemini-3.5-flash", + }); +}); From 02f394c8b5683c322ca93f8244c3ce74ff024624 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Wed, 10 Jun 2026 10:37:43 +0800 Subject: [PATCH 6/9] fix(webui): recover chat starts and isolate composer state --- .../test/webui/gateway-socket-client.test.mjs | 251 ++++++++++++ crates/agent-gateway/web/src/App.tsx | 368 +++++++++++++++--- .../web/src/lib/gatewaySocket.ts | 252 +++++++++++- .../web/src/lib/gatewaySocket.worker.ts | 13 +- .../agent-gateway/web/src/lib/gatewayTypes.ts | 40 ++ .../web/src/pages/chat/ChatComposerBar.tsx | 41 +- 6 files changed, 869 insertions(+), 96 deletions(-) diff --git a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs index 53f864fa9..7905c433e 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -70,12 +70,44 @@ function installBrowser(options = {}) { FakeWebSocket.instances = []; globalThis.WebSocket = FakeWebSocket; delete globalThis.SharedWorker; + const windowListeners = new Map(); + const documentListeners = new Map(); + const addListener = (listeners, type, listener) => { + const items = listeners.get(type) ?? new Set(); + items.add(listener); + listeners.set(type, items); + }; + const removeListener = (listeners, type, listener) => { + listeners.get(type)?.delete(listener); + }; + const dispatch = (listeners, event) => { + const type = event?.type; + if (typeof type !== "string") return; + for (const listener of listeners.get(type) ?? []) { + listener(event); + } + }; globalThis.window = { location: { origin: "https://gateway.example" }, setTimeout: options.setTimeout ?? setTimeout, clearTimeout: options.clearTimeout ?? clearTimeout, setInterval: options.setInterval ?? setInterval, clearInterval: options.clearInterval ?? clearInterval, + addEventListener: (type, listener) => addListener(windowListeners, type, listener), + removeEventListener: (type, listener) => removeListener(windowListeners, type, listener), + dispatchEvent: (event) => { + dispatch(windowListeners, event); + return true; + }, + }; + globalThis.document = { + visibilityState: options.visibilityState ?? "visible", + addEventListener: (type, listener) => addListener(documentListeners, type, listener), + removeEventListener: (type, listener) => removeListener(documentListeners, type, listener), + dispatchEvent: (event) => { + dispatch(documentListeners, event); + return true; + }, }; } @@ -216,6 +248,35 @@ test("SharedWorker gateway client sends conversation cancel even without a local resetGatewayWebSocketClient(); }); +test("SharedWorker gateway client forwards foreground wakeups to the worker", async () => { + installBrowser(); + FakeSharedWorker.instances = []; + globalThis.SharedWorker = FakeSharedWorker; + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + resetGatewayWebSocketClient(); + + getGatewayWebSocketClient(" token "); + assert.equal(FakeSharedWorker.instances.length, 1); + const port = FakeSharedWorker.instances[0].port; + const connect = port.messages.find((message) => message.type === "connect"); + assert.ok(connect); + port.emit({ + type: "ready", + connection_id: connect.connection_id, + payload: { status: { online: true }, error: null }, + }); + + window.dispatchEvent({ type: "pageshow" }); + + assert.deepEqual(port.messages.at(-1), { + type: "wakeup", + connection_id: connect.connection_id, + }); + + resetGatewayWebSocketClient(); +}); + test("SharedWorker gateway client accepts terminal list sessions from worker payload", async () => { installBrowser(); FakeSharedWorker.instances = []; @@ -468,6 +529,69 @@ test("Gateway SharedWorker broadcasts events with each port connection id", asyn globalThis.onconnect = previousOnConnect; }); +test("Gateway SharedWorker applies foreground wakeups to the managed socket client", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); + const clientInstances = []; + + class MockGatewayWebSocketClient { + wakeups = 0; + + constructor(token) { + this.token = token; + clientInstances.push(this); + } + + subscribeStatus() { + return () => {}; + } + + subscribeHistory() { + return () => {}; + } + + subscribeConversation() { + return () => {}; + } + + subscribeSettings() { + return () => {}; + } + + subscribeTerminal() { + return () => {}; + } + + noteForegroundWakeup() { + this.wakeups += 1; + } + + dispose() {} + } + + const workerLoader = createWebModuleLoader({ + mocks: { + [gatewaySocketPath]: { + GatewayWebSocketClient: MockGatewayWebSocketClient, + }, + }, + }); + + const previousOnConnect = globalThis.onconnect; + workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); + + const port = new FakeMessagePort(); + globalThis.onconnect({ ports: [port] }); + port.emit({ type: "connect", connection_id: "connection-1", token: "token" }); + port.emit({ type: "wakeup", connection_id: "connection-1" }); + + assert.equal(clientInstances.length, 1); + assert.equal(clientInstances[0].wakeups, 1); + + globalThis.onconnect = previousOnConnect; +}); + test("Gateway SharedWorker terminal metadata reaches every page while output stays scoped", async () => { installBrowser(); const loader = createWebModuleLoader(); @@ -2241,6 +2365,78 @@ test("GatewayWebSocketClient reconnects before read requests when an authenticat } }); +test("GatewayWebSocketClient reconnects before chat.start after a foreground restore", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + resetGatewayWebSocketClient(); + + const realDateNow = Date.now; + try { + const client = getGatewayWebSocketClient("token"); + const statusPromise = client.getStatus(); + const firstSocket = await connectAndAuth(); + await waitFor(() => firstSocket.sent.some((item) => item.type === "status.get"), "initial status.get"); + const statusRequest = firstSocket.sent.find((item) => item.type === "status.get"); + firstSocket.receive({ + id: statusRequest.id, + type: "response", + payload: { online: true, agent_id: "desktop-agent" }, + }); + await statusPromise; + + let mockNow = realDateNow(); + Date.now = () => mockNow; + mockNow += 12_000; + window.dispatchEvent({ type: "pageshow" }); + + const stream = client.chat("hello", "conversation-1"); + const firstEventPromise = stream.next(); + assert.equal(firstSocket.readyState, FakeWebSocket.CLOSED); + assert.equal(FakeWebSocket.instances.length, 2); + + Date.now = realDateNow; + + const reconnectSocket = FakeWebSocket.instances[1]; + reconnectSocket.open(); + await waitFor(() => reconnectSocket.sent.length >= 1, "foreground reconnect auth envelope"); + reconnectSocket.receive({ + id: reconnectSocket.sent[0].id, + type: "response", + payload: { ok: true }, + }); + await waitFor( + () => reconnectSocket.sent.some((item) => item.type === "chat.start"), + "chat.start after foreground reconnect", + ); + const chatStart = reconnectSocket.sent.find((item) => item.type === "chat.start"); + assert.deepEqual(chatStart.payload.conversation_id, "conversation-1"); + + reconnectSocket.receive({ + id: chatStart.id, + type: "chat.control", + payload: { + type: "started", + state: "running", + conversation_id: "conversation-1", + seq: 1, + }, + }); + assert.deepEqual(await firstEventPromise, { + value: { + type: "started", + state: "running", + conversation_id: "conversation-1", + seq: 1, + }, + done: false, + }); + } finally { + Date.now = realDateNow; + resetGatewayWebSocketClient(); + } +}); + test("GatewayWebSocketClient retries history.get after a recoverable transport stall timeout", async () => { const realSetTimeout = setTimeout; installBrowser({ @@ -2291,6 +2487,61 @@ test("GatewayWebSocketClient retries history.get after a recoverable transport s resetGatewayWebSocketClient(); }); +test("GatewayWebSocketClient recovers chat.start when the socket stops receiving inbound traffic", async () => { + const realSetTimeout = setTimeout; + installBrowser({ + setTimeout: (fn, delay, ...args) => + realSetTimeout(fn, delay >= 8_000 ? 0 : delay, ...args), + }); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const stream = client.chat("hello", "conversation-1"); + const firstEventPromise = stream.next(); + const firstSocket = await connectAndAuth(0); + await waitFor(() => firstSocket.sent.some((item) => item.type === "chat.start"), "chat.start envelope"); + const chatStart = firstSocket.sent.find((item) => item.type === "chat.start"); + + await waitFor(() => FakeWebSocket.instances.length === 2, "chat.start transport recovery websocket"); + const reconnectSocket = FakeWebSocket.instances[1]; + reconnectSocket.open(); + await waitFor(() => reconnectSocket.sent.length >= 1, "chat.start recovery auth envelope"); + reconnectSocket.receive({ + id: reconnectSocket.sent[0].id, + type: "response", + payload: { ok: true }, + }); + + await waitFor( + () => reconnectSocket.sent.some((item) => item.type === "chat.resume"), + "chat.start recovery resume envelope", + ); + const resume = reconnectSocket.sent.find((item) => item.type === "chat.resume"); + assert.deepEqual(resume.payload, { + request_id: chatStart.id, + conversation_id: "conversation-1", + after_seq: 0, + }); + + reconnectSocket.receive({ + id: chatStart.id, + type: "error", + error: "chat run not found", + }); + assert.deepEqual(await firstEventPromise, { + value: { + type: "error", + message: "chat run not found", + conversation_id: "conversation-1", + }, + done: false, + }); + assert.deepEqual(await stream.next(), { value: undefined, done: true }); + resetGatewayWebSocketClient(); +}); + test("GatewayWebSocketClient does not open chat streams for pre-aborted signals", async () => { installBrowser(); const loader = createWebModuleLoader(); diff --git a/crates/agent-gateway/web/src/App.tsx b/crates/agent-gateway/web/src/App.tsx index 771eb2786..973d607b5 100644 --- a/crates/agent-gateway/web/src/App.tsx +++ b/crates/agent-gateway/web/src/App.tsx @@ -59,7 +59,7 @@ import { McpHubPage } from "@/pages/mcp-hub/McpHubPage"; import type { SectionId } from "@/pages/settings/types"; import { useChatSkills } from "@/pages/chat/useChatSkills"; import { mergeAlwaysEnabledSkillNames } from "@/lib/skills"; -import { buildModelOptions, sortHistoryItems } from "@/lib/chat/chatPageHelpers"; +import { buildModelOptions, sortHistoryItems, VIBING_STATUS } from "@/lib/chat/chatPageHelpers"; import { SettingsPage } from "@/pages/SettingsPage"; import { findProviderModelConfig, @@ -111,6 +111,7 @@ import { import type { TerminalSession } from "./lib/terminal/types"; import type { AgentStatus, + ChatControlEvent, ChatEvent, ConversationSummary, GatewayHistoryEvent, @@ -383,6 +384,8 @@ const SECONDS_TIMESTAMP_MAX = 10_000_000_000; const DRAFT_HISTORY_ADOPTION_WINDOW_MS = 30_000; const LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS = 30_000; const PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS = 900; +const CHAT_RUNTIME_PREPARE_TIMEOUT_MS = 2_500; +const CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS = 1_500; const DEFAULT_BROWSER_TITLE = "LiveAgent Gateway"; const NEW_CONVERSATION_BROWSER_TITLE = "LiveAgent"; const SHARED_HISTORY_BROWSER_TITLE = "分享会话"; @@ -481,6 +484,34 @@ function isTerminalChatEvent(event: ChatEvent) { return event.type === "done" || event.type === "error"; } +function isChatControlEvent(event: ChatEvent): event is ChatControlEvent { + switch (event.type) { + case "accepted": + case "delivered": + case "claimed": + case "starting": + case "started": + case "progress": + case "completed": + case "failed": + case "cancelled": + return true; + default: + return false; + } +} + +function isTerminalChatControlEvent(event: ChatEvent) { + return ( + isChatControlEvent(event) && + (event.state === "completed" || event.state === "failed" || event.state === "cancelled") + ); +} + +function isRunningChatControlEvent(event: ChatEvent) { + return isChatControlEvent(event) && (event.state === "running" || event.type === "started"); +} + type TunnelManagerToolChange = { action: "create" | "close"; projectPathKey: string; @@ -898,6 +929,7 @@ export default function App() { const fileInputRef = useRef(null); const conversationIdRef = useRef(conversationId); const selectedHistoryIdRef = useRef(selectedHistoryId); + const statusRef = useRef(status); const chatBusyRef = useRef(chatBusy); const chatMessagesRef = useRef(chatMessages); const chatErrorRef = useRef(chatError); @@ -915,6 +947,7 @@ export default function App() { const sharedHistoryItemsRef = useRef([]); const sharedHistoryListRequestRef = useRef | null>(null); const pendingUploadedFilesRef = useRef(pendingUploadedFiles); + const pendingUploadsByConversationRef = useRef>(new Map()); const isUploadingFilesRef = useRef(isUploadingFiles); const uploadDragDepthRef = useRef(0); const localRunningConversationIdsRef = useRef>(new Set()); @@ -946,6 +979,9 @@ export default function App() { const draftConversationPinnedRef = useRef(false); const protectedConversationRef = useRef(""); const chatStartLocksRef = useRef>(new Set()); + const chatPreflightInFlightRef = useRef(false); + const chatStartInFlightRef = useRef(false); + const chatRuntimePreparePromiseRef = useRef | null>(null); const submitInFlightRef = useRef(false); const pendingDraftConversationMigrationRef = useRef( null, @@ -1170,6 +1206,10 @@ export default function App() { selectedHistoryIdRef.current = selectedHistoryId; }, [selectedHistoryId]); + useEffect(() => { + statusRef.current = status; + }, [status]); + useEffect(() => { chatBusyRef.current = chatBusy; }, [chatBusy]); @@ -1226,10 +1266,72 @@ export default function App() { pendingUploadedFilesRef.current = pendingUploadedFiles; }, [pendingUploadedFiles]); + useEffect(() => { + const displayedConversationId = resolveVisibleConversationId( + selectedHistoryId, + conversationId, + ).trim(); + const nextFiles = displayedConversationId + ? (pendingUploadsByConversationRef.current.get(displayedConversationId) ?? []) + : []; + pendingUploadedFilesRef.current = nextFiles; + setPendingUploadedFiles(nextFiles); + }, [conversationId, selectedHistoryId]); + useEffect(() => { isUploadingFilesRef.current = isUploadingFiles; }, [isUploadingFiles]); + function getDisplayedConversationId() { + return resolveVisibleConversationId( + selectedHistoryIdRef.current, + conversationIdRef.current, + ).trim(); + } + + function isDisplayedConversation(targetConversationId: string) { + const conversationIdValue = targetConversationId.trim(); + return conversationIdValue !== "" && getDisplayedConversationId() === conversationIdValue; + } + + function getPendingUploadsForConversation(targetConversationId: string) { + const conversationIdValue = targetConversationId.trim(); + if (!conversationIdValue || isDisplayedConversation(conversationIdValue)) { + return pendingUploadedFilesRef.current; + } + return pendingUploadsByConversationRef.current.get(conversationIdValue) ?? []; + } + + function setPendingUploadsForConversation( + targetConversationId: string, + nextFiles: PendingUploadedFile[], + ) { + const conversationIdValue = targetConversationId.trim(); + const normalizedFiles = nextFiles.slice(); + if (conversationIdValue) { + if (normalizedFiles.length > 0) { + pendingUploadsByConversationRef.current.set(conversationIdValue, normalizedFiles); + } else { + pendingUploadsByConversationRef.current.delete(conversationIdValue); + } + } + if (!conversationIdValue || isDisplayedConversation(conversationIdValue)) { + pendingUploadedFilesRef.current = normalizedFiles; + setPendingUploadedFiles(normalizedFiles); + } + } + + function updatePendingUploadsForConversation( + targetConversationId: string, + updater: (current: PendingUploadedFile[]) => PendingUploadedFile[], + ) { + const conversationIdValue = targetConversationId.trim(); + const currentFiles = getPendingUploadsForConversation(conversationIdValue); + const nextFiles = updater(currentFiles); + setPendingUploadsForConversation(conversationIdValue, nextFiles); + return nextFiles; + } + useEffect(() => { localRunningConversationIdsRef.current = localRunningConversationIds; }, [localRunningConversationIds]); @@ -3192,6 +3294,24 @@ export default function App() { selectedHistoryIdRef.current, conversationIdRef.current, ); + if (isChatControlEvent(event)) { + if (isRunningChatControlEvent(event)) { + setRemoteConversationRunningState(targetConversationId, true, { + workdir: event.workdir, + }); + if ( + visibleBroadcastConversationId === targetConversationId && + !isConversationLiveStreamAttached(targetConversationId) + ) { + attachVisibleConversationLiveStream(targetConversationId, api); + } + } else if (isTerminalChatControlEvent(event)) { + setRemoteConversationRunningState(targetConversationId, false, { + workdir: event.workdir, + }); + } + return; + } const isTerminalEvent = isTerminalChatEvent(event); if (!isTerminalEvent && !isChatStreamNotAvailableEvent(event)) { setRemoteConversationRunningState(targetConversationId, true, { @@ -3898,12 +4018,77 @@ export default function App() { ], ); + const prepareChatRuntime = useCallback( + async ( + reason: string, + currentApi = api, + timeoutMs = CHAT_RUNTIME_PREPARE_TIMEOUT_MS, + ): Promise => { + if (!currentApi) { + throw new Error("Gateway client is not ready."); + } + + if (!chatRuntimePreparePromiseRef.current) { + chatPreflightInFlightRef.current = true; + chatRuntimePreparePromiseRef.current = currentApi + .prepareChatRuntime(reason) + .then((nextStatus) => { + statusRef.current = nextStatus; + setStatus(nextStatus); + setStatusError(null); + return nextStatus; + }) + .catch((error) => { + setStatusError(asErrorMessage(error, "status request failed")); + throw error; + }) + .finally(() => { + chatRuntimePreparePromiseRef.current = null; + chatPreflightInFlightRef.current = false; + }); + } + + const preparePromise = chatRuntimePreparePromiseRef.current; + if (!preparePromise) { + throw new Error("Gateway chat runtime preparation did not start."); + } + if (timeoutMs <= 0) { + return preparePromise; + } + + let timeoutId: number | null = null; + try { + return await Promise.race([ + preparePromise, + new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject(new Error("Desktop chat runtime is recovering. Please retry shortly.")); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + } + }, + [api], + ); + const recoverVisibleConversationAfterPageRestore = useCallback( (currentApi = api) => { if (!currentApi) { return; } + if ( + chatPreflightInFlightRef.current || + chatStartInFlightRef.current || + submitInFlightRef.current + ) { + return; + } + const visibleConversationId = resolveVisibleConversationId( selectedHistoryIdRef.current, conversationIdRef.current, @@ -3979,6 +4164,9 @@ export default function App() { if (!api || !status?.online) { return; } + if (chatPreflightInFlightRef.current || chatStartInFlightRef.current) { + return; + } const currentConversationId = conversationIdRef.current.trim(); const shouldKeepNewConversation = @@ -4005,14 +4193,22 @@ export default function App() { if (typeof document !== "undefined" && document.visibilityState === "hidden") { return; } - recoverVisibleConversationAfterPageRestoreRef.current(api); - if (delayedRestoreTimer !== null) { - window.clearTimeout(delayedRestoreTimer); - } - delayedRestoreTimer = window.setTimeout(() => { - delayedRestoreTimer = null; - recoverVisibleConversationAfterPageRestoreRef.current(api); - }, PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS + 350); + void prepareChatRuntime( + "foreground", + api, + CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS, + ) + .catch(() => undefined) + .finally(() => { + recoverVisibleConversationAfterPageRestoreRef.current(api); + if (delayedRestoreTimer !== null) { + window.clearTimeout(delayedRestoreTimer); + } + delayedRestoreTimer = window.setTimeout(() => { + delayedRestoreTimer = null; + recoverVisibleConversationAfterPageRestoreRef.current(api); + }, PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS + 350); + }); }; const handleVisibilityChange = () => { @@ -4036,7 +4232,7 @@ export default function App() { document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("resume", runRecovery); }; - }, [api, historyShareToken, status?.online]); + }, [api, historyShareToken, prepareChatRuntime, status?.online]); async function sendChat(message: string, options?: SendChatOptions) { if (!api || chatBusyRef.current) { @@ -4052,6 +4248,17 @@ export default function App() { setConversationId(activeConversationId); setSelectedHistoryId(activeConversationId); } + const startedAsDraftConversation = isLocalDraftConversationId(activeConversationId); + const pendingDraftConversationId = + pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? ""; + if (pendingDraftConversationId && pendingDraftConversationId !== activeConversationId) { + const message = "上一条新会话仍在创建,请等待它出现在历史记录后再发送新会话。"; + updateConversationRuntimeEntry(activeConversationId, (current) => ({ + ...current, + error: message, + })); + return; + } if ( chatStartLocksRef.current.has(activeConversationId) || getConversationAbortController(activeConversationId) !== null || @@ -4069,7 +4276,6 @@ export default function App() { getConversationLiveStreamStore(activeConversationId); const controller = new AbortController(); setConversationAbortController(activeConversationId, controller); - const startedAsDraftConversation = isLocalDraftConversationId(activeConversationId); const clientRequestId = options?.clientRequestId?.trim() || `webui-chat-${activeConversationId}-${crypto.randomUUID()}`; @@ -4095,7 +4301,6 @@ export default function App() { : null; protectedConversationRef.current = activeConversationId; blockedHistoryHydrationConversationIdsRef.current.add(activeConversationId); - setConversationRunningState(activeConversationId, true); if ( resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === activeConversationId @@ -4105,7 +4310,9 @@ export default function App() { updateConversationRuntimeEntry(activeConversationId, (current) => ({ ...current, error: null, - toolStatus: null, + toolStatus: "Starting desktop runtime...", + toolStatusIsCompaction: false, + isSending: true, workdir: effectiveWorkdir || undefined, messages: [ ...current.messages, @@ -4138,6 +4345,14 @@ export default function App() { } let terminalEventSeen = false; + let runStarted = false; + const markRunStarted = () => { + if (runStarted) { + return; + } + runStarted = true; + setConversationRunningState(activeConversationId, true); + }; const runtimeControls = normalizeChatRuntimeControlsForProvider( options?.runtimeControls ?? settings.chatRuntimeControls, { @@ -4146,6 +4361,11 @@ export default function App() { }, ); try { + chatStartInFlightRef.current = true; + const preparedStatus = await prepareChatRuntime("send", api, CHAT_RUNTIME_PREPARE_TIMEOUT_MS); + if (preparedStatus.chat_runtime_ready !== true) { + throw new Error("Desktop chat runtime is not ready. Please retry."); + } for await (const event of api.chat( message, isLocalDraftConversationId(activeConversationId) ? undefined : activeConversationId, @@ -4170,6 +4390,10 @@ export default function App() { migrateConversationSummary(previousConversationId, nextConversationId); activeConversationId = nextConversationId; lockedConversationIds.add(activeConversationId); + if (runStarted) { + setConversationRunningState(previousConversationId, false); + setConversationRunningState(activeConversationId, true); + } } const summary = pickConversationSummary(historyItemsRef.current, activeConversationId); if (!summary && startedAsDraftConversation) { @@ -4194,6 +4418,27 @@ export default function App() { }); } } + if (isChatControlEvent(event)) { + if (isRunningChatControlEvent(event)) { + markRunStarted(); + updateConversationRuntimeEntry(activeConversationId, (current) => ({ + ...current, + toolStatus: VIBING_STATUS, + toolStatusIsCompaction: false, + })); + } else if (isTerminalChatControlEvent(event)) { + terminalEventSeen = true; + clearConversationStreamingState(activeConversationId); + if (event.type === "failed" || event.state === "failed") { + updateConversationRuntimeEntry(activeConversationId, (current) => ({ + ...current, + error: event.message?.trim() || "Desktop runtime did not start the request.", + })); + } + } + continue; + } + markRunStarted(); if (isChatStreamNotAvailableEvent(event)) { terminalEventSeen = true; recoverUnavailableActiveConversationStream(activeConversationId, api); @@ -4249,6 +4494,7 @@ export default function App() { } } } finally { + chatStartInFlightRef.current = false; clearConversationStreamingState(activeConversationId); if (status?.online && !terminalEventSeen) { await reloadHistory(api, { @@ -4341,7 +4587,16 @@ export default function App() { protectedConversationRef.current = PROTECTED_DRAFT_CONVERSATION; chatStartLocksRef.current.clear(); submitInFlightRef.current = false; - pendingDraftConversationMigrationRef.current = null; + const pendingDraftConversationId = + pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? ""; + const pendingDraftStillActive = + pendingDraftConversationId !== "" && + (localRunningConversationIdsRef.current.has(pendingDraftConversationId) || + getConversationAbortController(pendingDraftConversationId) !== null || + blockedHistoryHydrationConversationIdsRef.current.has(pendingDraftConversationId)); + if (!pendingDraftStillActive) { + pendingDraftConversationMigrationRef.current = null; + } composerRef.current?.clear(); const nextRuntime = createConversationRuntimeEntry({ workdir: options?.workdir?.trim() || undefined, @@ -4350,7 +4605,7 @@ export default function App() { syncVisibleConversationRuntime(nextConversationId, nextRuntime); setSelectedHistory(null); setSelectedHistoryEntries([]); - setPendingUploadedFiles([]); + setPendingUploadsForConversation(nextConversationId, []); } const removeWorkspaceProjectFromSettings = useCallback( @@ -4572,6 +4827,7 @@ export default function App() { blockedHistoryHydrationConversationIdsRef.current.delete(conversationId); clearConversationLiveStream(conversationId); clearCachedComposerDraft(conversationId); + pendingUploadsByConversationRef.current.delete(conversationId); } } if (terminalSessionsToClose.length > 0 && terminalClient) { @@ -5048,7 +5304,7 @@ export default function App() { setHistoryError(null); setChatError(null); composerRef.current?.clear(); - setPendingUploadedFiles([]); + setPendingUploadsForConversation(activeConversationId, []); blockedHistoryHydrationConversationIdsRef.current.add(activeConversationId); invalidateHistoryLoad(); markVisibleConversationRevision(); @@ -5139,10 +5395,17 @@ export default function App() { setChatError(translate("chat.upload.requireWorkdir", settings.locale)); return; } + const targetConversationId = getDisplayedConversationId(); + if (!targetConversationId) { + setChatError("请先选择或创建会话后再上传文件。"); + return; + } + const currentUploads = getPendingUploadsForConversation(targetConversationId); + setPendingUploadsForConversation(targetConversationId, currentUploads); const remainingFileSlots = Math.max( 0, - MAX_UPLOAD_FILES - pendingUploadedFilesRef.current.length, + MAX_UPLOAD_FILES - currentUploads.length, ); if (remainingFileSlots === 0) { setChatError( @@ -5168,15 +5431,16 @@ export default function App() { }); if (result.files.length > 0) { - setPendingUploadedFiles((current) => { + updatePendingUploadsForConversation(targetConversationId, (current) => { const next = mergePendingUploadedFiles(current, result.files).slice( 0, MAX_UPLOAD_FILES, ); - pendingUploadedFilesRef.current = next; return next; }); - composerRef.current?.focus(); + if (isDisplayedConversation(targetConversationId)) { + composerRef.current?.focus(); + } } const warnings: string[] = []; @@ -5193,11 +5457,13 @@ export default function App() { }), ); } - if (warnings.length > 0) { + if (warnings.length > 0 && isDisplayedConversation(targetConversationId)) { setChatError(warnings.join("\n")); } } catch (error) { - setChatError(asErrorMessage(error, "导入文件失败")); + if (isDisplayedConversation(targetConversationId)) { + setChatError(asErrorMessage(error, "导入文件失败")); + } } finally { isUploadingFilesRef.current = false; setIsUploadingFiles(false); @@ -5355,6 +5621,7 @@ export default function App() { sharedHistoryItemsRef.current = []; sharedHistoryListRequestRef.current = null; pendingUploadedFilesRef.current = []; + pendingUploadsByConversationRef.current.clear(); draftConversationPinnedRef.current = false; protectedConversationRef.current = ""; chatStartLocksRef.current.clear(); @@ -5632,20 +5899,6 @@ export default function App() { : status?.online !== true ? translate("projectTools.tunnelRemoteOffline", settings.locale) : undefined; - const tunnelManagerToolAvailable = - settingsSyncReady && - isAgentMode && - settings.remote.enableWebTunnels === true && - status?.online === true; - const tunnelManagerToolDisabledMessage = !settingsSyncReady - ? translate("chat.runtime.tunnelSettingsSyncing", settings.locale) - : !isAgentMode - ? translate("chat.runtime.tunnelAgentModeRequired", settings.locale) - : !settings.remote.enableWebTunnels - ? translate("chat.runtime.tunnelWebDisabled", settings.locale) - : status?.online !== true - ? translate("chat.runtime.tunnelRemoteOffline", settings.locale) - : undefined; const handleOpenWorkspaceFile = useCallback( (path: string) => { if (!terminalProjectPath || !terminalProjectPathKey) return; @@ -5676,10 +5929,6 @@ export default function App() { [terminalProjectPath, terminalProjectPathKey], ); - const handleOpenTunnelToolPanel = useCallback(() => { - if (!tunnelManagerToolAvailable) return; - openTunnelToolPanel(); - }, [openTunnelToolPanel, tunnelManagerToolAvailable]); const requestWorkspaceEditorClose = useCallback(() => { setWorkspaceEditorCloseRequestId((current) => current + 1); }, []); @@ -6539,8 +6788,6 @@ export default function App() { gitClient={gitClient} gitWriteEnabled={settings.remote.enableWebGit} gitDisabledMessage={gitDisabledMessage} - tunnelToolAvailable={tunnelManagerToolAvailable} - tunnelToolDisabledMessage={tunnelManagerToolDisabledMessage} onGitChanged={(gitWorkdir) => window.dispatchEvent( new CustomEvent("liveagent:git-changed", { @@ -6548,7 +6795,6 @@ export default function App() { }), ) } - onOpenTunnelToolPanel={handleOpenTunnelToolPanel} onSend={() => { if ( submitInFlightRef.current || @@ -6599,13 +6845,33 @@ export default function App() { if (!text && files.length === 0) { return; } + const uploadConversationId = getDisplayedConversationId(); + const pendingDraftConversationId = + pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? + ""; + if ( + pendingDraftConversationId && + pendingDraftConversationId !== uploadConversationId + ) { + const message = + "上一条新会话仍在创建,请等待它出现在历史记录后再发送新会话。"; + if (uploadConversationId) { + updateConversationRuntimeEntry(uploadConversationId, (current) => ({ + ...current, + error: message, + })); + } else { + setChatError(message); + } + return; + } composerRef.current?.clear(); - setPendingUploadedFiles([]); + setPendingUploadsForConversation(uploadConversationId, []); void sendChat(text, { uploadedFiles: files, runtimeControls: chatRuntimeControlsForCurrentProvider, }).catch(() => { - setPendingUploadedFiles((current) => + updatePendingUploadsForConversation(uploadConversationId, (current) => mergePendingUploadedFiles(current, files), ); }); @@ -6619,13 +6885,23 @@ export default function App() { isObservingRemoteLiveConversation ? displayedConversationId : undefined, ); }} + onPrepareChatRuntime={() => { + if (!api || historyShareToken) { + return; + } + void prepareChatRuntime( + "composer-focus", + api, + CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS, + ).catch(() => undefined); + }} onComposerBusyChange={handleComposerBusyChange} onChatRuntimeControlsChange={handleChatRuntimeControlsChange} onPickReadableFiles={() => fileInputRef.current?.click()} onPasteFiles={handleImportReadableFiles} pendingUploadedFiles={pendingUploadedFiles} onRemovePendingUpload={(relativePath) => { - setPendingUploadedFiles((current) => + updatePendingUploadsForConversation(getDisplayedConversationId(), (current) => current.filter((file) => file.relativePath !== relativePath), ); }} diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 304cfac4b..e09449d6f 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -1,6 +1,7 @@ import type { GatewaySettingsSyncPayload } from "@/lib/settings/sync"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; import type { PendingUploadedFile } from "@/lib/chat/uploadedFiles"; + import type { TerminalEvent, TerminalSession, @@ -10,6 +11,7 @@ import type { import type { AgentStatus, + ChatControlEvent, ChatEvent, ConversationSummary, GatewayHistoryEvent, @@ -53,6 +55,7 @@ type ChatStreamState = { lastSeq: number; resuming: boolean; attachedSocket: WebSocket | null; + startWatchdogId?: number; abortHandler?: () => void; }; @@ -375,6 +378,9 @@ const RECONNECT_INITIAL_DELAY_MS = 500; const RECONNECT_MAX_DELAY_MS = 15_000; const RECONNECT_NOTICE_DELAY_MS = 10_000; const SOCKET_INBOUND_STALL_MS = 25_000; +const FOREGROUND_SOCKET_RECYCLE_IDLE_MS = 10_000; +const FOREGROUND_WAKEUP_RECENCY_MS = 15_000; +const CHAT_STREAM_TRANSPORT_STALL_MS = 8_000; type RuntimeHost = { location?: { @@ -411,11 +417,55 @@ function getRuntimeOrigin() { return ""; } +function isForegroundWakeupEvent(event?: Event) { + const type = event?.type ?? ""; + if (type === "pagehide" || type === "freeze") { + return false; + } + if (typeof document !== "undefined" && type === "visibilitychange") { + return document.visibilityState === "visible"; + } + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" && + type !== "online" + ) { + return false; + } + return true; +} + function readChatEventSeq(event: ChatEvent) { const seq = event.seq; return typeof seq === "number" && Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0; } +function isChatControlEvent( + event: ChatEvent | null | undefined, +): event is ChatControlEvent { + switch (event?.type) { + case "accepted": + case "delivered": + case "claimed": + case "starting": + case "started": + case "progress": + case "completed": + case "failed": + case "cancelled": + return true; + default: + return false; + } +} + +function isTerminalChatControlEvent(event: ChatEvent | null | undefined) { + if (!isChatControlEvent(event)) { + return false; + } + return event?.state === "completed" || event?.state === "failed" || event?.state === "cancelled"; +} + function normalizeAfterSeq(value: unknown) { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; } @@ -600,14 +650,33 @@ export class GatewayWebSocketClient { private reconnectNoticeTimer: number | null = null; private reconnectAttempt = 0; private reconnecting = false; - private readonly reconnectWakeup = () => { - this.scheduleReconnect(0); + private lastForegroundWakeupAt = 0; + private prepareRuntimePromise: Promise | null = null; + private readonly reconnectWakeup = (event?: Event) => { + this.noteForegroundWakeup(event); }; constructor(private readonly token: string) { this.installReconnectWakeups(); } + noteForegroundWakeup(event?: Event) { + if (this.disposed || !isForegroundWakeupEvent(event)) { + return; + } + const now = Date.now(); + this.lastForegroundWakeupAt = now; + if ( + this.socket?.readyState === WebSocket.OPEN && + this.authenticated && + this.shouldRecycleAuthenticatedSocket(now) + ) { + this.handleDisconnect(this.buildTransportStallError("after page restore")); + return; + } + this.scheduleReconnect(0); + } + async getStatus(): Promise { const status = await this.requestWithRecovery("status.get", {}); this.lastStatus = status; @@ -615,6 +684,22 @@ export class GatewayWebSocketClient { return status; } + async prepareChatRuntime(_reason?: string): Promise { + if (this.prepareRuntimePromise) { + return this.prepareRuntimePromise; + } + this.noteForegroundWakeup(); + this.prepareRuntimePromise = (async () => { + await this.ensureConnected({ resumeStreams: true }); + const status = await this.getStatus(); + this.emitStatus(status, null); + return status; + })().finally(() => { + this.prepareRuntimePromise = null; + }); + return this.prepareRuntimePromise; + } + subscribeStatus(listener: StatusListener): () => void { this.statusListeners.add(listener); if (this.lastStatus || this.lastStatusError) { @@ -695,6 +780,7 @@ export class GatewayWebSocketClient { if (active?.conversationId) { void this.cancelChat(active.conversationId).catch(() => undefined); } + this.clearChatStreamStartWatchdog(streamState); this.chatStreams.delete(requestId); queue.close(); }; @@ -707,6 +793,7 @@ export class GatewayWebSocketClient { } try { + const requestStartedAt = Date.now(); this.sendEnvelope({ id: requestId, type: "chat.start", @@ -743,6 +830,12 @@ export class GatewayWebSocketClient { }, }); streamState.attachedSocket = this.socket; + this.armChatStreamStartWatchdog( + requestId, + streamState, + requestStartedAt, + "chat.start", + ); for await (const event of queue) { yield event; @@ -750,6 +843,9 @@ export class GatewayWebSocketClient { } finally { const active = this.chatStreams.get(requestId); active?.abortHandler?.(); + if (active) { + this.clearChatStreamStartWatchdog(active); + } this.chatStreams.delete(requestId); } } @@ -786,6 +882,7 @@ export class GatewayWebSocketClient { if (signal) { const handleAbort = () => { + this.clearChatStreamStartWatchdog(streamState); void this.detachChatStream(requestId).catch(() => undefined); this.chatStreams.delete(requestId); queue.close(); @@ -799,6 +896,7 @@ export class GatewayWebSocketClient { } try { + const requestStartedAt = Date.now(); this.sendEnvelope({ id: requestId, type: "chat.attach", @@ -808,6 +906,12 @@ export class GatewayWebSocketClient { }, }); streamState.attachedSocket = this.socket; + this.armChatStreamStartWatchdog( + requestId, + streamState, + requestStartedAt, + "chat.attach", + ); for await (const event of queue) { yield event; @@ -815,6 +919,9 @@ export class GatewayWebSocketClient { } finally { const active = this.chatStreams.get(requestId); active?.abortHandler?.(); + if (active) { + this.clearChatStreamStartWatchdog(active); + } if (active) { await this.detachChatStream(requestId).catch(() => undefined); this.chatStreams.delete(requestId); @@ -1675,6 +1782,7 @@ export class GatewayWebSocketClient { if (!this.socket || !this.authenticated || this.socket.readyState !== WebSocket.OPEN) { return; } + const requestStartedAt = Date.now(); if (stream.kind === "attach") { this.sendEnvelope({ id: requestId, @@ -1696,6 +1804,12 @@ export class GatewayWebSocketClient { }); } stream.attachedSocket = this.socket; + this.armChatStreamStartWatchdog( + requestId, + stream, + requestStartedAt, + stream.kind === "attach" ? "chat.attach" : "chat.resume", + ); } catch { this.scheduleReconnect(); } finally { @@ -1703,6 +1817,45 @@ export class GatewayWebSocketClient { } } + private armChatStreamStartWatchdog( + requestId: string, + stream: ChatStreamState, + requestStartedAt: number, + action: string, + ) { + this.clearChatStreamStartWatchdog(stream); + const socket = this.socket; + if (!socket) { + return; + } + const host = getRuntimeHost(); + stream.startWatchdogId = host.setTimeout(() => { + const active = this.chatStreams.get(requestId); + if (!active || active !== stream || active.lastSeq > 0) { + return; + } + active.startWatchdogId = undefined; + if ( + this.socket === socket && + active.attachedSocket === socket && + this.authenticated && + socket.readyState === WebSocket.OPEN && + this.lastInboundAt <= requestStartedAt + ) { + this.handleDisconnect(this.buildTransportStallError(`while waiting for ${action}`)); + } + }, CHAT_STREAM_TRANSPORT_STALL_MS); + } + + private clearChatStreamStartWatchdog(stream: ChatStreamState) { + if (stream.startWatchdogId === undefined) { + return; + } + const host = getRuntimeHost(); + host.clearTimeout(stream.startWatchdogId); + stream.startWatchdogId = undefined; + } + private async detachChatStream(requestId: string) { if (this.disposed) { return; @@ -1782,7 +1935,7 @@ export class GatewayWebSocketClient { return; } - if (envelope.type === "chat.event" && requestId) { + if ((envelope.type === "chat.event" || envelope.type === "chat.control") && requestId) { const stream = this.chatStreams.get(requestId); if (!stream) { return; @@ -1798,8 +1951,13 @@ export class GatewayWebSocketClient { if (event?.conversation_id) { stream.conversationId = event.conversation_id; } + this.clearChatStreamStartWatchdog(stream); stream.queue.push(event); - if (event?.type === "done" || event?.type === "error") { + if ( + event?.type === "done" || + event?.type === "error" || + isTerminalChatControlEvent(event) + ) { stream.abortHandler?.(); stream.queue.close(); this.chatStreams.delete(requestId); @@ -1807,7 +1965,7 @@ export class GatewayWebSocketClient { return; } - if (envelope.type === "conversation.event") { + if (envelope.type === "conversation.event" || envelope.type === "conversation.control") { const event = envelope.payload as ChatEvent; if (event?.conversation_id) { this.emitConversation(event); @@ -1819,6 +1977,7 @@ export class GatewayWebSocketClient { const stream = this.chatStreams.get(requestId); if (stream) { const message = typeof envelope.error === "string" ? envelope.error : "Request failed"; + this.clearChatStreamStartWatchdog(stream); stream.queue.push({ type: "error", message, @@ -1877,11 +2036,13 @@ export class GatewayWebSocketClient { const streams = [...this.chatStreams.values()]; this.chatStreams.clear(); for (const stream of streams) { + this.clearChatStreamStartWatchdog(stream); stream.abortHandler?.(); stream.queue.fail(error); } } else { for (const stream of this.chatStreams.values()) { + this.clearChatStreamStartWatchdog(stream); stream.attachedSocket = null; } } @@ -1912,12 +2073,22 @@ export class GatewayWebSocketClient { } private shouldRecycleAuthenticatedSocket(now = Date.now()) { + if ( + this.socket === null || + !this.authenticated || + this.socket.readyState !== WebSocket.OPEN || + this.lastInboundAt <= 0 + ) { + return false; + } + if (now - this.lastInboundAt >= SOCKET_INBOUND_STALL_MS) { + return true; + } return ( - this.socket !== null && - this.authenticated && - this.socket.readyState === WebSocket.OPEN && - this.lastInboundAt > 0 && - now - this.lastInboundAt >= SOCKET_INBOUND_STALL_MS + this.lastForegroundWakeupAt > 0 && + now - this.lastForegroundWakeupAt <= FOREGROUND_WAKEUP_RECENCY_MS && + this.lastInboundAt < this.lastForegroundWakeupAt && + now - this.lastInboundAt >= FOREGROUND_SOCKET_RECYCLE_IDLE_MS ); } @@ -1946,6 +2117,7 @@ export class GatewayWebSocketClient { export type GatewayWebSocketClientLike = { getStatus(): Promise; + prepareChatRuntime(reason?: string): Promise; subscribeStatus(listener: StatusListener): () => void; subscribeHistory(listener: HistoryListener): () => void; subscribeConversation(listener: ConversationListener): () => void; @@ -2164,6 +2336,10 @@ type SharedWorkerClientRequestMessage = connection_id: string; stream_id: string; } + | { + type: "wakeup"; + connection_id: string; + } | { type: "dispose"; connection_id: string; @@ -2202,6 +2378,9 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { private terminalListeners = new Set(); private lastStatus: AgentStatus | null = null; private lastStatusError: string | null = null; + private readonly workerWakeup = (event?: Event) => { + this.postWorkerWakeup(event); + }; constructor(private readonly token: string) { this.connectionID = this.nextRequestId("connection"); @@ -2222,6 +2401,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { this.handleDisconnect(new Error("Gateway SharedWorker message failed")); }; this.port.start(); + this.installWorkerWakeups(); const host = getRuntimeHost(); this.readyTimeoutId = host.setTimeout(() => { this.handleDisconnect(new Error("Gateway SharedWorker connection timed out")); @@ -2245,6 +2425,13 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { return status; } + async prepareChatRuntime(reason?: string): Promise { + const status = await this.request("chat.prepare", { reason: reason ?? "" }); + this.statusRefreshRequested = false; + this.emitStatus(status, null); + return status; + } + subscribeStatus(listener: StatusListener): () => void { this.statusListeners.add(listener); if (this.lastStatus || this.lastStatusError) { @@ -2857,6 +3044,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { return; } this.disposed = true; + this.uninstallWorkerWakeups(); try { this.postMessage({ type: "dispose", connection_id: this.connectionID }); } catch { @@ -2984,6 +3172,48 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { await this.connectPromise; } + private installWorkerWakeups() { + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + window.addEventListener("online", this.workerWakeup); + window.addEventListener("focus", this.workerWakeup); + window.addEventListener("pageshow", this.workerWakeup); + window.addEventListener("pagehide", this.workerWakeup); + } + if (typeof document !== "undefined" && typeof document.addEventListener === "function") { + document.addEventListener("visibilitychange", this.workerWakeup); + document.addEventListener("freeze", this.workerWakeup as EventListener); + document.addEventListener("resume", this.workerWakeup as EventListener); + } + } + + private uninstallWorkerWakeups() { + if (typeof window !== "undefined" && typeof window.removeEventListener === "function") { + window.removeEventListener("online", this.workerWakeup); + window.removeEventListener("focus", this.workerWakeup); + window.removeEventListener("pageshow", this.workerWakeup); + window.removeEventListener("pagehide", this.workerWakeup); + } + if (typeof document !== "undefined" && typeof document.removeEventListener === "function") { + document.removeEventListener("visibilitychange", this.workerWakeup); + document.removeEventListener("freeze", this.workerWakeup as EventListener); + document.removeEventListener("resume", this.workerWakeup as EventListener); + } + } + + private postWorkerWakeup(event?: Event) { + if (this.disposed || !isForegroundWakeupEvent(event)) { + return; + } + try { + this.postMessage({ + type: "wakeup", + connection_id: this.connectionID, + }); + } catch { + this.handleDisconnect(new Error("Gateway SharedWorker wakeup failed")); + } + } + private postMessage(message: SharedWorkerClientRequestMessage) { this.port.postMessage(message); } @@ -3076,7 +3306,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { stream.conversationId = event.conversation_id; } stream.queue.push(event); - if (event?.type === "done" || event?.type === "error") { + if (event?.type === "done" || event?.type === "error" || isTerminalChatControlEvent(event)) { stream.abortHandler?.(); stream.queue.close(); this.chatStreams.delete(streamId); diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts index 6b64cfc83..d219eed1d 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts @@ -63,6 +63,10 @@ type WorkerClientRequest = connection_id: string; stream_id: string; } + | { + type: "wakeup"; + connection_id: string; + } | { type: "dispose"; connection_id: string; @@ -94,6 +98,7 @@ type SharedWorkerScope = { const clients = new Map(); const portStates = new Map(); const TERMINAL_DETACH_GRACE_MS = 250; +const MANAGED_CLIENT_WARM_WINDOW_MS = 10 * 60_000; function asErrorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message.trim()) { @@ -244,7 +249,7 @@ function scheduleManagedClientCleanup(client: ManagedClient) { client.terminalDetachTimers.clear(); client.client.dispose(); clients.delete(client.token); - }, 60_000); + }, MANAGED_CLIENT_WARM_WINDOW_MS); } function clearManagedClientCleanup(client: ManagedClient) { @@ -364,6 +369,9 @@ async function resolveRequest(client: GatewayWebSocketClient, method: string, pa switch (method) { case "status.get": return client.getStatus(); + case "chat.prepare": + client.noteForegroundWakeup(); + return client.prepareChatRuntime("shared-worker"); case "fs.roots": return client.listFsRoots(); case "fs.list_dirs": @@ -938,6 +946,9 @@ function handlePortMessage(port: MessagePort, raw: unknown) { case "chat.detach": handleChatDetach(state, message); return; + case "wakeup": + state.client.client.noteForegroundWakeup(); + return; } } diff --git a/crates/agent-gateway/web/src/lib/gatewayTypes.ts b/crates/agent-gateway/web/src/lib/gatewayTypes.ts index c2a446687..3c55a1937 100644 --- a/crates/agent-gateway/web/src/lib/gatewayTypes.ts +++ b/crates/agent-gateway/web/src/lib/gatewayTypes.ts @@ -8,11 +8,18 @@ import type { export type AgentStatus = { online: boolean; + agent_ready?: boolean; + chat_runtime_ready?: boolean; agent_id?: string; agent_version?: string; session_id?: string; connected_since?: number; last_heartbeat?: number; + runtime_state?: "ready" | "draining" | "busy" | "suspended" | string; + runtime_last_heartbeat?: number; + runtime_worker_id?: string; + runtime_visible?: boolean; + runtime_active_run_count?: number; }; export type GatewaySelectedModel = { @@ -51,6 +58,38 @@ export type ChatCheckpointPayload = { }; }; +export type ChatRunControlState = + | "queued" + | "delivered" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type ChatControlEvent = { + type: + | "accepted" + | "delivered" + | "claimed" + | "starting" + | "started" + | "progress" + | "completed" + | "failed" + | "cancelled"; + request_id?: string; + client_request_id?: string; + conversation_id?: string; + run_epoch?: number; + state?: ChatRunControlState; + error_code?: string; + message?: string; + seq?: number; + workdir?: string; +}; + export type ChatEvent = ( | { type: "token"; @@ -118,6 +157,7 @@ export type ChatEvent = ( conversation_id?: string; } | { type: "error"; message: string; round?: number; conversation_id?: string } + | ChatControlEvent ) & { seq?: number; workdir?: string }; export type CronManagePayload = { diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index 2d053570c..79a246cdf 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -10,7 +10,6 @@ import { Brain, Globe2, Lightbulb, - Link2, Loader2, Paperclip, Send, @@ -82,12 +81,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; - tunnelToolAvailable?: boolean; - tunnelToolDisabledMessage?: string; onGitChanged?: (workdir: string) => void; - onOpenTunnelToolPanel?: () => void; onSend: () => void; onStop: () => void; + onPrepareChatRuntime?: () => void; onComposerBusyChange: (isBusy: boolean) => void; onChatRuntimeControlsChange: (patch: Partial) => void; onPickReadableFiles: () => void; @@ -109,12 +106,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { gitClient, gitWriteEnabled = true, gitDisabledMessage, - tunnelToolAvailable = false, - tunnelToolDisabledMessage, onGitChanged, - onOpenTunnelToolPanel, onSend, onStop, + onPrepareChatRuntime, onComposerBusyChange, onChatRuntimeControlsChange, onPickReadableFiles, @@ -142,11 +137,6 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { ? t("chat.runtime.thinkingUnavailable") : t("chat.runtime.thinkingTooltip"); const webSearchTooltip = t("chat.runtime.webSearchTooltip"); - const tunnelTooltip = - tunnelToolDisabledMessage ?? - (tunnelToolAvailable - ? t("chat.runtime.tunnelToolAvailable") - : t("chat.runtime.tunnelToolUnavailable")); useEffect(() => { if ( @@ -253,7 +243,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { className="pointer-events-none absolute inset-0 rounded-[24px] bg-gradient-to-b from-white/30 to-transparent opacity-60 dark:from-white/[0.04] dark:opacity-100" /> -
+
- - - - {reasoningOptions.length > 0 ? ( setTargetUrl(event.target.value)} - placeholder={t("projectTools.tunnelTargetPlaceholder")} - disabled={!enabled || creating} - className="h-8 min-w-0 text-xs" - /> - {targetValidationKey ? ( -
{t(targetValidationKey)}
- ) : null} -
-
-
- - setName(event.target.value)} - placeholder={t("projectTools.tunnelNamePlaceholder")} - disabled={!enabled || creating} - className="h-8 min-w-0 text-xs" +
+ {disabledMessage ? ( +
+ + {disabledMessage} +
+ ) : null} + + {normalizedProjectPathKey ? ( +
+
+
+
-
- -
- {TTL_OPTIONS.map((option) => ( - +
+
+
{ + event.preventDefault(); + if (!showCreateForm || !createOpen) return; + createTunnel(); + }} + > +
+ + setTargetUrl(event.target.value)} + placeholder={t("projectTools.tunnelTargetPlaceholder")} + disabled={createFieldsDisabled} + inputMode="url" + autoComplete="off" + spellCheck={false} + className={cn(TUNNEL_INPUT_CLASS, "font-mono")} + /> + {targetValidationKey ? ( +
+ + {t(targetValidationKey)} +
+ ) : null} +
+
+ + setName(event.target.value)} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={createFieldsDisabled} + autoComplete="off" + className={TUNNEL_INPUT_CLASS} + /> +
+
+ + +
+ - ))} + {creating ? ( + + ) : ( + + )} + {creating ? t("projectTools.tunnelCreating") : t("projectTools.tunnelCreate")} + +
-
- +
- ) : null} -
- ) : null} +
+ ) : null} -
{error ? ( -
+
{error}
) : null} - {loading && sortedTunnels.length === 0 ? ( -
- - {t("projectTools.tunnelLoading")} + +
+
+ + {t("projectTools.tunnelListSection")} + + {sortedTunnels.length > 0 ? ( + + {sortedTunnels.length} + + ) : null}
- ) : sortedTunnels.length === 0 ? ( -
-
- + {loading && sortedTunnels.length === 0 ? ( +
+ {t("projectTools.tunnelLoading")} +
+
-
{t("projectTools.tunnelEmpty")}
-
- ) : ( -
- {sortedTunnels.map((tunnel) => { - const hasExpiry = tunnel.expiresAt > 0; - const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; - const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); - const isEditing = editingId === tunnel.id; - const updating = savingId === tunnel.id; - const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); - return ( -
-
-
- -
-
-
-
- {displayTunnelName(tunnel)} -
+ ) : sortedTunnels.length === 0 ? ( +
+
+ +
+
+ {t("projectTools.tunnelEmpty")} +
+ {showCreateForm ? ( +
+ {t("projectTools.tunnelEmptyHintCreate")} +
+ ) : normalizedProjectPathKey ? ( +
+ {t("projectTools.tunnelEmptyHintProject")} +
+ ) : null} +
+ ) : ( +
+ {sortedTunnels.map((tunnel) => { + const hasExpiry = tunnel.expiresAt > 0; + const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; + const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); + const isEditing = editingId === tunnel.id; + const updating = savingId === tunnel.id; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + const handleEditKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + updateTunnel(tunnel); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEdit(); + } + }; + return ( +
+
+
+ {displayTunnelName(tunnel)} +
+ - {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} - -
- {isEditing ? ( -
- setEditTargetUrl(event.target.value)} - disabled={!enabled || updating} - className="h-8 min-w-0 text-xs" - aria-label={t("projectTools.tunnelTargetUrl")} - /> - {editTargetValidationKey ? ( -
- {t(editTargetValidationKey)} -
- ) : null} -
+ /> + {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} + +
+ + {isEditing ? ( + <> +
+
+ + setEditTargetUrl(event.target.value)} + onKeyDown={handleEditKeyDown} + disabled={!enabled || updating} + inputMode="url" + autoComplete="off" + spellCheck={false} + className={cn(TUNNEL_INPUT_CLASS, "font-mono")} + /> + {editTargetValidationKey ? ( +
+ + {t(editTargetValidationKey)} +
+ ) : null} +
+
+ setEditName(event.target.value)} + onKeyDown={handleEditKeyDown} placeholder={t("projectTools.tunnelNamePlaceholder")} disabled={!enabled || updating} - className="h-8 min-w-0 text-xs" - aria-label={t("projectTools.tunnelName")} + autoComplete="off" + className={TUNNEL_INPUT_CLASS} /> -
- -
- {TTL_OPTIONS.map((option) => ( - - ))} -
-
-
-
- ) : ( - <> -
- {t("projectTools.tunnelTarget")}: {tunnel.targetUrl} -
-
- - - {tunnel.publicUrl} -
-
- - - - {!hasExpiry - ? t("projectTools.tunnelTtlInfinite") - : expired - ? t("projectTools.tunnelExpired") - : t("projectTools.tunnelExpiresIn").replace( - "{time}", - formatRemaining(remaining), - )} - - - {scope === "global" ? ( - - {t( - tunnelProjectPathKey - ? "projectTools.tunnelScopeProjectBadge" - : "projectTools.tunnelScopeGlobalBadge", - )} - - ) : null} - {hasExpiry ? {formatDateTime(tunnel.expiresAt)} : null} +
+ +
- - )} -
-
-
- {isEditing ? ( - <> - - +
+
+ + +
) : ( <> - - - - +
- {closingId === tunnel.id ? ( - - ) : ( - - )} - + + {t("projectTools.tunnelTarget")} + {tunnel.targetUrl} +
+
+
+ + + + {!hasExpiry + ? t("projectTools.tunnelTtlInfinite") + : expired + ? t("projectTools.tunnelExpired") + : t("projectTools.tunnelExpiresIn").replace( + "{time}", + formatRemaining(remaining), + )} + + + {scope === "global" ? ( + + {t( + tunnelProjectPathKey + ? "projectTools.tunnelScopeProjectBadge" + : "projectTools.tunnelScopeGlobalBadge", + )} + + ) : null} +
+
+ + + +
+
)}
-
- ); - })} -
- )} + ); + })} +
+ )} +
); diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index d3e9af5d4..4604d9ff0 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -270,6 +270,10 @@ export const translations: Record> = { "projectTools.tunnelCancelEdit": "取消编辑", "projectTools.tunnelLoading": "正在加载内网穿透...", "projectTools.tunnelEmpty": "还没有内网穿透链接", + "projectTools.tunnelCreateSection": "新建链接", + "projectTools.tunnelListSection": "链接列表", + "projectTools.tunnelEmptyHintCreate": "在上方新建第一个临时链接", + "projectTools.tunnelEmptyHintProject": "切换到「当前项目」即可创建链接", "projectTools.tunnelTargetRequired": "请输入本地 HTTP 服务地址。", "projectTools.tunnelInvalidUrl": "请输入有效的 http://localhost 地址,不能包含账号、密码或片段。", "projectTools.tunnelLocalhostOnly": "仅支持 localhost、127.0.0.1 或 [::1]。", @@ -1491,6 +1495,10 @@ export const translations: Record> = { "projectTools.tunnelCancelEdit": "Cancel edit", "projectTools.tunnelLoading": "Loading tunnels...", "projectTools.tunnelEmpty": "No tunnel links yet", + "projectTools.tunnelCreateSection": "New link", + "projectTools.tunnelListSection": "Links", + "projectTools.tunnelEmptyHintCreate": "Create your first temporary link above", + "projectTools.tunnelEmptyHintProject": "Switch to Current Project to create links", "projectTools.tunnelTargetRequired": "Enter a local HTTP service URL.", "projectTools.tunnelInvalidUrl": "Enter a valid http://localhost URL without credentials or fragments.", diff --git a/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx b/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx index 120cff7f0..1679396d5 100644 --- a/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx @@ -3,7 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocale } from "../../i18n"; import { cn } from "../../lib/shared/utils"; import { + AlertTriangle, Check, + ChevronDown, Clock3, Copy, Edit3, @@ -12,7 +14,7 @@ import { Globe, Link2, Loader2, - Save, + Plus, Trash2, X, } from "../icons"; @@ -92,6 +94,43 @@ const TTL_OPTIONS: Array<{ value: TunnelTtlSeconds; labelKey: string }> = [ { value: 0, labelKey: "projectTools.tunnelTtlInfinite" }, ]; +const TUNNEL_INPUT_CLASS = + "h-8 min-w-0 rounded-lg border-border/60 bg-background/80 text-xs transition-[border-color,box-shadow,background-color] focus-visible:border-muted-foreground/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-muted-foreground/15 focus-visible:ring-offset-0"; + +function TtlSegmented({ + value, + onChange, + disabled, +}: { + value: TunnelTtlSeconds; + onChange: (value: TunnelTtlSeconds) => void; + disabled?: boolean; +}) { + const { t } = useLocale(); + return ( +
+ {TTL_OPTIONS.map((option) => { + const active = value === option.value; + return ( + + ); + })} +
+ ); +} + function validateLocalHttpTarget(input: string) { const value = input.trim(); if (!value) return "projectTools.tunnelTargetRequired"; @@ -197,6 +236,7 @@ export function LocalTunnelPanel({ const [targetUrl, setTargetUrl] = useState("http://localhost:3000"); const [name, setName] = useState(""); const [ttlSeconds, setTtlSeconds] = useState(3600); + const [createOpen, setCreateOpen] = useState(true); const [editingId, setEditingId] = useState(""); const [editTargetUrl, setEditTargetUrl] = useState(""); const [editName, setEditName] = useState(""); @@ -428,325 +468,397 @@ export function LocalTunnelPanel({ !targetValidationKey && (scope !== "project" || Boolean(normalizedProjectPathKey)); const showCreateForm = scope === "project" && Boolean(normalizedProjectPathKey); + const createFieldsDisabled = !showCreateForm || !createOpen || !enabled || creating; return ( -
-
-
-
+
+
+
+
-
+
{t("projectTools.tunnelTitle")}
{t("projectTools.tunnelDescription")}
-
-
- {TUNNEL_SCOPE_OPTIONS.map((option) => { - const active = scope === option.scope; - const disabled = option.scope === "project" && !normalizedProjectPathKey; - const Icon = option.scope === "project" ? Folder : Globe; - return ( - - ); - })} -
-
+
+
+
+ {TUNNEL_SCOPE_OPTIONS.map((option) => { + const active = scope === option.scope; + const disabled = option.scope === "project" && !normalizedProjectPathKey; + const Icon = option.scope === "project" ? Folder : Globe; + return ( + + ); + })}
- {disabledMessage || showCreateForm ? ( -
- {disabledMessage ? ( -
- {disabledMessage} -
- ) : null} - {showCreateForm ? ( -
-
- - setTargetUrl(event.target.value)} - placeholder={t("projectTools.tunnelTargetPlaceholder")} - disabled={!enabled || creating} - className="h-8 min-w-0 text-xs" - /> - {targetValidationKey ? ( -
{t(targetValidationKey)}
- ) : null} -
-
-
- - setName(event.target.value)} - placeholder={t("projectTools.tunnelNamePlaceholder")} - disabled={!enabled || creating} - className="h-8 min-w-0 text-xs" +
+ {disabledMessage ? ( +
+ + {disabledMessage} +
+ ) : null} + + {normalizedProjectPathKey ? ( +
+
+
+
-
- -
- {TTL_OPTIONS.map((option) => ( - +
+
+
{ + event.preventDefault(); + if (!showCreateForm || !createOpen) return; + createTunnel(); + }} + > +
+ + setTargetUrl(event.target.value)} + placeholder={t("projectTools.tunnelTargetPlaceholder")} + disabled={createFieldsDisabled} + inputMode="url" + autoComplete="off" + spellCheck={false} + className={cn(TUNNEL_INPUT_CLASS, "font-mono")} + /> + {targetValidationKey ? ( +
+ + {t(targetValidationKey)} +
+ ) : null} +
+
+ + setName(event.target.value)} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={createFieldsDisabled} + autoComplete="off" + className={TUNNEL_INPUT_CLASS} + /> +
+
+ + +
+ - ))} + {creating ? ( + + ) : ( + + )} + {creating + ? t("projectTools.tunnelCreating") + : t("projectTools.tunnelCreate")} + +
-
- +
- ) : null} -
- ) : null} +
+ ) : null} -
{error ? ( -
+
{error}
) : null} - {loading && sortedTunnels.length === 0 ? ( -
- - {t("projectTools.tunnelLoading")} + +
+
+ + {t("projectTools.tunnelListSection")} + + {sortedTunnels.length > 0 ? ( + + {sortedTunnels.length} + + ) : null}
- ) : sortedTunnels.length === 0 ? ( -
-
- + {loading && sortedTunnels.length === 0 ? ( +
+ {t("projectTools.tunnelLoading")} +
+
-
{t("projectTools.tunnelEmpty")}
-
- ) : ( -
- {sortedTunnels.map((tunnel) => { - const hasExpiry = tunnel.expiresAt > 0; - const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; - const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); - const isEditing = editingId === tunnel.id; - const updating = savingId === tunnel.id; - const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); - return ( -
-
-
- -
-
-
-
- {displayTunnelName(tunnel)} -
+ ) : sortedTunnels.length === 0 ? ( +
+
+ +
+
+ {t("projectTools.tunnelEmpty")} +
+ {showCreateForm ? ( +
+ {t("projectTools.tunnelEmptyHintCreate")} +
+ ) : normalizedProjectPathKey ? ( +
+ {t("projectTools.tunnelEmptyHintProject")} +
+ ) : null} +
+ ) : ( +
+ {sortedTunnels.map((tunnel) => { + const hasExpiry = tunnel.expiresAt > 0; + const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; + const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); + const isEditing = editingId === tunnel.id; + const updating = savingId === tunnel.id; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + const handleEditKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + updateTunnel(tunnel); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEdit(); + } + }; + return ( +
+
+
+ {displayTunnelName(tunnel)} +
+ - {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} - -
- {isEditing ? ( -
- setEditTargetUrl(event.target.value)} - disabled={!enabled || updating} - className="h-8 min-w-0 text-xs" - aria-label={t("projectTools.tunnelTargetUrl")} - /> - {editTargetValidationKey ? ( -
- {t(editTargetValidationKey)} -
- ) : null} -
+ /> + {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} + +
+ + {isEditing ? ( + <> +
+
+ + setEditTargetUrl(event.target.value)} + onKeyDown={handleEditKeyDown} + disabled={!enabled || updating} + inputMode="url" + autoComplete="off" + spellCheck={false} + className={cn(TUNNEL_INPUT_CLASS, "font-mono")} + /> + {editTargetValidationKey ? ( +
+ + {t(editTargetValidationKey)} +
+ ) : null} +
+
+ setEditName(event.target.value)} + onKeyDown={handleEditKeyDown} placeholder={t("projectTools.tunnelNamePlaceholder")} disabled={!enabled || updating} - className="h-8 min-w-0 text-xs" - aria-label={t("projectTools.tunnelName")} + autoComplete="off" + className={TUNNEL_INPUT_CLASS} /> -
- -
- {TTL_OPTIONS.map((option) => ( - - ))} -
-
-
-
- ) : ( - <> -
- {t("projectTools.tunnelTarget")}: {tunnel.targetUrl} -
-
- - - {tunnel.publicUrl} -
-
- - - - {!hasExpiry - ? t("projectTools.tunnelTtlInfinite") - : expired - ? t("projectTools.tunnelExpired") - : t("projectTools.tunnelExpiresIn").replace( - "{time}", - formatRemaining(remaining), - )} - - - {scope === "global" ? ( - - {t( - tunnelProjectPathKey - ? "projectTools.tunnelScopeProjectBadge" - : "projectTools.tunnelScopeGlobalBadge", - )} - - ) : null} - {hasExpiry ? {formatDateTime(tunnel.expiresAt)} : null} +
+ +
- - )} -
-
-
- {isEditing ? ( - <> - - +
+
+ + +
) : ( <> - - - +
- - - + + {t("projectTools.tunnelTarget")} + {tunnel.targetUrl} +
+
+
+ + + + {!hasExpiry + ? t("projectTools.tunnelTtlInfinite") + : expired + ? t("projectTools.tunnelExpired") + : t("projectTools.tunnelExpiresIn").replace( + "{time}", + formatRemaining(remaining), + )} + + + {scope === "global" ? ( + + {t( + tunnelProjectPathKey + ? "projectTools.tunnelScopeProjectBadge" + : "projectTools.tunnelScopeGlobalBadge", + )} + + ) : null} +
+
+ + + +
+
)}
-
- ); - })} -
- )} + ); + })} +
+ )} +
); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 1888d1b8a..9718ce48e 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -282,6 +282,10 @@ export const translations: Record> = { "projectTools.tunnelCancelEdit": "取消编辑", "projectTools.tunnelLoading": "正在加载内网穿透...", "projectTools.tunnelEmpty": "还没有内网穿透链接", + "projectTools.tunnelCreateSection": "新建链接", + "projectTools.tunnelListSection": "链接列表", + "projectTools.tunnelEmptyHintCreate": "在上方新建第一个临时链接", + "projectTools.tunnelEmptyHintProject": "切换到「当前项目」即可创建链接", "projectTools.tunnelTargetRequired": "请输入本地 HTTP 服务地址。", "projectTools.tunnelInvalidUrl": "请输入有效的 http://localhost 地址,不能包含账号、密码或片段。", @@ -1545,6 +1549,10 @@ export const translations: Record> = { "projectTools.tunnelCancelEdit": "Cancel edit", "projectTools.tunnelLoading": "Loading tunnels...", "projectTools.tunnelEmpty": "No tunnel links yet", + "projectTools.tunnelCreateSection": "New link", + "projectTools.tunnelListSection": "Links", + "projectTools.tunnelEmptyHintCreate": "Create your first temporary link above", + "projectTools.tunnelEmptyHintProject": "Switch to Current Project to create links", "projectTools.tunnelTargetRequired": "Enter a local HTTP service URL.", "projectTools.tunnelInvalidUrl": "Enter a valid http://localhost URL without credentials or fragments.",