Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

499 lines
23 KiB
Swift

// PR 9 — Integrations.
//
// Routes Claude Code requests to local models or to the cloud, exposes the
// other named integrations (Codex / OpenCode / OpenClaw / Hermes / Pi /
// Copilot) as model popups with per-tool launch commands, and renders the
// Claude Code setup command — both the simple `omlx launch claude` form and
// an "Advanced" env-var recipe that targets the real `claude` binary
// directly. The model popups read their options from /admin/api/models so
// the user can only pick something the server actually has on disk.
//
// The OpenAI Compatibility section + Connected Apps from the design canvas
// are skipped: there are no matching server fields. We keep every shipped
// row honestly wired.
import SwiftUI
struct IntegrationsScreen: View {
@Environment(AppServices.self) private var services
@State private var vm = IntegrationsScreenVM()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ClaudeCodeSection(vm: vm, client: services.client)
ClaudeSetupCommandSection(vm: vm)
OtherIntegrationsSection(vm: vm, client: services.client)
MCPSection(vm: vm, client: services.client)
if let error = vm.lastError {
Text(error)
.font(.omlxText(11))
.foregroundStyle(.red)
.padding(.horizontal, 18)
.padding(.top, 8)
}
}
.task { await vm.load(client: services.client) }
}
}
// MARK: - Claude Code
private struct ClaudeCodeSection: View {
@Bindable var vm: IntegrationsScreenVM
let client: OMLXClient
var body: some View {
SectionHeader(
String(localized: "integrations.section.claude_code",
defaultValue: "Claude Code",
comment: "Section header for the Claude Code integration"),
subtitle: String(localized: "integrations.section.claude_code.sub",
defaultValue: "Route Claude Code requests to local models or the cloud",
comment: "Subtitle for the Claude Code section")
)
ListGroup {
Row(label: String(localized: "integrations.claude.mode",
defaultValue: "Mode",
comment: "Row label for the Claude Code mode segmented control")) {
Segmented(
selection: vm.bind($vm.claudeMode, save: {
Task { await vm.save(.claudeMode, client: client) }
}),
options: [
("cloud", String(localized: "integrations.claude.mode.cloud",
defaultValue: "Cloud",
comment: "Claude Code mode option: route to cloud")),
("local", String(localized: "integrations.claude.mode.local",
defaultValue: "Local",
comment: "Claude Code mode option: route to local server")),
]
)
}
if vm.claudeMode == "local" {
Row(label: String(localized: "integrations.claude.opus",
defaultValue: "Opus tier",
comment: "Row label for the Opus model picker")) {
Popup(
selection: vm.bind($vm.opusModel, save: {
Task { await vm.save(.opusModel, client: client) }
}),
width: 220,
options: vm.modelOptions
)
}
Row(label: String(localized: "integrations.claude.sonnet",
defaultValue: "Sonnet tier",
comment: "Row label for the Sonnet model picker")) {
Popup(
selection: vm.bind($vm.sonnetModel, save: {
Task { await vm.save(.sonnetModel, client: client) }
}),
width: 220,
options: vm.modelOptions
)
}
Row(
label: String(localized: "integrations.claude.haiku",
defaultValue: "Haiku tier",
comment: "Row label for the Haiku model picker"),
sublabel: String(localized: "integrations.claude.haiku.sub",
defaultValue: "Used for background tasks and tool calls",
comment: "Sublabel for the Haiku tier picker")
) {
Popup(
selection: vm.bind($vm.haikuModel, save: {
Task { await vm.save(.haikuModel, client: client) }
}),
width: 220,
options: vm.modelOptions
)
}
}
Row(
label: String(localized: "integrations.claude.context_scaling",
defaultValue: "Context scaling",
comment: "Row label for the Claude Code context scaling toggle"),
sublabel: String(localized: "integrations.claude.context_scaling.sub",
defaultValue: "Stretch context windows for long agentic sessions",
comment: "Sublabel for the context scaling toggle"),
isLast: !vm.contextScaling
) {
Toggle("", isOn: vm.bind($vm.contextScaling, save: {
Task { await vm.save(.contextScaling, client: client) }
}))
.labelsHidden().toggleStyle(.switch)
}
if vm.contextScaling {
Row(
label: String(localized: "integrations.claude.target_context",
defaultValue: "Target context size",
comment: "Row label for the Claude Code target context size field"),
sublabel: String(localized: "integrations.claude.target_context.sub",
defaultValue: "Per-request context window Claude Code will scale toward",
comment: "Sublabel for the target context size field"),
isLast: true
) {
TextInput(
text: $vm.targetContextSizeText,
mono: true,
suffix: "tk",
width: 130
)
}
}
}
if vm.contextScaling {
HStack {
Spacer()
Button(String(localized: "integrations.target_context.apply",
defaultValue: "Apply",
comment: "Apply button for the Claude Code target context size field")) {
Task { await vm.save(.targetContextSize, client: client) }
}
.buttonStyle(.omlx(.primary))
.disabled(!vm.hasPendingContextSizeChange)
}
.padding(.horizontal, 18)
.padding(.top, 6)
}
}
}
// MARK: - Setup command (Claude Code)
/// Houses both the primary `omlx launch claude` block and the "Advanced"
/// env-var recipe that points the real `claude` binary at the local server.
/// Mirrors `claudeCodeCommand` in `omlx/admin/static/js/dashboard.js`.
private struct ClaudeSetupCommandSection: View {
var vm: IntegrationsScreenVM
@State private var showAdvanced = false
@Environment(\.omlxTheme) private var theme
var body: some View {
SectionHeader(String(localized: "integrations.section.setup_command",
defaultValue: "Setup Command",
comment: "Section header for the Claude Code setup command block"))
VStack(alignment: .leading, spacing: 10) {
CommandBlock(command: vm.claudeLaunchCommand)
DisclosureGroup(isExpanded: $showAdvanced) {
VStack(alignment: .leading, spacing: 6) {
Text(vm.claudeMode == "cloud"
? String(localized: "integrations.setup.advanced.cloud",
defaultValue: "Resets Anthropic env vars so the real `claude` binary talks to the cloud.",
comment: "Explanation of the advanced env recipe in cloud mode")
: String(localized: "integrations.setup.advanced.local",
defaultValue: "Points the real `claude` binary at your local oMLX server.",
comment: "Explanation of the advanced env recipe in local mode"))
.font(.omlxText(11.5))
.foregroundStyle(theme.textSecondary)
CommandBlock(command: vm.claudeEnvRecipe)
}
.padding(.top, 6)
} label: {
Text(String(localized: "integrations.setup.advanced.label",
defaultValue: "Advanced — run `claude` directly",
comment: "Disclosure label revealing the advanced env-var recipe for running claude directly"))
.font(.omlxText(12, weight: .medium))
.foregroundStyle(theme.textSecondary)
}
.padding(.horizontal, 4)
}
.padding(.horizontal, 14)
}
}
/// Shared monospaced command block with a copy button. Used by both the
/// Claude Code section and each per-tool row in OtherIntegrationsSection.
private struct CommandBlock: View {
let command: String
var caption: String? = nil
@Environment(\.omlxTheme) private var theme
var body: some View {
ZStack(alignment: .topTrailing) {
VStack(alignment: .leading, spacing: 4) {
Text(caption ?? String(localized: "integrations.command.terminal_caption",
defaultValue: "$ Terminal",
comment: "Caption above each shell command block"))
.font(.omlxText(10, weight: .semibold))
.foregroundStyle(theme.textTertiary)
.textCase(.uppercase)
.kerning(0.6)
Text(command)
.font(.omlxMono(12))
.foregroundStyle(theme.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.background(theme.codeBg)
.clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: theme.cornerRadius, style: .continuous)
.strokeBorder(theme.groupBorder, lineWidth: 0.5)
)
CopyButton(value: command)
.padding(.top, 6)
.padding(.trailing, 8)
}
}
}
private struct CopyButton: View {
let value: String
@State private var copied = false
@Environment(\.omlxTheme) private var theme
var body: some View {
Button {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(value, forType: .string)
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copied = false
}
} label: {
Image(systemName: copied ? "checkmark" : "document.on.document")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(copied ? theme.successText : theme.textSecondary)
.padding(5)
.background(theme.controlBg)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
}
.buttonStyle(.plain)
}
}
// MARK: - Other integrations
private struct OtherIntegrationsSection: View {
@Bindable var vm: IntegrationsScreenVM
let client: OMLXClient
var body: some View {
SectionHeader(
String(localized: "integrations.section.other",
defaultValue: "Other Integrations",
comment: "Section header for the additional integrations list"),
subtitle: String(localized: "integrations.section.other.sub",
defaultValue: "Default model + launcher command for each named integration",
comment: "Subtitle for the Other Integrations section")
)
ListGroup {
IntegrationRow(
name: String(localized: "integrations.tool.codex",
defaultValue: "Codex",
comment: "Display name for the Codex integration"),
modelBinding: vm.bind($vm.codexModel, save: {
Task { await vm.save(.codexModel, client: client) }
}),
modelOptions: vm.modelOptions,
command: vm.codexCommand,
commandLabel: String(localized: "integrations.codex.cli",
defaultValue: "CLI",
comment: "Label for the Codex CLI launcher command"),
secondaryCommand: vm.codexAppCommand,
secondaryCommandLabel: String(localized: "integrations.codex.app",
defaultValue: "Desktop App",
comment: "Label for the Codex desktop app launcher command")
)
IntegrationRow(
name: String(localized: "integrations.tool.opencode",
defaultValue: "OpenCode",
comment: "Display name for the OpenCode integration"),
modelBinding: vm.bind($vm.opencodeModel, save: {
Task { await vm.save(.opencodeModel, client: client) }
}),
modelOptions: vm.modelOptions,
command: vm.opencodeCommand
)
IntegrationRow(
name: String(localized: "integrations.tool.openclaw",
defaultValue: "OpenClaw",
comment: "Display name for the OpenClaw integration"),
modelBinding: vm.bind($vm.openclawModel, save: {
Task { await vm.save(.openclawModel, client: client) }
}),
modelOptions: vm.modelOptions,
command: vm.openclawCommand,
profileBinding: vm.bind($vm.openclawToolsProfile, save: {
Task { await vm.save(.openclawToolsProfile, client: client) }
}),
profileSublabel: String(localized: "integrations.openclaw.profile_sub",
defaultValue: "Built-in MCP tools the OpenClaw launcher exposes",
comment: "Sublabel for the OpenClaw tools-profile picker")
)
IntegrationRow(
name: String(localized: "integrations.tool.hermes",
defaultValue: "Hermes Agent",
comment: "Display name for the Hermes Agent integration"),
modelBinding: vm.bind($vm.hermesModel, save: {
Task { await vm.save(.hermesModel, client: client) }
}),
modelOptions: vm.modelOptions,
command: vm.hermesCommand
)
IntegrationRow(
name: String(localized: "integrations.tool.pi",
defaultValue: "Pi",
comment: "Display name for the Pi integration"),
modelBinding: vm.bind($vm.piModel, save: {
Task { await vm.save(.piModel, client: client) }
}),
modelOptions: vm.modelOptions,
command: vm.piCommand
)
IntegrationRow(
name: String(localized: "integrations.tool.copilot",
defaultValue: "Copilot CLI",
comment: "Display name for the Copilot CLI integration"),
modelBinding: vm.bind($vm.copilotModel, save: {
Task { await vm.save(.copilotModel, client: client) }
}),
modelOptions: vm.modelOptions,
command: vm.copilotCommand,
isLast: true
)
}
}
}
/// Per-integration FreeRow: name + model picker on the top half, monospaced
/// launcher command + copy button below. Optional OpenClaw tools-profile
/// popup folds in under the model picker.
private struct IntegrationRow: View {
let name: String
let modelBinding: Binding<String>
let modelOptions: [(String, String)]
let command: String
var commandLabel: String? = nil
var secondaryCommand: String? = nil
var secondaryCommandLabel: String? = nil
var profileBinding: Binding<String>? = nil
var profileSublabel: String? = nil
var isLast: Bool = false
@Environment(\.omlxTheme) private var theme
var body: some View {
FreeRow(isLast: isLast) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(name)
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.text)
Spacer(minLength: 12)
Popup(
selection: modelBinding,
width: 220,
options: modelOptions
)
}
if let profileBinding {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "integrations.openclaw.profile_label",
defaultValue: "Tools profile",
comment: "Row label for the OpenClaw tools-profile picker"))
.font(.omlxText(12))
.foregroundStyle(theme.textSecondary)
if let profileSublabel {
Text(profileSublabel)
.font(.omlxText(11))
.foregroundStyle(theme.textTertiary)
}
}
Spacer(minLength: 12)
Popup(
selection: profileBinding,
width: 160,
options: [
("minimal", String(localized: "integrations.openclaw.profile.minimal",
defaultValue: "Minimal",
comment: "OpenClaw tools profile option: minimal")),
("coding", String(localized: "integrations.openclaw.profile.coding",
defaultValue: "Coding",
comment: "OpenClaw tools profile option: coding")),
("messaging", String(localized: "integrations.openclaw.profile.messaging",
defaultValue: "Messaging",
comment: "OpenClaw tools profile option: messaging")),
("full", String(localized: "integrations.openclaw.profile.full",
defaultValue: "Full",
comment: "OpenClaw tools profile option: full")),
]
)
}
}
if let secondaryCommand {
VStack(alignment: .leading, spacing: 6) {
CommandBlock(command: command, caption: commandLabel)
CommandBlock(command: secondaryCommand, caption: secondaryCommandLabel)
}
} else {
CommandBlock(command: command, caption: commandLabel)
}
}
}
}
}
// MARK: - MCP
/// Path to an MCP server config file consumed by every integration launcher
/// (Claude Code, OpenClaw, Hermes, …). Lives at the bottom of Integrations
/// because it's a shared resource — putting it under any one integration
/// would mislead.
private struct MCPSection: View {
@Bindable var vm: IntegrationsScreenVM
let client: OMLXClient
var body: some View {
SectionHeader(
String(localized: "integrations.section.mcp",
defaultValue: "MCP",
comment: "Section header for the MCP config path row"),
subtitle: String(localized: "integrations.section.mcp.sub",
defaultValue: "Path to an MCP server config file. Shared across all integration launchers.",
comment: "Subtitle for the MCP section")
)
ListGroup {
Row(
label: String(localized: "integrations.mcp.config_path",
defaultValue: "Config Path",
comment: "Row label for the MCP config path input"),
sublabel: String(localized: "integrations.mcp.config_path.sub",
defaultValue: "Absolute path to an MCP config JSON. Leave blank to disable.",
comment: "Sublabel describing the MCP config path field"),
isLast: true
) {
TextInput(
text: $vm.mcpConfigPath,
placeholder: "/path/to/mcp.json",
mono: true,
width: 320
)
}
}
HStack {
Spacer()
Button(String(localized: "integrations.mcp.apply",
defaultValue: "Apply",
comment: "Apply button for the MCP config path")) {
Task { await vm.save(.mcpConfig, client: client) }
}
.buttonStyle(.omlx(.primary))
.disabled(!vm.hasPendingMCPChanges)
}
.padding(.horizontal, 18)
.padding(.top, 6)
}
}