@agentprojectcontext/apx 1.69.0 → 1.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/agent/tools/handlers/_obsidian.js +28 -0
- package/src/core/agent/tools/handlers/obsidian-list-notes.js +28 -0
- package/src/core/agent/tools/handlers/obsidian-read-note.js +31 -0
- package/src/core/agent/tools/handlers/obsidian-search-notes.js +30 -0
- package/src/core/agent/tools/handlers/obsidian-write-note.js +38 -0
- package/src/core/agent/tools/names.js +10 -0
- package/src/core/agent/tools/registry.js +8 -0
- package/src/core/integrations/catalog.js +6 -3
- package/src/core/integrations/index.js +2 -0
- package/src/core/integrations/mcp-sync.js +71 -0
- package/src/core/integrations/obsidian-memory.js +108 -0
- package/src/core/integrations/plugins/obsidian.js +299 -0
- package/src/core/mcp/runner.js +54 -20
- package/src/core/memory/broker.js +4 -1
- package/src/core/memory/index.js +29 -2
- package/src/core/memory/indexer.js +88 -1
- package/src/core/memory/store.js +23 -3
- package/src/host/daemon/api/integrations.js +27 -2
- package/src/host/daemon/api/mcps.js +2 -2
- package/src/host/daemon/index.js +7 -1
- package/src/interfaces/cli/commands/obsidian.js +79 -0
- package/src/interfaces/cli/index.js +48 -1
- package/src/interfaces/web/dist/assets/index-CDz9OwCP.css +1 -0
- package/src/interfaces/web/dist/assets/{index-_2zKBH4O.js → index-YFsZFhM6.js} +172 -177
- package/src/interfaces/web/dist/assets/index-YFsZFhM6.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/integrations/BrandLogos.tsx +29 -0
- package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +5 -4
- package/src/interfaces/web/src/components/integrations/FolderInput.tsx +137 -0
- package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +129 -10
- package/src/interfaces/web/src/i18n/en.ts +21 -0
- package/src/interfaces/web/src/i18n/es.ts +21 -0
- package/src/interfaces/web/src/lib/api/integrations.ts +10 -1
- package/src/interfaces/web/dist/assets/index-_2zKBH4O.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-xQYf6_ab.css +0 -1
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
<link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
|
|
19
19
|
<link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
|
|
20
20
|
<link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
|
|
21
|
-
<script type="module" crossorigin src="/assets/index-
|
|
22
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
21
|
+
<script type="module" crossorigin src="/assets/index-YFsZFhM6.js"></script>
|
|
22
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CDz9OwCP.css">
|
|
23
23
|
</head>
|
|
24
24
|
<body class="bg-background text-foreground antialiased">
|
|
25
25
|
<div id="root"></div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Single source of truth for third-party brand marks used across the
|
|
2
|
+
// Integrations UI (config cards in PluginConnect, roster cards in
|
|
3
|
+
// ComingSoonPlugin). Each is a single-path SVG from simple-icons, rendered in
|
|
4
|
+
// `currentColor` so callers control the tint via className. GitHub keeps using
|
|
5
|
+
// the lucide `Github` icon — it already reads well and needs no brand path.
|
|
6
|
+
|
|
7
|
+
export function AsanaLogo({ className }: { className?: string }) {
|
|
8
|
+
return (
|
|
9
|
+
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
|
10
|
+
<path d="M18.833 9.637a4.167 4.167 0 1 1 0 8.333 4.167 4.167 0 0 1 0-8.333zm-13.666 0a4.167 4.167 0 1 1 0 8.333 4.167 4.167 0 0 1 0-8.333zM12 2a4.167 4.167 0 1 1 0 8.333A4.167 4.167 0 0 1 12 2z" />
|
|
11
|
+
</svg>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ObsidianLogo({ className }: { className?: string }) {
|
|
16
|
+
return (
|
|
17
|
+
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
|
18
|
+
<path d="M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function WhatsAppLogo({ className }: { className?: string }) {
|
|
24
|
+
return (
|
|
25
|
+
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
|
26
|
+
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import { Github,
|
|
1
|
+
import { useState, type ComponentType } from "react";
|
|
2
|
+
import { Github, Mic, Puzzle } from "lucide-react";
|
|
3
3
|
import type { CatalogEntry } from "../../lib/api";
|
|
4
4
|
import { t } from "../../i18n";
|
|
5
5
|
import { PluginCard } from "./PluginCard";
|
|
6
|
+
import { WhatsAppLogo } from "./BrandLogos";
|
|
6
7
|
|
|
7
|
-
const ICONS: Record<string, { icon:
|
|
8
|
+
const ICONS: Record<string, { icon: ComponentType<{ className?: string }>; className: string; wrap: string }> = {
|
|
8
9
|
github: { icon: Github, className: "text-slate-200", wrap: "border-slate-500/30 from-slate-500/20 to-slate-700/20" },
|
|
9
|
-
whatsapp: { icon:
|
|
10
|
+
whatsapp: { icon: WhatsAppLogo, className: "text-[#25D366]", wrap: "border-[#25D366]/30 from-[#25D366]/20 to-[#128C7E]/20" },
|
|
10
11
|
"local-transcription": { icon: Mic, className: "text-orange-400", wrap: "border-orange-500/30 from-orange-500/20 to-amber-500/20" },
|
|
11
12
|
};
|
|
12
13
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { FolderOpen, Home, Loader2, Search, X } from "lucide-react";
|
|
3
|
+
import { Filesystem } from "../../lib/api";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { t } from "../../i18n";
|
|
6
|
+
|
|
7
|
+
// Text input for a filesystem folder plus a "browse" button. Tries the OS-native
|
|
8
|
+
// folder picker first (osascript / zenity / PowerShell via the daemon); if that's
|
|
9
|
+
// unavailable (headless / remote daemon) it falls back to an inline directory
|
|
10
|
+
// list. Mirrors AddProjectDialog's picker so path fields feel consistent.
|
|
11
|
+
export function FolderInput({
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
onEnter,
|
|
15
|
+
placeholder,
|
|
16
|
+
ringClassName,
|
|
17
|
+
btnClassName,
|
|
18
|
+
}: {
|
|
19
|
+
value: string;
|
|
20
|
+
onChange: (next: string) => void;
|
|
21
|
+
onEnter?: () => void;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
ringClassName?: string;
|
|
24
|
+
btnClassName?: string;
|
|
25
|
+
}) {
|
|
26
|
+
const [browseOpen, setBrowseOpen] = useState(false);
|
|
27
|
+
const [browsePath, setBrowsePath] = useState("");
|
|
28
|
+
const [entries, setEntries] = useState<string[]>([]);
|
|
29
|
+
const [parent, setParent] = useState<string | null>(null);
|
|
30
|
+
const [error, setError] = useState("");
|
|
31
|
+
const [loading, setLoading] = useState(false);
|
|
32
|
+
|
|
33
|
+
const loadDirs = async (nextPath: string) => {
|
|
34
|
+
setLoading(true);
|
|
35
|
+
setError("");
|
|
36
|
+
try {
|
|
37
|
+
const out = await Filesystem.dirs(nextPath || "~");
|
|
38
|
+
setBrowsePath(out.path);
|
|
39
|
+
onChange(out.path);
|
|
40
|
+
setParent(out.parent);
|
|
41
|
+
setEntries(out.entries);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
setError((e as Error).message);
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const openBrowser = async () => {
|
|
50
|
+
setLoading(true);
|
|
51
|
+
try {
|
|
52
|
+
const out = await Filesystem.pickDir(t("add_project.picker_prompt"));
|
|
53
|
+
if ("cancelled" in out) return;
|
|
54
|
+
onChange(out.path);
|
|
55
|
+
} catch {
|
|
56
|
+
// Native picker unavailable — reveal the inline browser instead.
|
|
57
|
+
setBrowseOpen(true);
|
|
58
|
+
await loadDirs(value || "~");
|
|
59
|
+
} finally {
|
|
60
|
+
setLoading(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="space-y-2">
|
|
66
|
+
<div className="flex gap-2">
|
|
67
|
+
<input
|
|
68
|
+
type="text"
|
|
69
|
+
placeholder={placeholder}
|
|
70
|
+
value={value}
|
|
71
|
+
onChange={(e) => onChange(e.target.value)}
|
|
72
|
+
onKeyDown={(e) => e.key === "Enter" && onEnter?.()}
|
|
73
|
+
className={cn(
|
|
74
|
+
"w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none placeholder:text-muted-foreground/60",
|
|
75
|
+
ringClassName,
|
|
76
|
+
)}
|
|
77
|
+
/>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={openBrowser}
|
|
81
|
+
disabled={loading}
|
|
82
|
+
className={cn(
|
|
83
|
+
"flex flex-shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50",
|
|
84
|
+
btnClassName,
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
|
|
88
|
+
{t("add_project.search_btn")}
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{browseOpen && (
|
|
93
|
+
<div className="rounded-lg border border-border bg-muted/20">
|
|
94
|
+
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
|
95
|
+
<span className="truncate font-mono text-[10px] text-muted-foreground">{browsePath || value || "~"}</span>
|
|
96
|
+
<div className="flex gap-1">
|
|
97
|
+
<button type="button" onClick={() => loadDirs("~")} disabled={loading} className="rounded p-1 hover:bg-accent disabled:opacity-50">
|
|
98
|
+
<Home className="h-3 w-3" />
|
|
99
|
+
</button>
|
|
100
|
+
<button type="button" onClick={() => parent && loadDirs(parent)} disabled={!parent || loading} className="rounded px-1.5 py-0.5 text-[10px] hover:bg-accent disabled:opacity-50">
|
|
101
|
+
..
|
|
102
|
+
</button>
|
|
103
|
+
<button type="button" onClick={() => setBrowseOpen(false)} disabled={loading} className="rounded p-1 hover:bg-accent disabled:opacity-50">
|
|
104
|
+
<X className="h-3 w-3" />
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<div className="max-h-56 overflow-y-auto p-2">
|
|
109
|
+
{loading && (
|
|
110
|
+
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
111
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("common.loading")}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
{!loading && error && (
|
|
115
|
+
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">{t("add_project.browser_unavailable")}</p>
|
|
116
|
+
)}
|
|
117
|
+
{!loading && !error && entries.length === 0 && (
|
|
118
|
+
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">{t("add_project.no_folders")}</p>
|
|
119
|
+
)}
|
|
120
|
+
{!loading && !error && entries.map((entry) => (
|
|
121
|
+
<button
|
|
122
|
+
key={entry}
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => loadDirs(entry)}
|
|
125
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent"
|
|
126
|
+
>
|
|
127
|
+
<FolderOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
|
128
|
+
<span className="truncate">{entry.split("/").pop()}</span>
|
|
129
|
+
<span className="ml-auto truncate font-mono text-[10px] text-muted-foreground">{entry}</span>
|
|
130
|
+
</button>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import useSWR from "swr";
|
|
3
|
-
import { AlertCircle, CheckCircle2, ChevronDown, ExternalLink, Eye, EyeOff, Github, Loader2, WifiOff, X } from "lucide-react";
|
|
3
|
+
import { AlertCircle, CheckCircle2, ChevronDown, ExternalLink, Eye, EyeOff, Github, Loader2, RefreshCw, WifiOff, X } from "lucide-react";
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
5
|
import { Integrations, type CatalogEntry, type IntegrationScope, type IntegrationStatus } from "../../lib/api";
|
|
6
6
|
import { t } from "../../i18n";
|
|
7
7
|
import type { TKey } from "../../i18n";
|
|
8
8
|
import { PluginCard } from "./PluginCard";
|
|
9
9
|
import { PluginToolsSection } from "./PluginToolsSection";
|
|
10
|
+
import { FolderInput } from "./FolderInput";
|
|
11
|
+
import { AsanaLogo, ObsidianLogo } from "./BrandLogos";
|
|
10
12
|
|
|
11
13
|
// One generic component that renders any token-based plugin's config form from
|
|
12
14
|
// its `ui` descriptor (structure) + i18n (all display text, keyed by slug). Asana
|
|
@@ -15,11 +17,22 @@ import { PluginToolsSection } from "./PluginToolsSection";
|
|
|
15
17
|
// Dynamic i18n lookup for computed per-slug keys (t is typed to known keys).
|
|
16
18
|
const tk = (key: string, vars?: Record<string, string | number>) => t(key as TKey, vars);
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
// Minimal on/off switch matching the hand-rolled styling of this component.
|
|
21
|
+
function Toggle({ on, onChange, disabled, accent }: { on: boolean; onChange: (next: boolean) => void; disabled?: boolean; accent: Accent }) {
|
|
19
22
|
return (
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
role="switch"
|
|
26
|
+
aria-checked={on}
|
|
27
|
+
disabled={disabled}
|
|
28
|
+
onClick={() => onChange(!on)}
|
|
29
|
+
className={cn(
|
|
30
|
+
"relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
|
31
|
+
on ? cn("bg-emerald-500/30", accent.border) : "border-border bg-muted",
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
<span className={cn("inline-block h-3.5 w-3.5 transform rounded-full bg-foreground transition-transform", on ? "translate-x-4" : "translate-x-0.5")} />
|
|
35
|
+
</button>
|
|
23
36
|
);
|
|
24
37
|
}
|
|
25
38
|
|
|
@@ -27,11 +40,13 @@ type Accent = { text: string; border: string; hover: string; ring: string; wrap:
|
|
|
27
40
|
const ACCENTS: Record<string, Accent> = {
|
|
28
41
|
rose: { text: "text-rose-400", border: "border-rose-700/50", hover: "hover:bg-rose-900/20", ring: "focus:border-rose-500/50", wrap: "border-rose-500/30 from-rose-500/20 to-pink-500/20" },
|
|
29
42
|
slate: { text: "text-slate-200", border: "border-slate-600/60", hover: "hover:bg-slate-700/30", ring: "focus:border-slate-400/60", wrap: "border-slate-500/30 from-slate-500/20 to-slate-700/20" },
|
|
43
|
+
purple: { text: "text-purple-400", border: "border-purple-700/50", hover: "hover:bg-purple-900/20", ring: "focus:border-purple-500/50", wrap: "border-purple-500/30 from-purple-500/20 to-violet-500/20" },
|
|
30
44
|
};
|
|
31
45
|
|
|
32
46
|
function iconFor(slug: string, accent: Accent): ReactNode {
|
|
33
47
|
if (slug === "github") return <Github className={cn("h-6 w-6", accent.text)} />;
|
|
34
48
|
if (slug === "asana") return <AsanaLogo className={cn("h-6 w-6", accent.text)} />;
|
|
49
|
+
if (slug === "obsidian") return <ObsidianLogo className={cn("h-6 w-6", accent.text)} />;
|
|
35
50
|
return <span className={cn("text-lg", accent.text)}>◆</span>;
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -50,6 +65,8 @@ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: Integ
|
|
|
50
65
|
const [error, setError] = useState<string | null>(null);
|
|
51
66
|
const [selectOptions, setSelectOptions] = useState<Opt[]>([]);
|
|
52
67
|
const [selectValue, setSelectValue] = useState("");
|
|
68
|
+
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
|
69
|
+
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
|
53
70
|
|
|
54
71
|
const { data: status, mutate, isLoading } = useSWR<IntegrationStatus>(
|
|
55
72
|
`integration-status-${entry.slug}-${pid}-${scope}`,
|
|
@@ -60,13 +77,22 @@ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: Integ
|
|
|
60
77
|
const isActive = status?.status === "active" && status.is_enabled === true;
|
|
61
78
|
const busy = step === "saving" || step === "validating";
|
|
62
79
|
const showForm = !isActive && selectOptions.length === 0;
|
|
80
|
+
// Toggle fields (boolean switches) vs. required text/password inputs.
|
|
81
|
+
const toggleFields = ui.configFields.filter((f) => f.type === "toggle");
|
|
82
|
+
const requiredFields = ui.configFields.filter((f) => f.type !== "toggle");
|
|
83
|
+
const missingRequired = requiredFields.some((f) => !values[f.key]?.trim());
|
|
63
84
|
|
|
64
85
|
async function handleConnect() {
|
|
65
|
-
if (
|
|
86
|
+
if (missingRequired) return;
|
|
66
87
|
setStep("saving");
|
|
67
88
|
setError(null);
|
|
68
89
|
try {
|
|
69
|
-
|
|
90
|
+
// Seed toggle defaults so the plugin gets an explicit boolean on first save.
|
|
91
|
+
const payload: Record<string, unknown> = { ...values };
|
|
92
|
+
for (const f of toggleFields) {
|
|
93
|
+
if (payload[f.key] === undefined) payload[f.key] = f.default ? "true" : "false";
|
|
94
|
+
}
|
|
95
|
+
await Integrations.configure(pid, entry.slug, scope, payload);
|
|
70
96
|
setStep("validating");
|
|
71
97
|
const result = (await Integrations.validate(pid, entry.slug, scope)) as unknown as Record<string, unknown>;
|
|
72
98
|
await mutate();
|
|
@@ -111,6 +137,40 @@ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: Integ
|
|
|
111
137
|
}
|
|
112
138
|
}
|
|
113
139
|
|
|
140
|
+
// Flip a boolean toggle while the plugin is already connected (re-configures +
|
|
141
|
+
// re-validates so side effects like the auto-MCP reconcile run).
|
|
142
|
+
async function handleToggleLive(key: string, next: boolean) {
|
|
143
|
+
setError(null);
|
|
144
|
+
setActionMsg(null);
|
|
145
|
+
try {
|
|
146
|
+
await Integrations.configure(pid, entry.slug, scope, { [key]: next ? "true" : "false" });
|
|
147
|
+
await Integrations.validate(pid, entry.slug, scope);
|
|
148
|
+
await mutate();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
setError(err instanceof Error ? err.message : t("integrations.err_generic"));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Run a plugin action (e.g. Obsidian sync_memory) from the connected view.
|
|
155
|
+
async function handleAction(action: string) {
|
|
156
|
+
setError(null);
|
|
157
|
+
setActionMsg(null);
|
|
158
|
+
setActionBusy(action);
|
|
159
|
+
try {
|
|
160
|
+
const r = (await Integrations.action(pid, entry.slug, action, scope)) as Record<string, unknown>;
|
|
161
|
+
if (typeof r?.count === "number") {
|
|
162
|
+
setActionMsg(tk(`integrations.${entry.slug}.actions.${action}_done`, { count: r.count, changed: Number(r.changed ?? 0) }));
|
|
163
|
+
} else {
|
|
164
|
+
setActionMsg(t("integrations.action_done"));
|
|
165
|
+
}
|
|
166
|
+
await mutate();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
setError(err instanceof Error ? err.message : t("integrations.err_generic"));
|
|
169
|
+
} finally {
|
|
170
|
+
setActionBusy(null);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
114
174
|
const badgeLabel = isLoading
|
|
115
175
|
? "…"
|
|
116
176
|
: isActive
|
|
@@ -171,6 +231,40 @@ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: Integ
|
|
|
171
231
|
</div>
|
|
172
232
|
)}
|
|
173
233
|
|
|
234
|
+
{/* Options (live toggles) + actions — shown once connected */}
|
|
235
|
+
{isActive && selectOptions.length === 0 && toggleFields.length > 0 && (
|
|
236
|
+
<div className="space-y-2 rounded-xl border border-border p-3">
|
|
237
|
+
{toggleFields.map((field) => (
|
|
238
|
+
<div key={field.key} className="flex items-center justify-between gap-3">
|
|
239
|
+
<div>
|
|
240
|
+
<p className="text-[11px] font-medium text-foreground">{tk(`integrations.${entry.slug}.fields.${field.key}.label`)}</p>
|
|
241
|
+
<p className="text-[10px] text-muted-foreground">{tk(`integrations.${entry.slug}.fields.${field.key}.hint`)}</p>
|
|
242
|
+
</div>
|
|
243
|
+
<Toggle on={!!status?.[field.key]} accent={accent} onChange={(next) => handleToggleLive(field.key, next)} />
|
|
244
|
+
</div>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{isActive && selectOptions.length === 0 && (ui.actions?.length ?? 0) > 0 && (
|
|
250
|
+
<div className="space-y-2">
|
|
251
|
+
{actionMsg && <p className="text-[11px] text-emerald-400">{actionMsg}</p>}
|
|
252
|
+
<div className="flex flex-wrap gap-2">
|
|
253
|
+
{ui.actions!.map((a) => (
|
|
254
|
+
<button
|
|
255
|
+
key={a.action}
|
|
256
|
+
onClick={() => handleAction(a.action)}
|
|
257
|
+
disabled={actionBusy === a.action}
|
|
258
|
+
className={cn("flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50", accent.border, accent.text, accent.hover)}
|
|
259
|
+
>
|
|
260
|
+
{actionBusy === a.action ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
|
261
|
+
{tk(`integrations.${entry.slug}.actions.${a.action}`)}
|
|
262
|
+
</button>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
174
268
|
{/* Post-validate selection (e.g. Asana workspace) */}
|
|
175
269
|
{selectOptions.length > 1 && ui.select && (
|
|
176
270
|
<div className="space-y-2">
|
|
@@ -201,7 +295,20 @@ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: Integ
|
|
|
201
295
|
{showForm && (
|
|
202
296
|
<div className="space-y-3">
|
|
203
297
|
<p className="text-xs font-semibold text-foreground">{t("integrations.credentials", { name: entry.name })}</p>
|
|
204
|
-
{ui.configFields.map((field) =>
|
|
298
|
+
{ui.configFields.map((field) => {
|
|
299
|
+
if (field.type === "toggle") {
|
|
300
|
+
const on = values[field.key] !== undefined ? values[field.key] === "true" : !!field.default;
|
|
301
|
+
return (
|
|
302
|
+
<div key={field.key} className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
|
|
303
|
+
<div>
|
|
304
|
+
<p className="text-[11px] font-medium text-foreground">{tk(`integrations.${entry.slug}.fields.${field.key}.label`)}</p>
|
|
305
|
+
<p className="text-[10px] text-muted-foreground">{tk(`integrations.${entry.slug}.fields.${field.key}.hint`)}</p>
|
|
306
|
+
</div>
|
|
307
|
+
<Toggle on={on} accent={accent} onChange={(next) => setValues((v) => ({ ...v, [field.key]: next ? "true" : "false" }))} />
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return (
|
|
205
312
|
<div key={field.key} className="space-y-2">
|
|
206
313
|
{field.help_url && (
|
|
207
314
|
<div className="overflow-hidden rounded-lg border border-border">
|
|
@@ -238,6 +345,16 @@ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: Integ
|
|
|
238
345
|
)}
|
|
239
346
|
<div>
|
|
240
347
|
<label className="mb-1 block text-[10px] text-muted-foreground">{tk(`integrations.${entry.slug}.fields.${field.key}.label`)}</label>
|
|
348
|
+
{field.type === "path" ? (
|
|
349
|
+
<FolderInput
|
|
350
|
+
value={values[field.key] || ""}
|
|
351
|
+
onChange={(next) => setValues((v) => ({ ...v, [field.key]: next }))}
|
|
352
|
+
onEnter={handleConnect}
|
|
353
|
+
placeholder={field.placeholder}
|
|
354
|
+
ringClassName={accent.ring}
|
|
355
|
+
btnClassName={cn(accent.border, accent.text, accent.hover)}
|
|
356
|
+
/>
|
|
357
|
+
) : (
|
|
241
358
|
<div className="relative">
|
|
242
359
|
<input
|
|
243
360
|
type={field.type === "password" && !reveal[field.key] ? "password" : "text"}
|
|
@@ -258,9 +375,11 @@ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: Integ
|
|
|
258
375
|
</button>
|
|
259
376
|
)}
|
|
260
377
|
</div>
|
|
378
|
+
)}
|
|
261
379
|
</div>
|
|
262
380
|
</div>
|
|
263
|
-
|
|
381
|
+
);
|
|
382
|
+
})}
|
|
264
383
|
|
|
265
384
|
{step === "validating" && (
|
|
266
385
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
@@ -270,7 +389,7 @@ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: Integ
|
|
|
270
389
|
|
|
271
390
|
<button
|
|
272
391
|
onClick={handleConnect}
|
|
273
|
-
disabled={
|
|
392
|
+
disabled={missingRequired || busy}
|
|
274
393
|
className={cn("flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50", accent.border, accent.text, accent.hover)}
|
|
275
394
|
>
|
|
276
395
|
{busy ? (
|
|
@@ -1881,6 +1881,7 @@ export const en = {
|
|
|
1881
1881
|
tools_available_note: "Available to agents that allow them, or via discover_tools.",
|
|
1882
1882
|
err_connect: "Failed to connect",
|
|
1883
1883
|
err_generic: "Something went wrong",
|
|
1884
|
+
action_done: "Done",
|
|
1884
1885
|
asana: {
|
|
1885
1886
|
select_label: "Select the workspace to use",
|
|
1886
1887
|
connected: { user_name: "Connected as", user_email: "Email", workspace_name: "Workspace" },
|
|
@@ -1902,5 +1903,25 @@ export const en = {
|
|
|
1902
1903
|
},
|
|
1903
1904
|
},
|
|
1904
1905
|
},
|
|
1906
|
+
obsidian: {
|
|
1907
|
+
connected: { vault_path: "Vault", vault_name: "Name", note_count: "Notes" },
|
|
1908
|
+
fields: {
|
|
1909
|
+
vault_path: {
|
|
1910
|
+
label: "Vault path",
|
|
1911
|
+
},
|
|
1912
|
+
auto_mcp: {
|
|
1913
|
+
label: "Auto-register Obsidian MCP",
|
|
1914
|
+
hint: "Adds an 'obsidian' MCP server pointing at this vault, in this scope.",
|
|
1915
|
+
},
|
|
1916
|
+
memory_sync: {
|
|
1917
|
+
label: "Sync APX memory",
|
|
1918
|
+
hint: "Enable mirroring APX memory into the vault, then use the button below.",
|
|
1919
|
+
},
|
|
1920
|
+
},
|
|
1921
|
+
actions: {
|
|
1922
|
+
sync_memory: "Sync memory now",
|
|
1923
|
+
sync_memory_done: "Synced {count} file(s) · {changed} changed",
|
|
1924
|
+
},
|
|
1925
|
+
},
|
|
1905
1926
|
},
|
|
1906
1927
|
} as const;
|
|
@@ -1879,6 +1879,7 @@ export const es = {
|
|
|
1879
1879
|
tools_available_note: "Disponibles para los agentes que las tengan permitidas, o vía discover_tools.",
|
|
1880
1880
|
err_connect: "Error al conectar",
|
|
1881
1881
|
err_generic: "Ocurrió un error",
|
|
1882
|
+
action_done: "Listo",
|
|
1882
1883
|
asana: {
|
|
1883
1884
|
select_label: "Seleccioná el workspace a usar",
|
|
1884
1885
|
connected: { user_name: "Conectado como", user_email: "Email", workspace_name: "Workspace" },
|
|
@@ -1900,6 +1901,26 @@ export const es = {
|
|
|
1900
1901
|
},
|
|
1901
1902
|
},
|
|
1902
1903
|
},
|
|
1904
|
+
obsidian: {
|
|
1905
|
+
connected: { vault_path: "Vault", vault_name: "Nombre", note_count: "Notas" },
|
|
1906
|
+
fields: {
|
|
1907
|
+
vault_path: {
|
|
1908
|
+
label: "Ruta del Vault",
|
|
1909
|
+
},
|
|
1910
|
+
auto_mcp: {
|
|
1911
|
+
label: "Registrar MCP de Obsidian",
|
|
1912
|
+
hint: "Agrega un MCP 'obsidian' apuntando a este vault, en este scope.",
|
|
1913
|
+
},
|
|
1914
|
+
memory_sync: {
|
|
1915
|
+
label: "Sincronizar memoria de APX",
|
|
1916
|
+
hint: "Habilita el respaldo de la memoria de APX en el vault; luego usá el botón de abajo.",
|
|
1917
|
+
},
|
|
1918
|
+
},
|
|
1919
|
+
actions: {
|
|
1920
|
+
sync_memory: "Sincronizar memoria",
|
|
1921
|
+
sync_memory_done: "Sincronizados {count} archivo(s) · {changed} cambiados",
|
|
1922
|
+
},
|
|
1923
|
+
},
|
|
1903
1924
|
},
|
|
1904
1925
|
} as const;
|
|
1905
1926
|
|
|
@@ -25,10 +25,13 @@ export interface PluginTool {
|
|
|
25
25
|
// generic PluginConnect component. help_url/help_url_label are non-translatable.
|
|
26
26
|
export interface PluginConfigField {
|
|
27
27
|
key: string;
|
|
28
|
-
|
|
28
|
+
// "toggle" renders a switch (boolean); text/password render inputs; "path"
|
|
29
|
+
// renders a text input plus a folder-picker button (OS-native + inline fallback).
|
|
30
|
+
type: "password" | "text" | "toggle" | "path";
|
|
29
31
|
placeholder?: string;
|
|
30
32
|
help_url?: string;
|
|
31
33
|
help_url_label?: string;
|
|
34
|
+
default?: boolean;
|
|
32
35
|
}
|
|
33
36
|
export interface PluginSelect {
|
|
34
37
|
key: string;
|
|
@@ -37,11 +40,17 @@ export interface PluginSelect {
|
|
|
37
40
|
valueKey: string;
|
|
38
41
|
labelKey: string;
|
|
39
42
|
}
|
|
43
|
+
// A plugin read/side-effect action surfaced as a button in the connected view
|
|
44
|
+
// (label from i18n integrations.<slug>.actions.<action>).
|
|
45
|
+
export interface PluginAction {
|
|
46
|
+
action: string;
|
|
47
|
+
}
|
|
40
48
|
export interface PluginUi {
|
|
41
49
|
accent?: string;
|
|
42
50
|
configFields: PluginConfigField[];
|
|
43
51
|
select?: PluginSelect;
|
|
44
52
|
connectedFields?: string[];
|
|
53
|
+
actions?: PluginAction[];
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
// One entry of the plugin catalog with its resolved status for this project.
|