feat: add stdio MCP transport support
- Add McpTransport enum with Http and Stdio variants - Update McpServerConfig to support both transport types - Implement stdio process spawning and JSON-RPC communication - Store tool descriptors with full metadata (name, description, schema) - Maintain backwards compatibility with existing HTTP MCPs This enables using community MCP servers that use stdio transport (which is the standard for most MCP servers).
This commit is contained in:
452
ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj
Normal file
452
ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,452 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84519FDE8FC75084938B292 /* ControlView.swift */; };
|
||||
0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 66A48A20D2178760301256C9 /* Assets.xcassets */; };
|
||||
29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A09A33A3A1A99446C8A88DC /* HistoryView.swift */; };
|
||||
4B50B97618C0CC469FF64592 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504A1222CE8971417834D229 /* Theme.swift */; };
|
||||
4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */; };
|
||||
5152C5313CD5AC01276D0AE6 /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */; };
|
||||
6865FE997D3E1D91D411F6BC /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9834D4EE32058824F9DF00 /* LoadingView.swift */; };
|
||||
6B87076797C9DFA01E24CC76 /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5908645A518F48B501390AB8 /* FilesView.swift */; };
|
||||
999ACAA94B0BD81A05288092 /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5A4720378F06807FDE73E1 /* GlassCard.swift */; };
|
||||
9BC40E40E1B5622B24328AEB /* Mission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AB47CF121ABA1946A4D879 /* Mission.swift */; };
|
||||
AA02567226057045DDD61CB1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B57FC3136B64DC87413CA6 /* ContentView.swift */; };
|
||||
CA70EC5A864C3D007D42E781 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB591B632D3EF26AB217976 /* ChatMessage.swift */; };
|
||||
D64972881E36894950658708 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC90C32FEF604E025FFBF78 /* APIService.swift */; };
|
||||
DA4634D7424AF3FC985987E7 /* GlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DE67017A858357F68424 /* GlassButton.swift */; };
|
||||
FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */; };
|
||||
FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
||||
139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAgentDashboardApp.swift; sourceTree = "<group>"; };
|
||||
2B9834D4EE32058824F9DF00 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
|
||||
3CB591B632D3EF26AB217976 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
|
||||
43A2EBAE84C0FFDCA5E1D66E /* OpenAgentDashboard.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenAgentDashboard.entitlements; sourceTree = "<group>"; };
|
||||
4D3D6B3EA3B04DE534F9709A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
504A1222CE8971417834D229 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
5267DE67017A858357F68424 /* GlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButton.swift; sourceTree = "<group>"; };
|
||||
5908645A518F48B501390AB8 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = "<group>"; };
|
||||
5A09A33A3A1A99446C8A88DC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
66A48A20D2178760301256C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
99B57FC3136B64DC87413CA6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
A84519FDE8FC75084938B292 /* ControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlView.swift; sourceTree = "<group>"; };
|
||||
BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = "<group>"; };
|
||||
CBC90C32FEF604E025FFBF78 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadge.swift; sourceTree = "<group>"; };
|
||||
D4AB47CF121ABA1946A4D879 /* Mission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mission.swift; sourceTree = "<group>"; };
|
||||
EB5A4720378F06807FDE73E1 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = "<group>"; };
|
||||
F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = OpenAgentDashboard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0C1185300420EEF31B892A3A /* Files */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5908645A518F48B501390AB8 /* FilesView.swift */,
|
||||
);
|
||||
path = Files;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0D9369EE2F3374EAA1EF332E /* Terminal */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */,
|
||||
);
|
||||
path = Terminal;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1B2400F48D7D400DF42A11F0 /* DesignSystem */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504A1222CE8971417834D229 /* Theme.swift */,
|
||||
);
|
||||
path = DesignSystem;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
279F9B8FE97DDCBF76C2E85E /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2EF415E84544334B25BD8E26 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5267DE67017A858357F68424 /* GlassButton.swift */,
|
||||
EB5A4720378F06807FDE73E1 /* GlassCard.swift */,
|
||||
2B9834D4EE32058824F9DF00 /* LoadingView.swift */,
|
||||
CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5A40B212F0D2055C1C499FCC /* History */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5A09A33A3A1A99446C8A88DC /* HistoryView.swift */,
|
||||
);
|
||||
path = History;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
73D80C56FA670F92E007E712 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2EF415E84544334B25BD8E26 /* Components */,
|
||||
DABAA3652C0B0A54CFC3221B /* Control */,
|
||||
0C1185300420EEF31B892A3A /* Files */,
|
||||
5A40B212F0D2055C1C499FCC /* History */,
|
||||
0D9369EE2F3374EAA1EF332E /* Terminal */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AB86DCEEB152D8EA7E8CBD86 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C86E333A0549E3B163391090 /* OpenAgentDashboard */,
|
||||
279F9B8FE97DDCBF76C2E85E /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C786EDDB39D9D19A1A112CE9 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3CB591B632D3EF26AB217976 /* ChatMessage.swift */,
|
||||
BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */,
|
||||
D4AB47CF121ABA1946A4D879 /* Mission.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C86E333A0549E3B163391090 /* OpenAgentDashboard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
66A48A20D2178760301256C9 /* Assets.xcassets */,
|
||||
99B57FC3136B64DC87413CA6 /* ContentView.swift */,
|
||||
4D3D6B3EA3B04DE534F9709A /* Info.plist */,
|
||||
43A2EBAE84C0FFDCA5E1D66E /* OpenAgentDashboard.entitlements */,
|
||||
139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */,
|
||||
1B2400F48D7D400DF42A11F0 /* DesignSystem */,
|
||||
C786EDDB39D9D19A1A112CE9 /* Models */,
|
||||
E9CA77690CC753DF6D133ACC /* Services */,
|
||||
73D80C56FA670F92E007E712 /* Views */,
|
||||
);
|
||||
path = OpenAgentDashboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DABAA3652C0B0A54CFC3221B /* Control */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A84519FDE8FC75084938B292 /* ControlView.swift */,
|
||||
);
|
||||
path = Control;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9CA77690CC753DF6D133ACC /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CBC90C32FEF604E025FFBF78 /* APIService.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
DD68473111E6CED00E695F44 /* OpenAgentDashboard */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 36DB69EB7A3A5AEB4D9D3B57 /* Build configuration list for PBXNativeTarget "OpenAgentDashboard" */;
|
||||
buildPhases = (
|
||||
BE523DA1714AE19926D7309A /* Sources */,
|
||||
F834FCE2F6EA811F16BF98AE /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = OpenAgentDashboard;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = OpenAgentDashboard;
|
||||
productReference = F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
F2797B25B56CE919907DC4F7 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
DD68473111E6CED00E695F44 = {
|
||||
DevelopmentTeam = "";
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = DFB11F92DB10F2E14DD9B35E /* Build configuration list for PBXProject "OpenAgentDashboard" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = AB86DCEEB152D8EA7E8CBD86;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
DD68473111E6CED00E695F44 /* OpenAgentDashboard */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
F834FCE2F6EA811F16BF98AE /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
BE523DA1714AE19926D7309A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D64972881E36894950658708 /* APIService.swift in Sources */,
|
||||
CA70EC5A864C3D007D42E781 /* ChatMessage.swift in Sources */,
|
||||
AA02567226057045DDD61CB1 /* ContentView.swift in Sources */,
|
||||
02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */,
|
||||
5152C5313CD5AC01276D0AE6 /* FileEntry.swift in Sources */,
|
||||
6B87076797C9DFA01E24CC76 /* FilesView.swift in Sources */,
|
||||
DA4634D7424AF3FC985987E7 /* GlassButton.swift in Sources */,
|
||||
999ACAA94B0BD81A05288092 /* GlassCard.swift in Sources */,
|
||||
29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */,
|
||||
6865FE997D3E1D91D411F6BC /* LoadingView.swift in Sources */,
|
||||
9BC40E40E1B5622B24328AEB /* Mission.swift in Sources */,
|
||||
FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */,
|
||||
FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */,
|
||||
4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */,
|
||||
4B50B97618C0CC469FF64592 /* Theme.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
387AE8B7392A5AF971AD749A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = OpenAgentDashboard/OpenAgentDashboard.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = OpenAgentDashboard/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = md.thomas.openagent.dashboard;
|
||||
PRODUCT_NAME = "Open Agent";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
9A248EB7DD7B3E88A6324395 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGNING_REQUIRED = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
ADC68A4DC006EED0F9D123CC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = OpenAgentDashboard/OpenAgentDashboard.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = OpenAgentDashboard/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = md.thomas.openagent.dashboard;
|
||||
PRODUCT_NAME = "Open Agent";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B45295B3864E2C7973AE65C3 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGNING_REQUIRED = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
36DB69EB7A3A5AEB4D9D3B57 /* Build configuration list for PBXNativeTarget "OpenAgentDashboard" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
387AE8B7392A5AF971AD749A /* Debug */,
|
||||
ADC68A4DC006EED0F9D123CC /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
DFB11F92DB10F2E14DD9B35E /* Build configuration list for PBXProject "OpenAgentDashboard" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
9A248EB7DD7B3E88A6324395 /* Debug */,
|
||||
B45295B3864E2C7973AE65C3 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = F2797B25B56CE919907DC4F7 /* Project object */;
|
||||
}
|
||||
7
ios_dashboard/OpenAgentDashboard.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios_dashboard/OpenAgentDashboard.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.945",
|
||||
"green" : "0.400",
|
||||
"red" : "0.388"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.945",
|
||||
"green" : "0.400",
|
||||
"red" : "0.388"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.078",
|
||||
"green" : "0.071",
|
||||
"red" : "0.071"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
274
ios_dashboard/OpenAgentDashboard/ContentView.swift
Normal file
274
ios_dashboard/OpenAgentDashboard/ContentView.swift
Normal file
@@ -0,0 +1,274 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Main content view with authentication gate and tab navigation
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var isAuthenticated = false
|
||||
@State private var isCheckingAuth = true
|
||||
@State private var authRequired = false
|
||||
|
||||
private let api = APIService.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isCheckingAuth {
|
||||
LoadingView(message: "Connecting...")
|
||||
.background(Theme.backgroundPrimary.ignoresSafeArea())
|
||||
} else if authRequired && !isAuthenticated {
|
||||
LoginView(onLogin: { isAuthenticated = true })
|
||||
} else {
|
||||
MainTabView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await checkAuth()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkAuth() async {
|
||||
isCheckingAuth = true
|
||||
|
||||
do {
|
||||
let _ = try await api.checkHealth()
|
||||
authRequired = api.authRequired
|
||||
isAuthenticated = api.isAuthenticated || !authRequired
|
||||
} catch {
|
||||
// If health check fails, assume we need auth
|
||||
authRequired = true
|
||||
isAuthenticated = api.isAuthenticated
|
||||
}
|
||||
|
||||
isCheckingAuth = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login View
|
||||
|
||||
struct LoginView: View {
|
||||
let onLogin: () -> Void
|
||||
|
||||
@State private var password = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var serverURL: String
|
||||
|
||||
@FocusState private var isPasswordFocused: Bool
|
||||
|
||||
private let api = APIService.shared
|
||||
|
||||
init(onLogin: @escaping () -> Void) {
|
||||
self.onLogin = onLogin
|
||||
_serverURL = State(initialValue: APIService.shared.baseURL)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background
|
||||
Theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
// Gradient accents
|
||||
RadialGradient(
|
||||
colors: [Theme.accent.opacity(0.15), .clear],
|
||||
center: .topTrailing,
|
||||
startRadius: 50,
|
||||
endRadius: 400
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
RadialGradient(
|
||||
colors: [Color.purple.opacity(0.1), .clear],
|
||||
center: .bottomLeading,
|
||||
startRadius: 50,
|
||||
endRadius: 400
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
.frame(height: 60)
|
||||
|
||||
// Logo
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "brain.head.profile")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("Open Agent")
|
||||
.font(.largeTitle.bold())
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
|
||||
Text("Dashboard")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Login form
|
||||
GlassCard(padding: 24, cornerRadius: 28) {
|
||||
VStack(spacing: 20) {
|
||||
// Server URL field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Server URL")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
|
||||
TextField("https://agent-backend.example.com", text: $serverURL)
|
||||
.textFieldStyle(.plain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Password field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Password")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
|
||||
SecureField("Enter password", text: $password)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($isPasswordFocused)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(isPasswordFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1)
|
||||
)
|
||||
.onSubmit {
|
||||
login()
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
if let error = errorMessage {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(Theme.error)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.error)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// Login button
|
||||
GlassPrimaryButton(
|
||||
"Sign In",
|
||||
icon: "arrow.right",
|
||||
isLoading: isLoading,
|
||||
isDisabled: password.isEmpty
|
||||
) {
|
||||
login()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func login() {
|
||||
guard !password.isEmpty else { return }
|
||||
|
||||
// Update server URL
|
||||
api.baseURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let _ = try await api.login(password: password)
|
||||
HapticService.success()
|
||||
onLogin()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
HapticService.error()
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Tab View
|
||||
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab: TabItem = .control
|
||||
|
||||
enum TabItem: String, CaseIterable {
|
||||
case control = "Control"
|
||||
case history = "History"
|
||||
case terminal = "Terminal"
|
||||
case files = "Files"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .control: return "message.fill"
|
||||
case .history: return "clock.fill"
|
||||
case .terminal: return "terminal.fill"
|
||||
case .files: return "folder.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(TabItem.allCases, id: \.rawValue) { tab in
|
||||
NavigationStack {
|
||||
tabContent(for: tab)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.rawValue, systemImage: tab.icon)
|
||||
}
|
||||
.tag(tab)
|
||||
}
|
||||
}
|
||||
.tint(Theme.accent)
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
HapticService.selectionChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func tabContent(for tab: TabItem) -> some View {
|
||||
switch tab {
|
||||
case .control:
|
||||
ControlView()
|
||||
case .history:
|
||||
HistoryView()
|
||||
case .terminal:
|
||||
TerminalView()
|
||||
case .files:
|
||||
FilesView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Login") {
|
||||
LoginView(onLogin: {})
|
||||
}
|
||||
|
||||
#Preview("Main") {
|
||||
MainTabView()
|
||||
}
|
||||
212
ios_dashboard/OpenAgentDashboard/DesignSystem/Theme.swift
Normal file
212
ios_dashboard/OpenAgentDashboard/DesignSystem/Theme.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
//
|
||||
// Theme.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Native-first, quiet confidence theme tokens
|
||||
// "Quiet Luxury + Liquid Glass" - Dark-first, Vercel/shadcn inspired
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum Theme {
|
||||
|
||||
// MARK: - Surfaces
|
||||
// Deep charcoal backgrounds - avoid pure black for quiet luxury feel
|
||||
|
||||
/// Primary background: #121214 - deep charcoal, not pure black
|
||||
static let backgroundPrimary = Color(
|
||||
uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.071, green: 0.071, blue: 0.078, alpha: 1.0)
|
||||
: UIColor.systemBackground
|
||||
}
|
||||
)
|
||||
|
||||
/// Secondary/elevated background: #1C1C1E - iOS system secondary background
|
||||
static let backgroundSecondary = Color(uiColor: .secondarySystemBackground)
|
||||
|
||||
/// Tertiary background: #2C2C2E - for nested elements
|
||||
static let backgroundTertiary = Color(
|
||||
uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1.0)
|
||||
: UIColor.tertiarySystemBackground
|
||||
}
|
||||
)
|
||||
|
||||
/// Card surface: subtle elevation from background
|
||||
static let card = Color(
|
||||
uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0)
|
||||
: UIColor.secondarySystemBackground
|
||||
}
|
||||
)
|
||||
|
||||
/// Elevated card: for nested or interactive elements
|
||||
static let cardElevated = Color(
|
||||
uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1.0)
|
||||
: UIColor.tertiarySystemBackground
|
||||
}
|
||||
)
|
||||
|
||||
/// Subtle divider/hairline
|
||||
static let hairline = Color(uiColor: .separator)
|
||||
|
||||
/// Border color with low opacity
|
||||
static let border = Color.white.opacity(0.06)
|
||||
static let borderElevated = Color.white.opacity(0.08)
|
||||
|
||||
// MARK: - Accent
|
||||
// Single accent color for primary actions - indigo per style guide
|
||||
static let accent = Color.indigo
|
||||
static let accentLight = Color(red: 0.388, green: 0.4, blue: 0.945)
|
||||
|
||||
// MARK: - Semantic Colors
|
||||
static let success = Color(red: 0.133, green: 0.773, blue: 0.369) // #22C55E
|
||||
static let warning = Color(red: 0.918, green: 0.702, blue: 0.031) // #EAB308
|
||||
static let error = Color(red: 0.937, green: 0.267, blue: 0.267) // #EF4444
|
||||
static let info = Color(red: 0.231, green: 0.510, blue: 0.965) // #3B82F6
|
||||
|
||||
// MARK: - Text
|
||||
// Use semantic colors for proper dark/light mode support
|
||||
static let textPrimary = Color(uiColor: .label)
|
||||
static let textSecondary = Color(uiColor: .secondaryLabel)
|
||||
static let textTertiary = Color(uiColor: .tertiaryLabel)
|
||||
static let textMuted = Color.white.opacity(0.4)
|
||||
|
||||
// MARK: - Typography Helpers
|
||||
|
||||
static func metric(_ value: Double) -> Text {
|
||||
Text(value, format: .number.precision(.fractionLength(0)))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
static func metric(_ value: Int) -> Text {
|
||||
Text("\(value)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Extensions
|
||||
|
||||
extension View {
|
||||
/// Apply the primary background
|
||||
func themeBackground() -> some View {
|
||||
background(Theme.backgroundPrimary.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// Card style with subtle elevation
|
||||
func themeCard() -> some View {
|
||||
self
|
||||
.background(Theme.card)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
/// Elevated card style
|
||||
func themeCardElevated() -> some View {
|
||||
self
|
||||
.background(Theme.cardElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
/// Apply subtle border
|
||||
func themeBorder() -> some View {
|
||||
self.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Button Styles
|
||||
|
||||
struct GlassButtonStyle: ButtonStyle {
|
||||
var isProminent: Bool = false
|
||||
|
||||
@ViewBuilder
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
if isProminent {
|
||||
configuration.label
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(.white.opacity(0.1), lineWidth: 0.5)
|
||||
)
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
|
||||
} else {
|
||||
configuration.label
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(.white.opacity(0.15), lineWidth: 0.5)
|
||||
)
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassProminentButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.foregroundStyle(.white)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Theme.accent, Theme.accent.opacity(0.85)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.shadow(color: Theme.accent.opacity(0.3), radius: 12, y: 6)
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
extension ButtonStyle where Self == GlassButtonStyle {
|
||||
static var glass: GlassButtonStyle { GlassButtonStyle() }
|
||||
static var glassProminent: GlassButtonStyle { GlassButtonStyle(isProminent: true) }
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
@MainActor
|
||||
enum HapticService {
|
||||
static func lightTap() {
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
static func mediumTap() {
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
static func selectionChanged() {
|
||||
let generator = UISelectionFeedbackGenerator()
|
||||
generator.selectionChanged()
|
||||
}
|
||||
|
||||
static func success() {
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(.success)
|
||||
}
|
||||
|
||||
static func error() {
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(.error)
|
||||
}
|
||||
}
|
||||
58
ios_dashboard/OpenAgentDashboard/Info.plist
Normal file
58
ios_dashboard/OpenAgentDashboard/Info.plist
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Open Agent</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
90
ios_dashboard/OpenAgentDashboard/Models/ChatMessage.swift
Normal file
90
ios_dashboard/OpenAgentDashboard/Models/ChatMessage.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// ChatMessage.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Chat message models for the control view
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ChatMessageType {
|
||||
case user
|
||||
case assistant(success: Bool, costCents: Int, model: String?)
|
||||
case thinking(done: Bool, startTime: Date)
|
||||
case system
|
||||
case error
|
||||
}
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id: String
|
||||
let type: ChatMessageType
|
||||
var content: String
|
||||
let timestamp: Date
|
||||
|
||||
init(id: String = UUID().uuidString, type: ChatMessageType, content: String, timestamp: Date = Date()) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.content = content
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
var isUser: Bool {
|
||||
if case .user = type { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isAssistant: Bool {
|
||||
if case .assistant = type { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isThinking: Bool {
|
||||
if case .thinking = type { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var thinkingDone: Bool {
|
||||
if case .thinking(let done, _) = type { return done }
|
||||
return false
|
||||
}
|
||||
|
||||
var displayModel: String? {
|
||||
if case .assistant(_, _, let model) = type {
|
||||
if let model = model {
|
||||
return model.split(separator: "/").last.map(String.init)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var costFormatted: String? {
|
||||
if case .assistant(_, let costCents, _) = type, costCents > 0 {
|
||||
return String(format: "$%.4f", Double(costCents) / 100.0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Control Session State
|
||||
|
||||
enum ControlRunState: String, Codable {
|
||||
case idle
|
||||
case running
|
||||
case waitingForTool = "waiting_for_tool"
|
||||
|
||||
var statusType: StatusType {
|
||||
switch self {
|
||||
case .idle: return .idle
|
||||
case .running: return .running
|
||||
case .waitingForTool: return .pending
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .idle: return "Idle"
|
||||
case .running: return "Running"
|
||||
case .waitingForTool: return "Waiting"
|
||||
}
|
||||
}
|
||||
}
|
||||
71
ios_dashboard/OpenAgentDashboard/Models/FileEntry.swift
Normal file
71
ios_dashboard/OpenAgentDashboard/Models/FileEntry.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// FileEntry.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// File system entry models
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FileEntry: Codable, Identifiable {
|
||||
let name: String
|
||||
let path: String
|
||||
let kind: String
|
||||
let size: Int
|
||||
let mtime: Int
|
||||
|
||||
var id: String { path }
|
||||
|
||||
var isDirectory: Bool {
|
||||
kind == "dir"
|
||||
}
|
||||
|
||||
var isFile: Bool {
|
||||
kind == "file"
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
if isDirectory {
|
||||
return "folder.fill"
|
||||
}
|
||||
|
||||
let ext = (name as NSString).pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "swift", "rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "c", "cpp", "h":
|
||||
return "doc.text.fill"
|
||||
case "json", "yaml", "yml", "toml", "xml":
|
||||
return "doc.badge.gearshape.fill"
|
||||
case "md", "txt", "log":
|
||||
return "doc.plaintext.fill"
|
||||
case "png", "jpg", "jpeg", "gif", "svg", "webp":
|
||||
return "photo.fill"
|
||||
case "pdf":
|
||||
return "doc.richtext.fill"
|
||||
case "zip", "tar", "gz", "rar":
|
||||
return "doc.zipper"
|
||||
default:
|
||||
return "doc.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var formattedSize: String {
|
||||
guard isFile else { return "—" }
|
||||
if size < 1024 { return "\(size) B" }
|
||||
|
||||
let units = ["KB", "MB", "GB", "TB"]
|
||||
var value = Double(size) / 1024.0
|
||||
var unitIndex = 0
|
||||
|
||||
while value >= 1024 && unitIndex < units.count - 1 {
|
||||
value /= 1024.0
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return value >= 10 ? String(format: "%.0f %@", value, units[unitIndex])
|
||||
: String(format: "%.1f %@", value, units[unitIndex])
|
||||
}
|
||||
|
||||
var modifiedDate: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(mtime))
|
||||
}
|
||||
}
|
||||
131
ios_dashboard/OpenAgentDashboard/Models/Mission.swift
Normal file
131
ios_dashboard/OpenAgentDashboard/Models/Mission.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// Mission.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Mission and task data models
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MissionStatus: String, Codable, CaseIterable {
|
||||
case active
|
||||
case completed
|
||||
case failed
|
||||
|
||||
var statusType: StatusType {
|
||||
switch self {
|
||||
case .active: return .active
|
||||
case .completed: return .completed
|
||||
case .failed: return .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MissionHistoryEntry: Codable, Identifiable {
|
||||
var id: String { "\(role)-\(content.prefix(20))" }
|
||||
let role: String
|
||||
let content: String
|
||||
|
||||
var isUser: Bool {
|
||||
role == "user"
|
||||
}
|
||||
}
|
||||
|
||||
struct Mission: Codable, Identifiable, Hashable {
|
||||
let id: String
|
||||
var status: MissionStatus
|
||||
let title: String?
|
||||
let history: [MissionHistoryEntry]
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
static func == (lhs: Mission, rhs: Mission) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, status, title, history
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
if let title = title, !title.isEmpty {
|
||||
return title.count > 60 ? String(title.prefix(60)) + "..." : title
|
||||
}
|
||||
return "Untitled Mission"
|
||||
}
|
||||
|
||||
var updatedDate: Date? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
enum TaskStatus: String, Codable, CaseIterable {
|
||||
case pending
|
||||
case running
|
||||
case completed
|
||||
case failed
|
||||
case cancelled
|
||||
|
||||
var statusType: StatusType {
|
||||
switch self {
|
||||
case .pending: return .pending
|
||||
case .running: return .running
|
||||
case .completed: return .completed
|
||||
case .failed: return .failed
|
||||
case .cancelled: return .cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskState: Codable, Identifiable {
|
||||
let id: String
|
||||
let status: TaskStatus
|
||||
let task: String
|
||||
let model: String
|
||||
let iterations: Int
|
||||
let result: String?
|
||||
|
||||
var displayModel: String {
|
||||
if let lastPart = model.split(separator: "/").last {
|
||||
return String(lastPart)
|
||||
}
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
struct Run: Codable, Identifiable {
|
||||
let id: String
|
||||
let createdAt: String
|
||||
let status: String
|
||||
let inputText: String
|
||||
let finalOutput: String?
|
||||
let totalCostCents: Int
|
||||
let summaryText: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, status
|
||||
case createdAt = "created_at"
|
||||
case inputText = "input_text"
|
||||
case finalOutput = "final_output"
|
||||
case totalCostCents = "total_cost_cents"
|
||||
case summaryText = "summary_text"
|
||||
}
|
||||
|
||||
var costDollars: Double {
|
||||
Double(totalCostCents) / 100.0
|
||||
}
|
||||
|
||||
var createdDate: Date? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.date(from: createdAt) ?? ISO8601DateFormatter().date(from: createdAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
</dict>
|
||||
</plist>
|
||||
18
ios_dashboard/OpenAgentDashboard/OpenAgentDashboardApp.swift
Normal file
18
ios_dashboard/OpenAgentDashboard/OpenAgentDashboardApp.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// OpenAgentDashboardApp.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// iOS Dashboard for Open Agent with liquid glass design
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OpenAgentDashboardApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
364
ios_dashboard/OpenAgentDashboard/Services/APIService.swift
Normal file
364
ios_dashboard/OpenAgentDashboard/Services/APIService.swift
Normal file
@@ -0,0 +1,364 @@
|
||||
//
|
||||
// APIService.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// HTTP API client for the Open Agent backend
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class APIService {
|
||||
static let shared = APIService()
|
||||
nonisolated init() {}
|
||||
|
||||
// Configuration
|
||||
var baseURL: String {
|
||||
get { UserDefaults.standard.string(forKey: "api_base_url") ?? "https://agent-backend.thomas.md" }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "api_base_url") }
|
||||
}
|
||||
|
||||
private var jwtToken: String? {
|
||||
get { UserDefaults.standard.string(forKey: "jwt_token") }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "jwt_token") }
|
||||
}
|
||||
|
||||
var isAuthenticated: Bool {
|
||||
jwtToken != nil
|
||||
}
|
||||
|
||||
var authRequired: Bool = false
|
||||
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
func login(password: String) async throws -> Bool {
|
||||
struct LoginRequest: Encodable {
|
||||
let password: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let token: String
|
||||
let exp: Int
|
||||
}
|
||||
|
||||
let response: LoginResponse = try await post("/api/auth/login", body: LoginRequest(password: password), authenticated: false)
|
||||
jwtToken = response.token
|
||||
return true
|
||||
}
|
||||
|
||||
func logout() {
|
||||
jwtToken = nil
|
||||
}
|
||||
|
||||
func checkHealth() async throws -> Bool {
|
||||
struct HealthResponse: Decodable {
|
||||
let status: String
|
||||
let authRequired: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case authRequired = "auth_required"
|
||||
}
|
||||
}
|
||||
|
||||
let response: HealthResponse = try await get("/api/health", authenticated: false)
|
||||
authRequired = response.authRequired
|
||||
return response.status == "ok"
|
||||
}
|
||||
|
||||
// MARK: - Missions
|
||||
|
||||
func listMissions() async throws -> [Mission] {
|
||||
try await get("/api/control/missions")
|
||||
}
|
||||
|
||||
func getMission(id: String) async throws -> Mission {
|
||||
try await get("/api/control/missions/\(id)")
|
||||
}
|
||||
|
||||
func getCurrentMission() async throws -> Mission? {
|
||||
try await get("/api/control/missions/current")
|
||||
}
|
||||
|
||||
func createMission() async throws -> Mission {
|
||||
try await post("/api/control/missions", body: EmptyBody())
|
||||
}
|
||||
|
||||
func loadMission(id: String) async throws -> Mission {
|
||||
try await post("/api/control/missions/\(id)/load", body: EmptyBody())
|
||||
}
|
||||
|
||||
func setMissionStatus(id: String, status: MissionStatus) async throws {
|
||||
struct StatusRequest: Encodable {
|
||||
let status: String
|
||||
}
|
||||
let _: EmptyResponse = try await post("/api/control/missions/\(id)/status", body: StatusRequest(status: status.rawValue))
|
||||
}
|
||||
|
||||
// MARK: - Control
|
||||
|
||||
func sendMessage(content: String) async throws -> (id: String, queued: Bool) {
|
||||
struct MessageRequest: Encodable {
|
||||
let content: String
|
||||
}
|
||||
|
||||
struct MessageResponse: Decodable {
|
||||
let id: String
|
||||
let queued: Bool
|
||||
}
|
||||
|
||||
let response: MessageResponse = try await post("/api/control/message", body: MessageRequest(content: content))
|
||||
return (response.id, response.queued)
|
||||
}
|
||||
|
||||
func cancelControl() async throws {
|
||||
let _: EmptyResponse = try await post("/api/control/cancel", body: EmptyBody())
|
||||
}
|
||||
|
||||
// MARK: - Tasks
|
||||
|
||||
func listTasks() async throws -> [TaskState] {
|
||||
try await get("/api/tasks")
|
||||
}
|
||||
|
||||
// MARK: - Runs
|
||||
|
||||
func listRuns(limit: Int = 20, offset: Int = 0) async throws -> [Run] {
|
||||
struct RunsResponse: Decodable {
|
||||
let runs: [Run]
|
||||
}
|
||||
let response: RunsResponse = try await get("/api/runs?limit=\(limit)&offset=\(offset)")
|
||||
return response.runs
|
||||
}
|
||||
|
||||
// MARK: - File System
|
||||
|
||||
func listDirectory(path: String) async throws -> [FileEntry] {
|
||||
try await get("/api/fs/list?path=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? path)")
|
||||
}
|
||||
|
||||
func createDirectory(path: String) async throws {
|
||||
struct MkdirRequest: Encodable {
|
||||
let path: String
|
||||
}
|
||||
let _: EmptyResponse = try await post("/api/fs/mkdir", body: MkdirRequest(path: path))
|
||||
}
|
||||
|
||||
func deleteFile(path: String, recursive: Bool = false) async throws {
|
||||
struct RmRequest: Encodable {
|
||||
let path: String
|
||||
let recursive: Bool
|
||||
}
|
||||
let _: EmptyResponse = try await post("/api/fs/rm", body: RmRequest(path: path, recursive: recursive))
|
||||
}
|
||||
|
||||
func downloadURL(path: String) -> URL? {
|
||||
guard var components = URLComponents(string: baseURL) else { return nil }
|
||||
components.path = "/api/fs/download"
|
||||
components.queryItems = [URLQueryItem(name: "path", value: path)]
|
||||
return components.url
|
||||
}
|
||||
|
||||
func uploadFile(data: Data, fileName: String, directory: String) async throws -> String {
|
||||
guard let url = URL(string: "\(baseURL)/api/fs/upload?path=\(directory.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? directory)") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
|
||||
if let token = jwtToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let boundary = UUID().uuidString
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(data)
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
request.httpBody = body
|
||||
|
||||
let (responseData, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
logout()
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
|
||||
throw APIError.httpError(httpResponse.statusCode, String(data: responseData, encoding: .utf8))
|
||||
}
|
||||
|
||||
struct UploadResponse: Decodable {
|
||||
let path: String
|
||||
}
|
||||
|
||||
let uploadResponse = try JSONDecoder().decode(UploadResponse.self, from: responseData)
|
||||
return uploadResponse.path
|
||||
}
|
||||
|
||||
// MARK: - SSE Streaming
|
||||
|
||||
func streamControl(onEvent: @escaping (String, [String: Any]) -> Void) -> Task<Void, Never> {
|
||||
Task {
|
||||
guard let url = URL(string: "\(baseURL)/api/control/stream") else { return }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
|
||||
if let token = jwtToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
do {
|
||||
let (stream, _) = try await URLSession.shared.bytes(for: request)
|
||||
|
||||
var buffer = ""
|
||||
for try await byte in stream {
|
||||
guard !Task.isCancelled else { break }
|
||||
|
||||
if let char = String(bytes: [byte], encoding: .utf8) {
|
||||
buffer.append(char)
|
||||
|
||||
// Look for double newline (end of SSE event)
|
||||
while let range = buffer.range(of: "\n\n") {
|
||||
let eventString = String(buffer[..<range.lowerBound])
|
||||
buffer = String(buffer[range.upperBound...])
|
||||
|
||||
parseSSEEvent(eventString, onEvent: onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
onEvent("error", ["message": "Stream connection failed: \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func parseSSEEvent(_ eventString: String, onEvent: @escaping (String, [String: Any]) -> Void) {
|
||||
var eventType = "message"
|
||||
var dataString = ""
|
||||
|
||||
for line in eventString.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let lineStr = String(line)
|
||||
if lineStr.hasPrefix("event:") {
|
||||
eventType = String(lineStr.dropFirst(6)).trimmingCharacters(in: .whitespaces)
|
||||
} else if lineStr.hasPrefix("data:") {
|
||||
dataString += String(lineStr.dropFirst(5)).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
guard !dataString.isEmpty else { return }
|
||||
|
||||
do {
|
||||
if let data = dataString.data(using: .utf8),
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
onEvent(eventType, json)
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private struct EmptyBody: Encodable {}
|
||||
private struct EmptyResponse: Decodable {}
|
||||
|
||||
private func get<T: Decodable>(_ path: String, authenticated: Bool = true) async throws -> T {
|
||||
guard let url = URL(string: "\(baseURL)\(path)") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
if authenticated, let token = jwtToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private func post<T: Decodable, B: Encodable>(_ path: String, body: B, authenticated: Bool = true) async throws -> T {
|
||||
guard let url = URL(string: "\(baseURL)\(path)") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if authenticated, let token = jwtToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
logout()
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
|
||||
throw APIError.httpError(httpResponse.statusCode, String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
// Handle empty responses
|
||||
if data.isEmpty || (T.self == EmptyResponse.self) {
|
||||
if let empty = EmptyResponse() as? T {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
case httpError(Int, String?)
|
||||
case decodingError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Invalid URL"
|
||||
case .invalidResponse:
|
||||
return "Invalid response from server"
|
||||
case .unauthorized:
|
||||
return "Authentication required"
|
||||
case .httpError(let code, let message):
|
||||
return "HTTP \(code): \(message ?? "Unknown error")"
|
||||
case .decodingError(let error):
|
||||
return "Failed to decode response: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//
|
||||
// GlassButton.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Glass morphism button components
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GlassButton: View {
|
||||
let title: String
|
||||
let icon: String?
|
||||
let action: () -> Void
|
||||
|
||||
@State private var isPressed = false
|
||||
|
||||
init(_ title: String, icon: String? = nil, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(.white.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 4)
|
||||
.scaleEffect(isPressed ? 0.97 : 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassPrimaryButton: View {
|
||||
let title: String
|
||||
let icon: String?
|
||||
let action: () -> Void
|
||||
var isLoading: Bool = false
|
||||
var isDisabled: Bool = false
|
||||
|
||||
@State private var isPressed = false
|
||||
|
||||
init(_ title: String, icon: String? = nil, isLoading: Bool = false, isDisabled: Bool = false, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.isLoading = isLoading
|
||||
self.isDisabled = isDisabled
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Theme.accent, Theme.accent.opacity(0.85)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.shadow(color: Theme.accent.opacity(0.3), radius: 12, y: 6)
|
||||
.scaleEffect(isPressed ? 0.97 : 1)
|
||||
.opacity(isDisabled ? 0.5 : 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading || isDisabled)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
guard !isLoading && !isDisabled else { return }
|
||||
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassIconButton: View {
|
||||
let icon: String
|
||||
let action: () -> Void
|
||||
var size: CGFloat = 44
|
||||
var tint: Color? = nil
|
||||
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: size * 0.4, weight: .semibold))
|
||||
.foregroundStyle(tint ?? .primary)
|
||||
.frame(width: size, height: size)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.06), radius: 6, y: 3)
|
||||
.scaleEffect(isPressed ? 0.92 : 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassDestructiveButton: View {
|
||||
let title: String
|
||||
let icon: String?
|
||||
let action: () -> Void
|
||||
|
||||
@State private var isPressed = false
|
||||
|
||||
init(_ title: String, icon: String? = nil, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Theme.error)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(Theme.error.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Theme.error.opacity(0.3), lineWidth: 0.5)
|
||||
)
|
||||
.scaleEffect(isPressed ? 0.97 : 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [.orange.opacity(0.5), .pink.opacity(0.4)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
GlassButton("Secondary Button", icon: "message") {
|
||||
print("Tapped")
|
||||
}
|
||||
|
||||
GlassPrimaryButton("Send Message", icon: "paperplane.fill") {
|
||||
print("Tapped")
|
||||
}
|
||||
|
||||
GlassPrimaryButton("Loading...", isLoading: true) {
|
||||
print("Tapped")
|
||||
}
|
||||
|
||||
GlassDestructiveButton("Delete", icon: "trash") {
|
||||
print("Delete")
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
GlassIconButton(icon: "message.fill") {
|
||||
print("Message")
|
||||
}
|
||||
|
||||
GlassIconButton(icon: "folder.fill") {
|
||||
print("Folder")
|
||||
}
|
||||
|
||||
GlassIconButton(icon: "terminal.fill", action: {
|
||||
print("Terminal")
|
||||
}, tint: Theme.accent)
|
||||
|
||||
GlassIconButton(icon: "xmark", action: {
|
||||
print("Close")
|
||||
}, size: 36)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//
|
||||
// GlassCard.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Beautiful glass morphism card components with liquid glass effects
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GlassCard<Content: View>: View {
|
||||
let content: Content
|
||||
var padding: CGFloat = 20
|
||||
var cornerRadius: CGFloat = 24
|
||||
|
||||
init(
|
||||
padding: CGFloat = 20,
|
||||
cornerRadius: CGFloat = 24,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.content = content()
|
||||
self.padding = padding
|
||||
self.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.06), radius: 16, y: 8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(.white.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassCardLight<Content: View>: View {
|
||||
let content: Content
|
||||
var padding: CGFloat = 16
|
||||
var cornerRadius: CGFloat = 20
|
||||
|
||||
init(
|
||||
padding: CGFloat = 16,
|
||||
cornerRadius: CGFloat = 20,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.content = content()
|
||||
self.padding = padding
|
||||
self.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassCardThick<Content: View>: View {
|
||||
let content: Content
|
||||
var padding: CGFloat = 20
|
||||
var cornerRadius: CGFloat = 24
|
||||
|
||||
init(
|
||||
padding: CGFloat = 20,
|
||||
cornerRadius: CGFloat = 24,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.content = content()
|
||||
self.padding = padding
|
||||
self.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(.thickMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.08), radius: 20, y: 10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(.white.opacity(0.25), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Interactive glass card with hover/press states
|
||||
struct InteractiveGlassCard<Content: View>: View {
|
||||
let content: Content
|
||||
var padding: CGFloat = 16
|
||||
var cornerRadius: CGFloat = 16
|
||||
let action: () -> Void
|
||||
|
||||
@State private var isPressed = false
|
||||
|
||||
init(
|
||||
padding: CGFloat = 16,
|
||||
cornerRadius: CGFloat = 16,
|
||||
action: @escaping () -> Void,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.content = content()
|
||||
self.padding = padding
|
||||
self.cornerRadius = cornerRadius
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(.ultraThinMaterial.opacity(isPressed ? 0.8 : 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(.white.opacity(isPressed ? 0.25 : 0.12), lineWidth: 0.5)
|
||||
)
|
||||
.scaleEffect(isPressed ? 0.98 : 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [.indigo.opacity(0.6), .purple.opacity(0.4)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
GlassCard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Glass Card")
|
||||
.font(.headline)
|
||||
Text("Beautiful translucent design")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
GlassCardLight {
|
||||
Text("Light Glass Card")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
GlassCardThick {
|
||||
Text("Thick Glass Card")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
InteractiveGlassCard(action: { print("Tapped") }) {
|
||||
Text("Interactive Card - Tap me!")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// LoadingView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Loading indicators and shimmer effects
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LoadingView: View {
|
||||
var message: String = "Loading..."
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
.tint(Theme.accent)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct ShimmerView: View {
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.04),
|
||||
Color.white.opacity(0.08),
|
||||
Color.white.opacity(0.04)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.offset(x: isAnimating ? 300 : -300)
|
||||
.animation(.linear(duration: 1.5).repeatForever(autoreverses: false), value: isAnimating)
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShimmerRow: View {
|
||||
var height: CGFloat = 16
|
||||
var width: CGFloat? = nil
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.frame(width: width, height: height)
|
||||
.overlay(ShimmerView())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
|
||||
struct ShimmerCard: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay(ShimmerView())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ShimmerRow(height: 14, width: 120)
|
||||
ShimmerRow(height: 12, width: 80)
|
||||
}
|
||||
}
|
||||
|
||||
ShimmerRow(height: 12)
|
||||
ShimmerRow(height: 12, width: 200)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.white.opacity(0.03))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
var action: (() -> Void)? = nil
|
||||
var actionLabel: String = "Try Again"
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
if let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionLabel)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Theme.accent.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 24) {
|
||||
LoadingView()
|
||||
.frame(height: 150)
|
||||
|
||||
ShimmerCard()
|
||||
.padding()
|
||||
|
||||
EmptyStateView(
|
||||
icon: "message.badge.filled.fill",
|
||||
title: "No Messages",
|
||||
message: "Start a conversation with the agent",
|
||||
action: { print("Tapped") }
|
||||
)
|
||||
.frame(height: 250)
|
||||
}
|
||||
.background(Theme.backgroundPrimary)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// StatusBadge.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Status indicator badges with semantic colors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum StatusType {
|
||||
case pending
|
||||
case running
|
||||
case active
|
||||
case completed
|
||||
case failed
|
||||
case cancelled
|
||||
case idle
|
||||
case error
|
||||
case connected
|
||||
case disconnected
|
||||
case connecting
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .pending, .idle:
|
||||
return Theme.textMuted
|
||||
case .running, .active, .connecting:
|
||||
return Theme.accent
|
||||
case .completed, .connected:
|
||||
return Theme.success
|
||||
case .failed, .error:
|
||||
return Theme.error
|
||||
case .cancelled, .disconnected:
|
||||
return Theme.textTertiary
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor: Color {
|
||||
color.opacity(0.15)
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .pending: return "Pending"
|
||||
case .running: return "Running"
|
||||
case .active: return "Active"
|
||||
case .completed: return "Completed"
|
||||
case .failed: return "Failed"
|
||||
case .cancelled: return "Cancelled"
|
||||
case .idle: return "Idle"
|
||||
case .error: return "Error"
|
||||
case .connected: return "Connected"
|
||||
case .disconnected: return "Disconnected"
|
||||
case .connecting: return "Connecting"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .pending: return "clock"
|
||||
case .running, .connecting: return "arrow.trianglehead.2.clockwise"
|
||||
case .active: return "circle.fill"
|
||||
case .completed: return "checkmark.circle.fill"
|
||||
case .failed, .error: return "xmark.circle.fill"
|
||||
case .cancelled: return "slash.circle"
|
||||
case .idle: return "moon.fill"
|
||||
case .connected: return "wifi"
|
||||
case .disconnected: return "wifi.slash"
|
||||
}
|
||||
}
|
||||
|
||||
var shouldPulse: Bool {
|
||||
switch self {
|
||||
case .running, .active, .connecting:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusBadge: View {
|
||||
let status: StatusType
|
||||
var showIcon: Bool = true
|
||||
var compact: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: compact ? 4 : 6) {
|
||||
if showIcon {
|
||||
Image(systemName: status.icon)
|
||||
.font(.system(size: compact ? 10 : 12, weight: .medium))
|
||||
.symbolEffect(.pulse, options: status.shouldPulse ? .repeating : .nonRepeating)
|
||||
}
|
||||
Text(status.label)
|
||||
.font(.system(size: compact ? 10 : 11, weight: .semibold))
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
.foregroundStyle(status.color)
|
||||
.padding(.horizontal, compact ? 8 : 10)
|
||||
.padding(.vertical, compact ? 4 : 6)
|
||||
.background(status.backgroundColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusDot: View {
|
||||
let status: StatusType
|
||||
var size: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(status.color)
|
||||
.frame(width: size, height: size)
|
||||
.overlay {
|
||||
if status.shouldPulse {
|
||||
Circle()
|
||||
.stroke(status.color.opacity(0.5), lineWidth: 2)
|
||||
.scaleEffect(1.5)
|
||||
.opacity(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 8) {
|
||||
StatusBadge(status: .pending)
|
||||
StatusBadge(status: .running)
|
||||
StatusBadge(status: .completed)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
StatusBadge(status: .failed)
|
||||
StatusBadge(status: .cancelled)
|
||||
StatusBadge(status: .active)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
StatusBadge(status: .connected, compact: true)
|
||||
StatusBadge(status: .disconnected, compact: true)
|
||||
StatusBadge(status: .connecting, compact: true)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 16) {
|
||||
ForEach([StatusType.active, .completed, .failed, .idle], id: \.label) { status in
|
||||
StatusDot(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Theme.backgroundPrimary)
|
||||
}
|
||||
538
ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift
Normal file
538
ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift
Normal file
@@ -0,0 +1,538 @@
|
||||
//
|
||||
// ControlView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Chat interface for the AI agent with real-time streaming
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ControlView: View {
|
||||
@State private var messages: [ChatMessage] = []
|
||||
@State private var inputText = ""
|
||||
@State private var runState: ControlRunState = .idle
|
||||
@State private var queueLength = 0
|
||||
@State private var currentMission: Mission?
|
||||
@State private var isLoading = true
|
||||
@State private var streamTask: Task<Void, Never>?
|
||||
@State private var showMissionMenu = false
|
||||
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
private let api = APIService.shared
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background with subtle accent glow
|
||||
Theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
// Subtle radial gradients for liquid glass refraction
|
||||
backgroundGlows
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
headerView
|
||||
|
||||
// Messages
|
||||
messagesView
|
||||
|
||||
// Input area
|
||||
inputView
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await loadCurrentMission()
|
||||
startStreaming()
|
||||
}
|
||||
.onDisappear {
|
||||
streamTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background
|
||||
|
||||
private var backgroundGlows: some View {
|
||||
ZStack {
|
||||
RadialGradient(
|
||||
colors: [Theme.accent.opacity(0.08), .clear],
|
||||
center: .topTrailing,
|
||||
startRadius: 20,
|
||||
endRadius: 400
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
|
||||
RadialGradient(
|
||||
colors: [Color.white.opacity(0.03), .clear],
|
||||
center: .bottomLeading,
|
||||
startRadius: 30,
|
||||
endRadius: 500
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerView: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Mission info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 8) {
|
||||
Text(currentMission?.displayTitle ?? "Control")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let status = currentMission?.status {
|
||||
StatusBadge(status: status.statusType, compact: true)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
StatusDot(status: runState.statusType, size: 6)
|
||||
Text(runState.label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
|
||||
if queueLength > 0 {
|
||||
Text("• Queue: \(queueLength)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Mission menu
|
||||
Menu {
|
||||
Button {
|
||||
Task { await createNewMission() }
|
||||
} label: {
|
||||
Label("New Mission", systemImage: "plus")
|
||||
}
|
||||
|
||||
if let mission = currentMission {
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
Task { await setMissionStatus(.completed) }
|
||||
} label: {
|
||||
Label("Mark Complete", systemImage: "checkmark.circle")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await setMissionStatus(.failed) }
|
||||
} label: {
|
||||
Label("Mark Failed", systemImage: "xmark.circle")
|
||||
}
|
||||
|
||||
if mission.status != .active {
|
||||
Button {
|
||||
Task { await setMissionStatus(.active) }
|
||||
} label: {
|
||||
Label("Reactivate", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
GlassIconButton(icon: "ellipsis", action: {}, size: 36)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
private var messagesView: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
if messages.isEmpty && !isLoading {
|
||||
emptyStateView
|
||||
} else if isLoading {
|
||||
LoadingView(message: "Loading conversation...")
|
||||
.frame(height: 200)
|
||||
} else {
|
||||
ForEach(messages) { message in
|
||||
MessageBubble(message: message)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
if let lastMessage = messages.last {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastMessage.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Theme.accent.opacity(0.6))
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Start a Conversation")
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
|
||||
Text("Send a message to the AI agent to begin")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(40)
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
private var inputView: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
.background(Theme.border)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
// Text input
|
||||
TextField("Message the agent...", text: $inputText, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.body)
|
||||
.lineLimit(1...5)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(isInputFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1)
|
||||
)
|
||||
.focused($isInputFocused)
|
||||
.onSubmit {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
// Send/Stop button
|
||||
if runState != .idle {
|
||||
Button {
|
||||
Task { await cancelRun() }
|
||||
} label: {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.error)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
sendMessage()
|
||||
} label: {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Theme.accent.opacity(0.5) : Theme.accent)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadCurrentMission() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
if let mission = try await api.getCurrentMission() {
|
||||
currentMission = mission
|
||||
messages = mission.history.enumerated().map { index, entry in
|
||||
ChatMessage(
|
||||
id: "\(mission.id)-\(index)",
|
||||
type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil),
|
||||
content: entry.content
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load mission: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func createNewMission() async {
|
||||
do {
|
||||
let mission = try await api.createMission()
|
||||
currentMission = mission
|
||||
messages = []
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to create mission: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
private func setMissionStatus(_ status: MissionStatus) async {
|
||||
guard let mission = currentMission else { return }
|
||||
|
||||
do {
|
||||
try await api.setMissionStatus(id: mission.id, status: status)
|
||||
currentMission?.status = status
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to set status: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let content = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
inputText = ""
|
||||
HapticService.lightTap()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let _ = try await api.sendMessage(content: content)
|
||||
} catch {
|
||||
print("Failed to send message: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelRun() async {
|
||||
do {
|
||||
try await api.cancelControl()
|
||||
HapticService.success()
|
||||
} catch {
|
||||
print("Failed to cancel: \(error)")
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
private func startStreaming() {
|
||||
streamTask = api.streamControl { eventType, data in
|
||||
Task { @MainActor in
|
||||
handleStreamEvent(type: eventType, data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleStreamEvent(type: String, data: [String: Any]) {
|
||||
switch type {
|
||||
case "status":
|
||||
if let state = data["state"] as? String {
|
||||
runState = ControlRunState(rawValue: state) ?? .idle
|
||||
}
|
||||
if let queue = data["queue_len"] as? Int {
|
||||
queueLength = queue
|
||||
}
|
||||
|
||||
case "user_message":
|
||||
if let content = data["content"] as? String,
|
||||
let id = data["id"] as? String {
|
||||
let message = ChatMessage(id: id, type: .user, content: content)
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
case "assistant_message":
|
||||
if let content = data["content"] as? String,
|
||||
let id = data["id"] as? String {
|
||||
let success = data["success"] as? Bool ?? true
|
||||
let costCents = data["cost_cents"] as? Int ?? 0
|
||||
let model = data["model"] as? String
|
||||
|
||||
// Remove any incomplete thinking messages
|
||||
messages.removeAll { $0.isThinking && !$0.thinkingDone }
|
||||
|
||||
let message = ChatMessage(
|
||||
id: id,
|
||||
type: .assistant(success: success, costCents: costCents, model: model),
|
||||
content: content
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
case "thinking":
|
||||
if let content = data["content"] as? String {
|
||||
let done = data["done"] as? Bool ?? false
|
||||
|
||||
// Find existing thinking message or create new
|
||||
if let index = messages.lastIndex(where: { $0.isThinking && !$0.thinkingDone }) {
|
||||
messages[index].content += "\n\n---\n\n" + content
|
||||
if done {
|
||||
messages[index] = ChatMessage(
|
||||
id: messages[index].id,
|
||||
type: .thinking(done: true, startTime: Date()),
|
||||
content: messages[index].content
|
||||
)
|
||||
}
|
||||
} else if !done {
|
||||
let message = ChatMessage(
|
||||
id: "thinking-\(Date().timeIntervalSince1970)",
|
||||
type: .thinking(done: false, startTime: Date()),
|
||||
content: content
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
case "error":
|
||||
if let errorMessage = data["message"] as? String {
|
||||
let message = ChatMessage(
|
||||
id: "error-\(Date().timeIntervalSince1970)",
|
||||
type: .error,
|
||||
content: errorMessage
|
||||
)
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Bubble
|
||||
|
||||
private struct MessageBubble: View {
|
||||
let message: ChatMessage
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
if message.isUser {
|
||||
Spacer(minLength: 60)
|
||||
userBubble
|
||||
} else if message.isThinking {
|
||||
thinkingBubble
|
||||
Spacer(minLength: 60)
|
||||
} else {
|
||||
assistantBubble
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var userBubble: some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.clipShape(
|
||||
.rect(
|
||||
topLeadingRadius: 20,
|
||||
bottomLeadingRadius: 20,
|
||||
bottomTrailingRadius: 6,
|
||||
topTrailingRadius: 20
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var assistantBubble: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Status header for assistant messages
|
||||
if case .assistant(let success, _, let model) = message.type {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(success ? Theme.success : Theme.error)
|
||||
|
||||
if let model = message.displayModel {
|
||||
Text(model)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
|
||||
if let cost = message.costFormatted {
|
||||
Text("•")
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
Text(cost)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(Theme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(
|
||||
.rect(
|
||||
topLeadingRadius: 20,
|
||||
bottomLeadingRadius: 6,
|
||||
bottomTrailingRadius: 20,
|
||||
topTrailingRadius: 20
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var thinkingBubble: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "brain")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.symbolEffect(.pulse, options: message.thinkingDone ? .nonRepeating : .repeating)
|
||||
|
||||
Text(message.thinkingDone ? "Thought" : "Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.accent.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
|
||||
if !message.content.isEmpty {
|
||||
Text(message.content)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(message.thinkingDone ? 3 : nil)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.white.opacity(0.02))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ControlView()
|
||||
}
|
||||
}
|
||||
407
ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift
Normal file
407
ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift
Normal file
@@ -0,0 +1,407 @@
|
||||
//
|
||||
// FilesView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Remote file explorer with SFTP-like functionality
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct FilesView: View {
|
||||
@State private var currentPath = "/root/context"
|
||||
@State private var entries: [FileEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var selectedEntry: FileEntry?
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var showingNewFolderAlert = false
|
||||
@State private var newFolderName = ""
|
||||
@State private var isImporting = false
|
||||
|
||||
private let api = APIService.shared
|
||||
|
||||
private var sortedEntries: [FileEntry] {
|
||||
let dirs = entries.filter { $0.isDirectory }.sorted { $0.name < $1.name }
|
||||
let files = entries.filter { !$0.isDirectory }.sorted { $0.name < $1.name }
|
||||
return dirs + files
|
||||
}
|
||||
|
||||
private var breadcrumbs: [(name: String, path: String)] {
|
||||
var crumbs: [(name: String, path: String)] = [("/", "/")]
|
||||
var accumulated = ""
|
||||
for part in currentPath.split(separator: "/") {
|
||||
accumulated += "/" + part
|
||||
crumbs.append((String(part), accumulated))
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Toolbar
|
||||
toolbarView
|
||||
|
||||
// Breadcrumb navigation
|
||||
breadcrumbView
|
||||
|
||||
// File list
|
||||
if isLoading {
|
||||
LoadingView(message: "Loading files...")
|
||||
} else if let error = errorMessage {
|
||||
EmptyStateView(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Failed to Load",
|
||||
message: error,
|
||||
action: { Task { await loadDirectory() } },
|
||||
actionLabel: "Retry"
|
||||
)
|
||||
} else if sortedEntries.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "folder",
|
||||
title: "Empty Folder",
|
||||
message: "This folder is empty.\nDrag files here or tap Import."
|
||||
)
|
||||
} else {
|
||||
fileListView
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Files")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
showingNewFolderAlert = true
|
||||
} label: {
|
||||
Label("New Folder", systemImage: "folder.badge.plus")
|
||||
}
|
||||
|
||||
Button {
|
||||
isImporting = true
|
||||
} label: {
|
||||
Label("Import Files", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
Task { await loadDirectory() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("New Folder", isPresented: $showingNewFolderAlert) {
|
||||
TextField("Folder name", text: $newFolderName)
|
||||
Button("Cancel", role: .cancel) {
|
||||
newFolderName = ""
|
||||
}
|
||||
Button("Create") {
|
||||
Task { await createFolder() }
|
||||
}
|
||||
}
|
||||
.alert("Delete \(selectedEntry?.name ?? "")?", isPresented: $showingDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete", role: .destructive) {
|
||||
Task { await deleteSelected() }
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $isImporting,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
Task { await handleFileImport(result) }
|
||||
}
|
||||
.task {
|
||||
await loadDirectory()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private var toolbarView: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Back button
|
||||
Button {
|
||||
goUp()
|
||||
} label: {
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(currentPath == "/" ? Theme.textMuted : Theme.textPrimary)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.disabled(currentPath == "/")
|
||||
|
||||
// Quick nav buttons
|
||||
quickNavButton(icon: "📥", label: "context", path: "/root/context")
|
||||
quickNavButton(icon: "🔨", label: "work", path: "/root/work")
|
||||
quickNavButton(icon: "🛠️", label: "tools", path: "/root/tools")
|
||||
|
||||
Spacer()
|
||||
|
||||
// Import button
|
||||
Button {
|
||||
isImporting = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
Text("Import")
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Theme.accent.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func quickNavButton(icon: String, label: String, path: String) -> some View {
|
||||
Button {
|
||||
navigateTo(path)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(icon)
|
||||
.font(.caption)
|
||||
Text(label)
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(currentPath.hasPrefix(path) ? Theme.accent : Theme.textSecondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(currentPath.hasPrefix(path) ? Theme.accent.opacity(0.15) : Color.white.opacity(0.05))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(currentPath.hasPrefix(path) ? Theme.accent.opacity(0.3) : Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var breadcrumbView: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(breadcrumbs.enumerated()), id: \.offset) { index, crumb in
|
||||
if index > 0 {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
}
|
||||
|
||||
Button {
|
||||
navigateTo(crumb.path)
|
||||
} label: {
|
||||
Text(crumb.name)
|
||||
.font(.caption.weight(index == breadcrumbs.count - 1 ? .semibold : .regular))
|
||||
.foregroundStyle(index == breadcrumbs.count - 1 ? Theme.textPrimary : Theme.textSecondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.white.opacity(0.02))
|
||||
}
|
||||
|
||||
private var fileListView: some View {
|
||||
List {
|
||||
ForEach(sortedEntries) { entry in
|
||||
FileRow(entry: entry)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if entry.isDirectory {
|
||||
navigateTo(entry.path)
|
||||
} else {
|
||||
selectedEntry = entry
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
selectedEntry = entry
|
||||
showingDeleteAlert = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
if entry.isFile {
|
||||
Button {
|
||||
downloadFile(entry)
|
||||
} label: {
|
||||
Label("Download", systemImage: "arrow.down.circle")
|
||||
}
|
||||
.tint(Theme.accent)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadDirectory() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
entries = try await api.listDirectory(path: currentPath)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func navigateTo(_ path: String) {
|
||||
currentPath = path
|
||||
Task { await loadDirectory() }
|
||||
HapticService.selectionChanged()
|
||||
}
|
||||
|
||||
private func goUp() {
|
||||
guard currentPath != "/" else { return }
|
||||
var parts = currentPath.split(separator: "/")
|
||||
parts.removeLast()
|
||||
currentPath = parts.isEmpty ? "/" : "/" + parts.joined(separator: "/")
|
||||
Task { await loadDirectory() }
|
||||
HapticService.selectionChanged()
|
||||
}
|
||||
|
||||
private func createFolder() async {
|
||||
guard !newFolderName.isEmpty else { return }
|
||||
|
||||
let folderPath = currentPath.hasSuffix("/")
|
||||
? currentPath + newFolderName
|
||||
: currentPath + "/" + newFolderName
|
||||
|
||||
do {
|
||||
try await api.createDirectory(path: folderPath)
|
||||
newFolderName = ""
|
||||
await loadDirectory()
|
||||
HapticService.success()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteSelected() async {
|
||||
guard let entry = selectedEntry else { return }
|
||||
|
||||
do {
|
||||
try await api.deleteFile(path: entry.path, recursive: entry.isDirectory)
|
||||
selectedEntry = nil
|
||||
await loadDirectory()
|
||||
HapticService.success()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadFile(_ entry: FileEntry) {
|
||||
guard let url = api.downloadURL(path: entry.path) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func handleFileImport(_ result: Result<[URL], Error>) async {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
for url in urls {
|
||||
guard url.startAccessingSecurityScopedResource() else { continue }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let _ = try await api.uploadFile(
|
||||
data: data,
|
||||
fileName: url.lastPathComponent,
|
||||
directory: currentPath
|
||||
)
|
||||
} catch {
|
||||
errorMessage = "Upload failed: \(error.localizedDescription)"
|
||||
HapticService.error()
|
||||
return
|
||||
}
|
||||
}
|
||||
await loadDirectory()
|
||||
HapticService.success()
|
||||
|
||||
case .failure(let error):
|
||||
errorMessage = error.localizedDescription
|
||||
HapticService.error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File Row
|
||||
|
||||
private struct FileRow: View {
|
||||
let entry: FileEntry
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
// Icon
|
||||
Image(systemName: entry.icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(entry.isDirectory ? Theme.accent : Theme.textSecondary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(entry.isDirectory ? Theme.accent.opacity(0.15) : Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
// Name and details
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.name)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(entry.formattedSize)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
|
||||
Text(entry.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Chevron for directories
|
||||
if entry.isDirectory {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
FilesView()
|
||||
}
|
||||
}
|
||||
454
ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift
Normal file
454
ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift
Normal file
@@ -0,0 +1,454 @@
|
||||
//
|
||||
// HistoryView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// Mission history list with search and filtering
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HistoryView: View {
|
||||
@State private var missions: [Mission] = []
|
||||
@State private var tasks: [TaskState] = []
|
||||
@State private var runs: [Run] = []
|
||||
@State private var isLoading = true
|
||||
@State private var searchText = ""
|
||||
@State private var selectedFilter: StatusFilter = .all
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private let api = APIService.shared
|
||||
|
||||
enum StatusFilter: String, CaseIterable {
|
||||
case all = "All"
|
||||
case active = "Active"
|
||||
case completed = "Completed"
|
||||
case failed = "Failed"
|
||||
|
||||
var missionStatus: MissionStatus? {
|
||||
switch self {
|
||||
case .all: return nil
|
||||
case .active: return .active
|
||||
case .completed: return .completed
|
||||
case .failed: return .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredMissions: [Mission] {
|
||||
missions.filter { mission in
|
||||
// Filter by status
|
||||
if let statusFilter = selectedFilter.missionStatus, mission.status != statusFilter {
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if !searchText.isEmpty {
|
||||
let title = mission.title ?? ""
|
||||
if !title.localizedCaseInsensitiveContains(searchText) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
.sorted { ($0.updatedDate ?? Date.distantPast) > ($1.updatedDate ?? Date.distantPast) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Search and filter
|
||||
VStack(spacing: 12) {
|
||||
// Search bar
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
|
||||
TextField("Search missions...", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
|
||||
// Filter pills
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(StatusFilter.allCases, id: \.rawValue) { filter in
|
||||
FilterPill(
|
||||
title: filter.rawValue,
|
||||
isSelected: selectedFilter == filter
|
||||
) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedFilter = filter
|
||||
}
|
||||
HapticService.selectionChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
// Content
|
||||
if isLoading {
|
||||
LoadingView(message: "Loading history...")
|
||||
} else if let error = errorMessage {
|
||||
EmptyStateView(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Failed to Load",
|
||||
message: error,
|
||||
action: { Task { await loadData() } },
|
||||
actionLabel: "Retry"
|
||||
)
|
||||
} else if filteredMissions.isEmpty && tasks.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "No History",
|
||||
message: "Your missions will appear here"
|
||||
)
|
||||
} else {
|
||||
missionsList
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("History")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await loadData()
|
||||
}
|
||||
.refreshable {
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
|
||||
private var missionsList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
// Missions section
|
||||
if !filteredMissions.isEmpty {
|
||||
Section {
|
||||
ForEach(filteredMissions) { mission in
|
||||
NavigationLink(value: mission) {
|
||||
MissionRow(mission: mission)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} header: {
|
||||
SectionHeader(
|
||||
title: "Missions",
|
||||
count: filteredMissions.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Active tasks section
|
||||
if !tasks.isEmpty {
|
||||
Section {
|
||||
ForEach(tasks) { task in
|
||||
TaskRow(task: task)
|
||||
}
|
||||
} header: {
|
||||
SectionHeader(
|
||||
title: "Active Tasks",
|
||||
count: tasks.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Archived runs section
|
||||
if !runs.isEmpty {
|
||||
Section {
|
||||
ForEach(runs) { run in
|
||||
RunRow(run: run)
|
||||
}
|
||||
} header: {
|
||||
SectionHeader(
|
||||
title: "Archived Runs",
|
||||
count: runs.count
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationDestination(for: Mission.self) { mission in
|
||||
MissionDetailView(mission: mission)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadData() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
async let missionsTask = api.listMissions()
|
||||
async let tasksTask = api.listTasks()
|
||||
async let runsTask = api.listRuns()
|
||||
|
||||
let (missionsResult, tasksResult, runsResult) = try await (missionsTask, tasksTask, runsTask)
|
||||
|
||||
missions = missionsResult
|
||||
tasks = tasksResult
|
||||
runs = runsResult
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
private struct SectionHeader: View {
|
||||
let title: String
|
||||
let count: Int
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title.uppercased())
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
|
||||
Text("(\(count))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private struct FilterPill: View {
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(isSelected ? .white : Theme.textSecondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(isSelected ? Theme.accent : Color.white.opacity(0.05))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(isSelected ? .clear : Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MissionRow: View {
|
||||
let mission: Mission
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
// Icon
|
||||
Image(systemName: "target")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Theme.accent.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(mission.displayTitle)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
StatusBadge(status: mission.status.statusType, compact: true)
|
||||
|
||||
Text("\(mission.history.count) messages")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Timestamp and chevron
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
if let date = mission.updatedDate {
|
||||
Text(date.relativeFormatted)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TaskRow: View {
|
||||
let task: TaskState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(task.task)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
StatusBadge(status: task.status.statusType, compact: true)
|
||||
|
||||
Text(task.displayModel)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
|
||||
Text("•")
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
|
||||
Text("\(task.iterations) iterations")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RunRow: View {
|
||||
let run: Run
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(run.inputText)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if let date = run.createdDate {
|
||||
Text(date.relativeFormatted)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
|
||||
Text("•")
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
|
||||
Text(String(format: "$%.2f", run.costDollars))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(Theme.success)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mission Detail View
|
||||
|
||||
struct MissionDetailView: View {
|
||||
let mission: Mission
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
StatusBadge(status: mission.status.statusType)
|
||||
Spacer()
|
||||
if let date = mission.updatedDate {
|
||||
Text(date.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(mission.title ?? "Untitled Mission")
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
|
||||
// Messages
|
||||
if !mission.history.isEmpty {
|
||||
ForEach(mission.history) { entry in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: entry.isUser ? "person.circle.fill" : "sparkles")
|
||||
.foregroundStyle(entry.isUser ? Theme.accent : Theme.textSecondary)
|
||||
|
||||
Text(entry.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.ultraThinMaterial.opacity(entry.isUser ? 0.8 : 0.4))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Theme.backgroundPrimary.ignoresSafeArea())
|
||||
.navigationTitle("Mission")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date Extension
|
||||
|
||||
extension Date {
|
||||
var relativeFormatted: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: self, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
HistoryView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//
|
||||
// TerminalView.swift
|
||||
// OpenAgentDashboard
|
||||
//
|
||||
// SSH terminal with WebSocket connection
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TerminalView: View {
|
||||
@State private var terminalOutput: [TerminalLine] = []
|
||||
@State private var inputText = ""
|
||||
@State private var connectionStatus: StatusType = .disconnected
|
||||
@State private var webSocketTask: URLSessionWebSocketTask?
|
||||
@State private var isConnecting = false
|
||||
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
private let api = APIService.shared
|
||||
|
||||
struct TerminalLine: Identifiable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
let type: LineType
|
||||
|
||||
enum LineType {
|
||||
case input
|
||||
case output
|
||||
case error
|
||||
case system
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch type {
|
||||
case .input: return Theme.accent
|
||||
case .output: return Theme.textPrimary
|
||||
case .error: return Theme.error
|
||||
case .system: return Theme.textTertiary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Theme.backgroundPrimary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Connection status header
|
||||
connectionHeader
|
||||
|
||||
// Terminal output
|
||||
terminalOutputView
|
||||
|
||||
// Input field
|
||||
inputView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Terminal")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if connectionStatus == .connected {
|
||||
disconnect()
|
||||
} else {
|
||||
connect()
|
||||
}
|
||||
} label: {
|
||||
Text(connectionStatus == .connected ? "Disconnect" : "Connect")
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
connect()
|
||||
}
|
||||
.onDisappear {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
StatusDot(status: connectionStatus, size: 8)
|
||||
|
||||
Text(connectionStatus.label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if connectionStatus != .connected {
|
||||
Button {
|
||||
connect()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if isConnecting {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
Text("Reconnect")
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.accent.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.disabled(isConnecting)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
private var terminalOutputView: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(terminalOutput) { line in
|
||||
Text(line.text)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(line.color)
|
||||
.textSelection(.enabled)
|
||||
.id(line.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color.black.opacity(0.3))
|
||||
.onChange(of: terminalOutput.count) { _, _ in
|
||||
if let lastLine = terminalOutput.last {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastLine.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var inputView: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
.background(Theme.border)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text("$")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(Theme.accent)
|
||||
|
||||
TextField("Enter command...", text: $inputText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($isInputFocused)
|
||||
.onSubmit {
|
||||
sendCommand()
|
||||
}
|
||||
|
||||
Button {
|
||||
sendCommand()
|
||||
} label: {
|
||||
Image(systemName: "return")
|
||||
.font(.body)
|
||||
.foregroundStyle(inputText.isEmpty ? Theme.textMuted : Theme.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Theme.accent.opacity(inputText.isEmpty ? 0.1 : 0.2))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.disabled(inputText.isEmpty || connectionStatus != .connected)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WebSocket Connection
|
||||
|
||||
private func connect() {
|
||||
guard connectionStatus != .connected && !isConnecting else { return }
|
||||
|
||||
isConnecting = true
|
||||
connectionStatus = .connecting
|
||||
addSystemLine("Connecting to \(api.baseURL)...")
|
||||
|
||||
guard let wsURL = buildWebSocketURL() else {
|
||||
addErrorLine("Invalid WebSocket URL")
|
||||
connectionStatus = .error
|
||||
isConnecting = false
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: wsURL)
|
||||
|
||||
// Add auth via subprotocol if available
|
||||
if let token = UserDefaults.standard.string(forKey: "jwt_token") {
|
||||
request.setValue("openagent, jwt.\(token)", forHTTPHeaderField: "Sec-WebSocket-Protocol")
|
||||
}
|
||||
|
||||
webSocketTask = URLSession.shared.webSocketTask(with: request)
|
||||
webSocketTask?.resume()
|
||||
|
||||
// Start receiving messages
|
||||
receiveMessages()
|
||||
|
||||
// Send initial resize message after a brief delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if connectionStatus == .connecting {
|
||||
connectionStatus = .connected
|
||||
addSystemLine("Connected.")
|
||||
}
|
||||
isConnecting = false
|
||||
sendResize(cols: 80, rows: 24)
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnect() {
|
||||
webSocketTask?.cancel(with: .normalClosure, reason: nil)
|
||||
webSocketTask = nil
|
||||
connectionStatus = .disconnected
|
||||
addSystemLine("Disconnected.")
|
||||
}
|
||||
|
||||
private func buildWebSocketURL() -> URL? {
|
||||
guard var components = URLComponents(string: api.baseURL) else { return nil }
|
||||
components.scheme = components.scheme == "https" ? "wss" : "ws"
|
||||
components.path = "/api/console/ws"
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func receiveMessages() {
|
||||
webSocketTask?.receive { [self] result in
|
||||
switch result {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text):
|
||||
DispatchQueue.main.async {
|
||||
self.handleOutput(text)
|
||||
}
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
DispatchQueue.main.async {
|
||||
self.handleOutput(text)
|
||||
}
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
// Continue receiving
|
||||
receiveMessages()
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
if connectionStatus != .disconnected {
|
||||
connectionStatus = .error
|
||||
addErrorLine("Connection error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleOutput(_ text: String) {
|
||||
// Split by newlines and add each line
|
||||
let lines = text.components(separatedBy: .newlines)
|
||||
for line in lines {
|
||||
if !line.isEmpty {
|
||||
terminalOutput.append(TerminalLine(text: line, type: .output))
|
||||
}
|
||||
}
|
||||
|
||||
// Limit history
|
||||
if terminalOutput.count > 1000 {
|
||||
terminalOutput.removeFirst(terminalOutput.count - 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCommand() {
|
||||
guard !inputText.isEmpty, connectionStatus == .connected else { return }
|
||||
|
||||
let command = inputText
|
||||
inputText = ""
|
||||
|
||||
// Show the command in output
|
||||
terminalOutput.append(TerminalLine(text: "$ \(command)", type: .input))
|
||||
|
||||
// Send to WebSocket
|
||||
let message = ["t": "i", "d": command + "\n"]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: data, encoding: .utf8) {
|
||||
webSocketTask?.send(.string(jsonString)) { error in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
addErrorLine("Send error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HapticService.lightTap()
|
||||
}
|
||||
|
||||
private func sendResize(cols: Int, rows: Int) {
|
||||
let message = ["t": "r", "c": cols, "r": rows] as [String: Any]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: data, encoding: .utf8) {
|
||||
webSocketTask?.send(.string(jsonString)) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
private func addSystemLine(_ text: String) {
|
||||
terminalOutput.append(TerminalLine(text: text, type: .system))
|
||||
}
|
||||
|
||||
private func addErrorLine(_ text: String) {
|
||||
terminalOutput.append(TerminalLine(text: text, type: .error))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TerminalView()
|
||||
}
|
||||
}
|
||||
18
ios_dashboard/Package.swift
Normal file
18
ios_dashboard/Package.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
// swift-tools-version:6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "OpenAgentDashboard",
|
||||
platforms: [
|
||||
.iOS(.v18)
|
||||
],
|
||||
products: [
|
||||
.library(name: "OpenAgentDashboard", targets: ["OpenAgentDashboard"])
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "OpenAgentDashboard",
|
||||
path: "OpenAgentDashboard"
|
||||
)
|
||||
]
|
||||
)
|
||||
38
ios_dashboard/project.yml
Normal file
38
ios_dashboard/project.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
name: OpenAgentDashboard
|
||||
options:
|
||||
bundleIdPrefix: md.thomas.openagent
|
||||
deploymentTarget:
|
||||
iOS: "18.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
DEVELOPMENT_TEAM: ""
|
||||
CODE_SIGN_IDENTITY: ""
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
CODE_SIGN_ENTITLEMENTS: ""
|
||||
ENABLE_PREVIEWS: "YES"
|
||||
|
||||
targets:
|
||||
OpenAgentDashboard:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "18.0"
|
||||
sources:
|
||||
- path: OpenAgentDashboard
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: OpenAgentDashboard/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: md.thomas.openagent.dashboard
|
||||
PRODUCT_NAME: "Open Agent"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
SWIFT_EMIT_LOC_STRINGS: "YES"
|
||||
GENERATE_INFOPLIST_FILE: "NO"
|
||||
entitlements:
|
||||
path: OpenAgentDashboard/OpenAgentDashboard.entitlements
|
||||
properties: {}
|
||||
@@ -1,12 +1,19 @@
|
||||
//! MCP runtime registry - manages connections and tool execution.
|
||||
//!
|
||||
//! Supports both HTTP and stdio transports:
|
||||
//! - HTTP: JSON-RPC over HTTP POST requests
|
||||
//! - Stdio: JSON-RPC over stdin/stdout with spawned child processes
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::config::McpConfigStore;
|
||||
@@ -15,14 +22,23 @@ use super::types::*;
|
||||
/// MCP protocol version we support
|
||||
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||
|
||||
/// Handle for a stdio MCP process
|
||||
struct StdioProcess {
|
||||
child: Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout_lines: Arc<Mutex<BufReader<tokio::process::ChildStdout>>>,
|
||||
}
|
||||
|
||||
/// Runtime registry for MCP servers.
|
||||
pub struct McpRegistry {
|
||||
/// Persistent configuration store
|
||||
config_store: Arc<McpConfigStore>,
|
||||
/// Runtime state for each MCP (keyed by ID)
|
||||
states: RwLock<HashMap<Uuid, McpServerState>>,
|
||||
/// HTTP client for MCP requests
|
||||
client: reqwest::Client,
|
||||
/// HTTP client for HTTP MCP requests
|
||||
http_client: reqwest::Client,
|
||||
/// Stdio processes for stdio MCPs (keyed by ID)
|
||||
stdio_processes: RwLock<HashMap<Uuid, Arc<Mutex<StdioProcess>>>>,
|
||||
/// Disabled tools (by name)
|
||||
disabled_tools: RwLock<std::collections::HashSet<String>>,
|
||||
/// Request ID counter for JSON-RPC
|
||||
@@ -42,16 +58,17 @@ impl McpRegistry {
|
||||
}
|
||||
|
||||
// Use very short timeouts to avoid blocking for too long
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.connect_timeout(Duration::from_millis(1000))
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_millis(5000))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
Self {
|
||||
config_store,
|
||||
states: RwLock::new(states),
|
||||
client,
|
||||
http_client,
|
||||
stdio_processes: RwLock::new(HashMap::new()),
|
||||
disabled_tools: RwLock::new(std::collections::HashSet::new()),
|
||||
request_id: AtomicU64::new(1),
|
||||
}
|
||||
@@ -62,8 +79,8 @@ impl McpRegistry {
|
||||
self.request_id.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request to an MCP server
|
||||
async fn send_jsonrpc(
|
||||
/// Send a JSON-RPC request via HTTP
|
||||
async fn send_jsonrpc_http(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
method: &str,
|
||||
@@ -72,7 +89,7 @@ impl McpRegistry {
|
||||
let request = JsonRpcRequest::new(self.next_request_id(), method, params);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.http_client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request)
|
||||
@@ -94,8 +111,94 @@ impl McpRegistry {
|
||||
.ok_or_else(|| anyhow::anyhow!("No result in response"))
|
||||
}
|
||||
|
||||
/// Initialize connection with an MCP server
|
||||
async fn initialize_mcp(&self, endpoint: &str) -> anyhow::Result<InitializeResult> {
|
||||
/// Send a JSON-RPC request via stdio
|
||||
async fn send_jsonrpc_stdio(
|
||||
&self,
|
||||
process: &Arc<Mutex<StdioProcess>>,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let request = JsonRpcRequest::new(self.next_request_id(), method, params);
|
||||
let request_json = serde_json::to_string(&request)?;
|
||||
|
||||
let mut proc = process.lock().await;
|
||||
|
||||
// Write request to stdin
|
||||
proc.stdin
|
||||
.write_all(request_json.as_bytes())
|
||||
.await?;
|
||||
proc.stdin.write_all(b"\n").await?;
|
||||
proc.stdin.flush().await?;
|
||||
|
||||
// Read response from stdout
|
||||
let mut stdout = proc.stdout_lines.lock().await;
|
||||
let mut line = String::new();
|
||||
|
||||
// Read with timeout
|
||||
let read_result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
stdout.read_line(&mut line),
|
||||
)
|
||||
.await;
|
||||
|
||||
match read_result {
|
||||
Ok(Ok(0)) => anyhow::bail!("MCP process closed stdout"),
|
||||
Ok(Ok(_)) => {
|
||||
let json_response: JsonRpcResponse = serde_json::from_str(&line)?;
|
||||
|
||||
if let Some(error) = json_response.error {
|
||||
anyhow::bail!("JSON-RPC error {}: {}", error.code, error.message);
|
||||
}
|
||||
|
||||
json_response
|
||||
.result
|
||||
.ok_or_else(|| anyhow::anyhow!("No result in response"))
|
||||
}
|
||||
Ok(Err(e)) => anyhow::bail!("Read error: {}", e),
|
||||
Err(_) => anyhow::bail!("Timeout waiting for MCP response"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a stdio MCP process
|
||||
async fn spawn_stdio_process(
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
) -> anyhow::Result<StdioProcess> {
|
||||
let mut cmd = Command::new(command);
|
||||
cmd.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// Add environment variables
|
||||
for (key, value) in env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdin"))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
|
||||
|
||||
let stdout_lines = Arc::new(Mutex::new(BufReader::new(stdout)));
|
||||
|
||||
Ok(StdioProcess {
|
||||
child,
|
||||
stdin,
|
||||
stdout_lines,
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize connection with an MCP server (HTTP)
|
||||
async fn initialize_mcp_http(&self, endpoint: &str) -> anyhow::Result<InitializeResult> {
|
||||
let params = InitializeParams {
|
||||
protocol_version: MCP_PROTOCOL_VERSION.to_string(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
@@ -106,14 +209,14 @@ impl McpRegistry {
|
||||
};
|
||||
|
||||
let result = self
|
||||
.send_jsonrpc(endpoint, "initialize", Some(serde_json::to_value(params)?))
|
||||
.send_jsonrpc_http(endpoint, "initialize", Some(serde_json::to_value(params)?))
|
||||
.await?;
|
||||
|
||||
let init_result: InitializeResult = serde_json::from_value(result)?;
|
||||
|
||||
// Send initialized notification (no response expected, but some servers require it)
|
||||
let _ = self
|
||||
.client
|
||||
.http_client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
@@ -126,6 +229,41 @@ impl McpRegistry {
|
||||
Ok(init_result)
|
||||
}
|
||||
|
||||
/// Initialize connection with an MCP server (stdio)
|
||||
async fn initialize_mcp_stdio(
|
||||
&self,
|
||||
process: &Arc<Mutex<StdioProcess>>,
|
||||
) -> anyhow::Result<InitializeResult> {
|
||||
let params = InitializeParams {
|
||||
protocol_version: MCP_PROTOCOL_VERSION.to_string(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: ClientInfo {
|
||||
name: "open-agent".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = self
|
||||
.send_jsonrpc_stdio(process, "initialize", Some(serde_json::to_value(params)?))
|
||||
.await?;
|
||||
|
||||
let init_result: InitializeResult = serde_json::from_value(result)?;
|
||||
|
||||
// Send initialized notification
|
||||
let notification = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
});
|
||||
let notification_json = serde_json::to_string(¬ification)?;
|
||||
|
||||
let mut proc = process.lock().await;
|
||||
let _ = proc.stdin.write_all(notification_json.as_bytes()).await;
|
||||
let _ = proc.stdin.write_all(b"\n").await;
|
||||
let _ = proc.stdin.flush().await;
|
||||
|
||||
Ok(init_result)
|
||||
}
|
||||
|
||||
/// List all MCP servers with their current state.
|
||||
pub async fn list(&self) -> Vec<McpServerState> {
|
||||
self.states.read().await.values().cloned().collect()
|
||||
@@ -139,7 +277,14 @@ impl McpRegistry {
|
||||
/// Add a new MCP server.
|
||||
/// Note: This does NOT automatically attempt to connect. Use refresh() after adding.
|
||||
pub async fn add(&self, req: AddMcpRequest) -> anyhow::Result<McpServerState> {
|
||||
let mut config = McpServerConfig::new(req.name, req.endpoint);
|
||||
let transport = req.effective_transport();
|
||||
|
||||
let mut config = match &transport {
|
||||
McpTransport::Http { endpoint } => McpServerConfig::new(req.name.clone(), endpoint.clone()),
|
||||
McpTransport::Stdio { command, args, env } => {
|
||||
McpServerConfig::new_stdio(req.name.clone(), command.clone(), args.clone(), env.clone())
|
||||
}
|
||||
};
|
||||
config.description = req.description;
|
||||
|
||||
// Save to persistent store
|
||||
@@ -161,6 +306,15 @@ impl McpRegistry {
|
||||
|
||||
/// Remove an MCP server.
|
||||
pub async fn remove(&self, id: Uuid) -> anyhow::Result<()> {
|
||||
// Kill stdio process if running
|
||||
{
|
||||
let mut processes = self.stdio_processes.write().await;
|
||||
if let Some(process) = processes.remove(&id) {
|
||||
let mut proc = process.lock().await;
|
||||
let _ = proc.child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from persistent store
|
||||
self.config_store.remove(id).await?;
|
||||
|
||||
@@ -193,6 +347,15 @@ impl McpRegistry {
|
||||
|
||||
/// Disable an MCP server.
|
||||
pub async fn disable(&self, id: Uuid) -> anyhow::Result<McpServerState> {
|
||||
// Kill stdio process if running
|
||||
{
|
||||
let mut processes = self.stdio_processes.write().await;
|
||||
if let Some(process) = processes.remove(&id) {
|
||||
let mut proc = process.lock().await;
|
||||
let _ = proc.child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Update persistent config
|
||||
let config = self.config_store.disable(id).await?;
|
||||
|
||||
@@ -235,15 +398,18 @@ impl McpRegistry {
|
||||
async fn update_state_success(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tool_names: Vec<String>,
|
||||
tool_descriptors: Vec<McpToolDescriptor>,
|
||||
server_version: Option<String>,
|
||||
) {
|
||||
let tool_names: Vec<String> = tool_descriptors.iter().map(|t| t.name.clone()).collect();
|
||||
|
||||
// Try up to 5 times with small delays to handle temporary lock contention
|
||||
for attempt in 0..5 {
|
||||
if let Ok(mut states) = self.states.try_write() {
|
||||
if let Some(state) = states.get_mut(&id) {
|
||||
state.config.tools = tool_names;
|
||||
state.config.version = server_version;
|
||||
state.config.tools = tool_names.clone();
|
||||
state.config.tool_descriptors = tool_descriptors.clone();
|
||||
state.config.version = server_version.clone();
|
||||
state.config.last_connected_at = Some(chrono::Utc::now());
|
||||
state.status = McpStatus::Connected;
|
||||
state.error = None;
|
||||
@@ -270,10 +436,22 @@ impl McpRegistry {
|
||||
return Ok(state);
|
||||
}
|
||||
|
||||
let endpoint = state.config.endpoint.trim_end_matches('/').to_string();
|
||||
match &state.config.transport {
|
||||
McpTransport::Http { endpoint } => {
|
||||
self.refresh_http(id, endpoint.clone()).await
|
||||
}
|
||||
McpTransport::Stdio { command, args, env } => {
|
||||
self.refresh_stdio(id, command.clone(), args.clone(), env.clone()).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh an HTTP MCP server
|
||||
async fn refresh_http(&self, id: Uuid, endpoint: String) -> anyhow::Result<McpServerState> {
|
||||
let endpoint = endpoint.trim_end_matches('/').to_string();
|
||||
|
||||
// Step 1: Initialize the MCP connection with JSON-RPC
|
||||
let init_result = match self.initialize_mcp(&endpoint).await {
|
||||
let init_result = match self.initialize_mcp_http(&endpoint).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
self.update_state_error(id, format!("Initialize failed: {}", e))
|
||||
@@ -292,28 +470,127 @@ impl McpRegistry {
|
||||
.and_then(|s| s.version.clone());
|
||||
|
||||
// Step 2: List tools using JSON-RPC
|
||||
match self.send_jsonrpc(&endpoint, "tools/list", None).await {
|
||||
match self.send_jsonrpc_http(&endpoint, "tools/list", None).await {
|
||||
Ok(result) => {
|
||||
match serde_json::from_value::<McpToolsResponse>(result) {
|
||||
Ok(tools_response) => {
|
||||
let tool_names: Vec<String> = tools_response
|
||||
.tools
|
||||
.iter()
|
||||
.map(|t| t.name.clone())
|
||||
.collect();
|
||||
let tool_descriptors = tools_response.tools;
|
||||
let tool_names: Vec<String> = tool_descriptors.iter().map(|t| t.name.clone()).collect();
|
||||
|
||||
// Update config with discovered tools
|
||||
let _ = self
|
||||
.config_store
|
||||
.update(id, |c| {
|
||||
c.tools = tool_names.clone();
|
||||
c.tool_descriptors = tool_descriptors.clone();
|
||||
c.version = server_version.clone();
|
||||
c.last_connected_at = Some(chrono::Utc::now());
|
||||
})
|
||||
.await;
|
||||
|
||||
// Update runtime state
|
||||
self.update_state_success(id, tool_names, server_version)
|
||||
self.update_state_success(id, tool_descriptors, server_version)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
self.update_state_error(id, format!("Failed to parse tools: {}", e))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.update_state_error(id, format!("tools/list failed: {}", e))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
self.get(id)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("MCP not found"))
|
||||
}
|
||||
|
||||
/// Refresh a stdio MCP server
|
||||
async fn refresh_stdio(
|
||||
&self,
|
||||
id: Uuid,
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
env: HashMap<String, String>,
|
||||
) -> anyhow::Result<McpServerState> {
|
||||
// Kill existing process if any
|
||||
{
|
||||
let mut processes = self.stdio_processes.write().await;
|
||||
if let Some(process) = processes.remove(&id) {
|
||||
let mut proc = process.lock().await;
|
||||
let _ = proc.child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn new process
|
||||
let process = match self.spawn_stdio_process(&command, &args, &env).await {
|
||||
Ok(p) => Arc::new(Mutex::new(p)),
|
||||
Err(e) => {
|
||||
self.update_state_error(id, format!("Failed to spawn process: {}", e))
|
||||
.await;
|
||||
return self
|
||||
.get(id)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("MCP not found"));
|
||||
}
|
||||
};
|
||||
|
||||
// Store process handle
|
||||
{
|
||||
let mut processes = self.stdio_processes.write().await;
|
||||
processes.insert(id, Arc::clone(&process));
|
||||
}
|
||||
|
||||
// Step 1: Initialize the MCP connection
|
||||
let init_result = match self.initialize_mcp_stdio(&process).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
self.update_state_error(id, format!("Initialize failed: {}", e))
|
||||
.await;
|
||||
// Clean up process
|
||||
let mut processes = self.stdio_processes.write().await;
|
||||
if let Some(process) = processes.remove(&id) {
|
||||
let mut proc = process.lock().await;
|
||||
let _ = proc.child.kill().await;
|
||||
}
|
||||
return self
|
||||
.get(id)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("MCP not found"));
|
||||
}
|
||||
};
|
||||
|
||||
// Extract server version if available
|
||||
let server_version = init_result
|
||||
.server_info
|
||||
.as_ref()
|
||||
.and_then(|s| s.version.clone());
|
||||
|
||||
// Step 2: List tools
|
||||
match self.send_jsonrpc_stdio(&process, "tools/list", None).await {
|
||||
Ok(result) => {
|
||||
match serde_json::from_value::<McpToolsResponse>(result) {
|
||||
Ok(tools_response) => {
|
||||
let tool_descriptors = tools_response.tools;
|
||||
let tool_names: Vec<String> = tool_descriptors.iter().map(|t| t.name.clone()).collect();
|
||||
|
||||
// Update config with discovered tools
|
||||
let _ = self
|
||||
.config_store
|
||||
.update(id, |c| {
|
||||
c.tools = tool_names.clone();
|
||||
c.tool_descriptors = tool_descriptors.clone();
|
||||
c.version = server_version.clone();
|
||||
c.last_connected_at = Some(chrono::Utc::now());
|
||||
})
|
||||
.await;
|
||||
|
||||
// Update runtime state
|
||||
self.update_state_success(id, tool_descriptors, server_version)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -367,19 +644,61 @@ impl McpRegistry {
|
||||
anyhow::bail!("MCP {} is not connected", state.config.name);
|
||||
}
|
||||
|
||||
let endpoint = state.config.endpoint.trim_end_matches('/');
|
||||
|
||||
// Use JSON-RPC tools/call method
|
||||
let params = serde_json::json!({
|
||||
"name": tool_name,
|
||||
"arguments": arguments
|
||||
});
|
||||
|
||||
let result = match self
|
||||
.send_jsonrpc(endpoint, "tools/call", Some(params))
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
let result = match &state.config.transport {
|
||||
McpTransport::Http { endpoint } => {
|
||||
let endpoint = endpoint.trim_end_matches('/');
|
||||
self.send_jsonrpc_http(endpoint, "tools/call", Some(params)).await
|
||||
}
|
||||
McpTransport::Stdio { .. } => {
|
||||
let processes = self.stdio_processes.read().await;
|
||||
let process = processes
|
||||
.get(&mcp_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("No stdio process for MCP {}", mcp_id))?;
|
||||
self.send_jsonrpc_stdio(process, "tools/call", Some(params)).await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(result) => {
|
||||
let response: McpCallToolResponse = serde_json::from_value(result)?;
|
||||
|
||||
// Increment counters
|
||||
{
|
||||
let mut states = self.states.write().await;
|
||||
if let Some(state) = states.get_mut(&mcp_id) {
|
||||
if response.is_error {
|
||||
state.tool_errors += 1;
|
||||
} else {
|
||||
state.tool_calls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if response.is_error {
|
||||
let error_text = response
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| c.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
anyhow::bail!("Tool error: {}", error_text);
|
||||
}
|
||||
|
||||
// Combine text content
|
||||
let output = response
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| c.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
Err(e) => {
|
||||
// Increment error counter
|
||||
let mut states = self.states.write().await;
|
||||
@@ -388,41 +707,7 @@ impl McpRegistry {
|
||||
}
|
||||
anyhow::bail!("Tool call failed: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
let response: McpCallToolResponse = serde_json::from_value(result)?;
|
||||
|
||||
// Increment counters
|
||||
{
|
||||
let mut states = self.states.write().await;
|
||||
if let Some(state) = states.get_mut(&mcp_id) {
|
||||
if response.is_error {
|
||||
state.tool_errors += 1;
|
||||
} else {
|
||||
state.tool_calls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if response.is_error {
|
||||
let error_text = response
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| c.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
anyhow::bail!("Tool error: {}", error_text);
|
||||
}
|
||||
|
||||
// Combine text content
|
||||
let output = response
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| c.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// List all tools from all connected MCPs.
|
||||
@@ -433,13 +718,13 @@ impl McpRegistry {
|
||||
let mut tools = Vec::new();
|
||||
for state in states.values() {
|
||||
if state.config.enabled && state.status == McpStatus::Connected {
|
||||
for tool_name in &state.config.tools {
|
||||
for descriptor in &state.config.tool_descriptors {
|
||||
tools.push(McpTool {
|
||||
name: tool_name.clone(),
|
||||
description: String::new(), // Would need to store this from discovery
|
||||
parameters_schema: serde_json::json!({}),
|
||||
name: descriptor.name.clone(),
|
||||
description: descriptor.description.clone(),
|
||||
parameters_schema: descriptor.input_schema.clone(),
|
||||
mcp_id: state.config.id,
|
||||
enabled: !disabled.contains(tool_name),
|
||||
enabled: !disabled.contains(&descriptor.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Transport type for MCP server communication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpTransport {
|
||||
/// HTTP JSON-RPC transport (server must be running and listening)
|
||||
Http { endpoint: String },
|
||||
/// Stdio transport (spawn process, communicate via stdin/stdout)
|
||||
Stdio {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: std::collections::HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for McpTransport {
|
||||
fn default() -> Self {
|
||||
McpTransport::Http {
|
||||
endpoint: "http://127.0.0.1:3000".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of an MCP server connection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -129,7 +153,10 @@ pub struct McpServerConfig {
|
||||
pub id: Uuid,
|
||||
/// Human-readable name (e.g., "Supabase", "Browser Extension")
|
||||
pub name: String,
|
||||
/// Server endpoint URL (e.g., "http://127.0.0.1:4011")
|
||||
/// Transport configuration (HTTP or stdio)
|
||||
pub transport: McpTransport,
|
||||
/// Server endpoint URL (e.g., "http://127.0.0.1:4011") - DEPRECATED, use transport
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub endpoint: String,
|
||||
/// Optional description
|
||||
pub description: Option<String>,
|
||||
@@ -140,6 +167,9 @@ pub struct McpServerConfig {
|
||||
/// Tool names exposed by this MCP (populated after connection)
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
/// Tool descriptors with full metadata (name, description, schema)
|
||||
#[serde(default)]
|
||||
pub tool_descriptors: Vec<McpToolDescriptor>,
|
||||
/// When this MCP was added
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Last time we successfully connected
|
||||
@@ -147,20 +177,52 @@ pub struct McpServerConfig {
|
||||
}
|
||||
|
||||
impl McpServerConfig {
|
||||
/// Create a new MCP server configuration.
|
||||
/// Create a new MCP server configuration with HTTP transport.
|
||||
pub fn new(name: String, endpoint: String) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name,
|
||||
endpoint,
|
||||
transport: McpTransport::Http { endpoint: endpoint.clone() },
|
||||
endpoint, // Keep for backwards compat
|
||||
description: None,
|
||||
enabled: true,
|
||||
version: None,
|
||||
tools: Vec::new(),
|
||||
tool_descriptors: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
last_connected_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new MCP server configuration with stdio transport.
|
||||
pub fn new_stdio(
|
||||
name: String,
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
env: std::collections::HashMap<String, String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name,
|
||||
transport: McpTransport::Stdio { command, args, env },
|
||||
endpoint: String::new(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
version: None,
|
||||
tools: Vec::new(),
|
||||
tool_descriptors: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
last_connected_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the effective endpoint (for backwards compat)
|
||||
pub fn effective_endpoint(&self) -> Option<&str> {
|
||||
match &self.transport {
|
||||
McpTransport::Http { endpoint } => Some(endpoint.as_str()),
|
||||
McpTransport::Stdio { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime state of an MCP server (not persisted).
|
||||
@@ -215,15 +277,34 @@ pub struct McpTool {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AddMcpRequest {
|
||||
pub name: String,
|
||||
pub endpoint: String,
|
||||
/// HTTP endpoint (for backwards compat, use transport instead)
|
||||
#[serde(default)]
|
||||
pub endpoint: Option<String>,
|
||||
/// Transport configuration (preferred)
|
||||
#[serde(default)]
|
||||
pub transport: Option<McpTransport>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl AddMcpRequest {
|
||||
/// Get the effective transport from the request
|
||||
pub fn effective_transport(&self) -> McpTransport {
|
||||
if let Some(transport) = &self.transport {
|
||||
transport.clone()
|
||||
} else if let Some(endpoint) = &self.endpoint {
|
||||
McpTransport::Http { endpoint: endpoint.clone() }
|
||||
} else {
|
||||
McpTransport::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to update an MCP server.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UpdateMcpRequest {
|
||||
pub name: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
pub transport: Option<McpTransport>,
|
||||
pub description: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
@@ -235,7 +316,7 @@ pub struct McpToolsResponse {
|
||||
}
|
||||
|
||||
/// Tool descriptor from MCP server.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolDescriptor {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
|
||||
Reference in New Issue
Block a user