@aion0/forge 0.8.4 → 0.8.6
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/RELEASE_NOTES.md +4 -8
- package/app/api/agents/route.ts +11 -1
- package/app/api/jobs/preview/route.ts +54 -5
- package/app/api/jobs/recipes/route.ts +59 -0
- package/app/api/skills/install-local/route.ts +54 -1
- package/app/api/workflows/marketplace/route.ts +52 -0
- package/bin/forge-server.mjs +21 -0
- package/components/PipelineView.tsx +255 -7
- package/components/SettingsModal.tsx +45 -10
- package/components/SkillsPanel.tsx +178 -22
- package/components/WorkspaceView.tsx +3 -1
- package/install.sh +28 -0
- package/lib/agents/index.ts +6 -1
- package/lib/chat/agent-loop.ts +37 -3
- package/lib/chat/llm/anthropic.ts +22 -4
- package/lib/chat/protocols/http.ts +46 -2
- package/lib/chat/tool-dispatcher.ts +21 -3
- package/lib/jobs/recipes.ts +247 -0
- package/lib/jobs/scheduler.ts +17 -2
- package/lib/pipeline.ts +5 -610
- package/lib/settings.ts +6 -0
- package/lib/workflow-marketplace.ts +287 -0
- package/package.json +1 -1
|
@@ -459,6 +459,70 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
|
|
|
459
459
|
const [editorIsConversation, setEditorIsConversation] = useState(false);
|
|
460
460
|
const [showImport, setShowImport] = useState(false);
|
|
461
461
|
const [importYaml, setImportYaml] = useState('');
|
|
462
|
+
const importFileRef = useRef<HTMLInputElement | null>(null);
|
|
463
|
+
|
|
464
|
+
// Marketplace state — pull pipeline templates from the configured
|
|
465
|
+
// workflowRepoUrl (default: aiwatching/forge-workflow). Stays collapsed
|
|
466
|
+
// until the user clicks the header button.
|
|
467
|
+
type MarketRow = {
|
|
468
|
+
kind: 'pipeline'; name: string; display_name: string; description?: string;
|
|
469
|
+
version: string; author?: string; tags?: string[];
|
|
470
|
+
source: 'registry' | 'local'; installed: boolean;
|
|
471
|
+
installed_version?: string; has_update?: boolean;
|
|
472
|
+
};
|
|
473
|
+
const [showMarketplace, setShowMarketplace] = useState(false);
|
|
474
|
+
const [marketRows, setMarketRows] = useState<MarketRow[] | null>(null);
|
|
475
|
+
const [marketBusy, setMarketBusy] = useState(false);
|
|
476
|
+
const [marketErr, setMarketErr] = useState<string>('');
|
|
477
|
+
|
|
478
|
+
async function fetchMarketplace() {
|
|
479
|
+
try {
|
|
480
|
+
const res = await fetch('/api/workflows/marketplace');
|
|
481
|
+
const data = await res.json();
|
|
482
|
+
setMarketRows((data.pipelines || []) as MarketRow[]);
|
|
483
|
+
} catch (e) {
|
|
484
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
485
|
+
setMarketRows([]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function syncMarketplace() {
|
|
490
|
+
setMarketBusy(true);
|
|
491
|
+
setMarketErr('');
|
|
492
|
+
try {
|
|
493
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
494
|
+
method: 'POST',
|
|
495
|
+
headers: { 'Content-Type': 'application/json' },
|
|
496
|
+
body: JSON.stringify({ action: 'sync' }),
|
|
497
|
+
});
|
|
498
|
+
const data = await res.json();
|
|
499
|
+
if (!data.ok) setMarketErr(data.error || 'sync failed');
|
|
500
|
+
await fetchMarketplace();
|
|
501
|
+
} catch (e) {
|
|
502
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
503
|
+
} finally {
|
|
504
|
+
setMarketBusy(false);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function installFromMarketplace(name: string) {
|
|
509
|
+
setMarketBusy(true);
|
|
510
|
+
setMarketErr('');
|
|
511
|
+
try {
|
|
512
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
headers: { 'Content-Type': 'application/json' },
|
|
515
|
+
body: JSON.stringify({ action: 'install', kind: 'pipeline', name }),
|
|
516
|
+
});
|
|
517
|
+
const data = await res.json();
|
|
518
|
+
if (!data.ok) { setMarketErr(data.error || 'install failed'); return; }
|
|
519
|
+
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
522
|
+
} finally {
|
|
523
|
+
setMarketBusy(false);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
462
526
|
const [agents, setAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
|
|
463
527
|
|
|
464
528
|
// "Load older runs" state, per-workflow. The initial /api/pipelines fetch
|
|
@@ -694,19 +758,155 @@ initial_prompt: "{{input.task}}"
|
|
|
694
758
|
onClick={() => { setImportYaml(generateConversationTemplate()); setShowImport(true); }}
|
|
695
759
|
className="text-[9px] text-purple-400 hover:underline"
|
|
696
760
|
>+ Conversation</button>
|
|
761
|
+
<button
|
|
762
|
+
onClick={() => {
|
|
763
|
+
const next = !showMarketplace;
|
|
764
|
+
setShowMarketplace(next);
|
|
765
|
+
if (next && marketRows == null) void fetchMarketplace();
|
|
766
|
+
}}
|
|
767
|
+
className="text-[9px] text-blue-400 hover:underline"
|
|
768
|
+
title="Import a workflow from the marketplace as a new local copy"
|
|
769
|
+
>+ From marketplace</button>
|
|
697
770
|
</div>
|
|
698
771
|
|
|
772
|
+
{/* Marketplace import — each pipeline in the registry can be
|
|
773
|
+
cloned to as many local copies as you want, each with a
|
|
774
|
+
unique name. The local copy is fully independent of the
|
|
775
|
+
upstream — edits don't propagate either way. */}
|
|
776
|
+
{showMarketplace && (
|
|
777
|
+
<div className="p-3 border-b border-[var(--border)] space-y-2">
|
|
778
|
+
<div className="flex items-center gap-2 mb-1">
|
|
779
|
+
<span className="text-[10px] font-semibold text-[var(--text-secondary)] flex-1">
|
|
780
|
+
Pipelines from forge-workflow
|
|
781
|
+
</span>
|
|
782
|
+
<button
|
|
783
|
+
onClick={() => void syncMarketplace()}
|
|
784
|
+
disabled={marketBusy}
|
|
785
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
786
|
+
>{marketBusy ? '…' : 'Sync'}</button>
|
|
787
|
+
</div>
|
|
788
|
+
{marketRows == null ? (
|
|
789
|
+
<div className="text-[10px] text-[var(--text-secondary)]">Loading…</div>
|
|
790
|
+
) : marketRows.length === 0 ? (
|
|
791
|
+
<div className="text-[10px] text-[var(--text-secondary)]">
|
|
792
|
+
Nothing yet — click <b>Sync</b> to pull the registry.
|
|
793
|
+
</div>
|
|
794
|
+
) : (
|
|
795
|
+
<div className="space-y-1.5 max-h-80 overflow-y-auto">
|
|
796
|
+
{marketRows.map((r) => (
|
|
797
|
+
<div key={r.name} className="border border-[var(--border)]/60 rounded p-2 bg-[var(--bg-tertiary)]/40">
|
|
798
|
+
<div className="flex items-baseline gap-1.5">
|
|
799
|
+
<span className="text-[10px] font-semibold text-[var(--text-primary)] truncate flex-1">{r.display_name}</span>
|
|
800
|
+
<span className="text-[8px] text-[var(--text-secondary)] font-mono">v{r.version}</span>
|
|
801
|
+
{r.installed && (
|
|
802
|
+
<span className="text-[8px] text-[var(--green)]">installed</span>
|
|
803
|
+
)}
|
|
804
|
+
{r.installed && (
|
|
805
|
+
<button
|
|
806
|
+
onClick={async () => {
|
|
807
|
+
if (!confirm(`Reinstall "${r.name}" from registry (v${r.version})?\n\nThis OVERWRITES your local copy at ~/.forge/data/flows/${r.name}.yaml — any local edits will be lost.`)) return;
|
|
808
|
+
setMarketBusy(true);
|
|
809
|
+
setMarketErr('');
|
|
810
|
+
try {
|
|
811
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
812
|
+
method: 'POST',
|
|
813
|
+
headers: { 'Content-Type': 'application/json' },
|
|
814
|
+
body: JSON.stringify({ action: 'install', kind: 'pipeline', name: r.name, target_name: r.name, overwrite: true }),
|
|
815
|
+
});
|
|
816
|
+
const data = await res.json();
|
|
817
|
+
if (!data.ok) { setMarketErr(data.error || 'reinstall failed'); return; }
|
|
818
|
+
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
819
|
+
alert(`"${r.name}" reinstalled from registry (v${r.version}).`);
|
|
820
|
+
} catch (e) {
|
|
821
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
822
|
+
} finally { setMarketBusy(false); }
|
|
823
|
+
}}
|
|
824
|
+
disabled={marketBusy}
|
|
825
|
+
className="text-[8px] px-2 py-0.5 border border-[var(--yellow)] text-[var(--yellow)] rounded hover:bg-[var(--yellow)] hover:text-black disabled:opacity-50"
|
|
826
|
+
title="Overwrite the local copy with the latest from registry"
|
|
827
|
+
>Reinstall</button>
|
|
828
|
+
)}
|
|
829
|
+
<button
|
|
830
|
+
onClick={async () => {
|
|
831
|
+
const installed = r.installed;
|
|
832
|
+
const defaultName = installed ? `${r.name}-2` : r.name;
|
|
833
|
+
const localName = window.prompt(
|
|
834
|
+
installed
|
|
835
|
+
? `"${r.name}" is already installed locally. Pick a NEW name for an additional copy (or click Reinstall to overwrite):`
|
|
836
|
+
: `Create a local copy of "${r.name}". Pick a name for your copy:`,
|
|
837
|
+
defaultName,
|
|
838
|
+
);
|
|
839
|
+
if (!localName) return;
|
|
840
|
+
const trimmed = localName.trim();
|
|
841
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(trimmed)) {
|
|
842
|
+
alert('Name must be lowercase alphanumerics + hyphens/underscores');
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
setMarketBusy(true);
|
|
846
|
+
setMarketErr('');
|
|
847
|
+
try {
|
|
848
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
849
|
+
method: 'POST',
|
|
850
|
+
headers: { 'Content-Type': 'application/json' },
|
|
851
|
+
body: JSON.stringify({ action: 'install', kind: 'pipeline', name: r.name, target_name: trimmed }),
|
|
852
|
+
});
|
|
853
|
+
const data = await res.json();
|
|
854
|
+
if (!data.ok) { setMarketErr(data.error || 'import failed'); return; }
|
|
855
|
+
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
856
|
+
alert(`Imported as "${data.installed_as}". Open it from the Workflows list.`);
|
|
857
|
+
} catch (e) {
|
|
858
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
859
|
+
} finally { setMarketBusy(false); }
|
|
860
|
+
}}
|
|
861
|
+
disabled={marketBusy}
|
|
862
|
+
className="text-[8px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
|
|
863
|
+
>{r.installed ? 'Import as new copy…' : 'Import as…'}</button>
|
|
864
|
+
</div>
|
|
865
|
+
{r.description && (
|
|
866
|
+
<p className="text-[9px] text-[var(--text-secondary)] mt-1 line-clamp-3">{r.description}</p>
|
|
867
|
+
)}
|
|
868
|
+
{r.tags && r.tags.length > 0 && (
|
|
869
|
+
<div className="text-[8px] text-[var(--text-secondary)] mt-1">
|
|
870
|
+
{r.tags.map((t) => `#${t}`).join(' ')}
|
|
871
|
+
</div>
|
|
872
|
+
)}
|
|
873
|
+
</div>
|
|
874
|
+
))}
|
|
875
|
+
</div>
|
|
876
|
+
)}
|
|
877
|
+
{marketErr && (
|
|
878
|
+
<div className="text-[10px] text-[var(--red)]">{marketErr}</div>
|
|
879
|
+
)}
|
|
880
|
+
</div>
|
|
881
|
+
)}
|
|
882
|
+
|
|
699
883
|
{/* Import form */}
|
|
700
884
|
{showImport && (
|
|
701
885
|
<div className="p-3 border-b border-[var(--border)] space-y-2">
|
|
886
|
+
<input
|
|
887
|
+
ref={importFileRef}
|
|
888
|
+
type="file"
|
|
889
|
+
accept=".yaml,.yml"
|
|
890
|
+
className="hidden"
|
|
891
|
+
onChange={(e) => {
|
|
892
|
+
const f = e.target.files?.[0];
|
|
893
|
+
e.target.value = '';
|
|
894
|
+
if (!f) return;
|
|
895
|
+
f.text().then(setImportYaml).catch(() => alert('Failed to read file'));
|
|
896
|
+
}}
|
|
897
|
+
/>
|
|
702
898
|
<textarea
|
|
703
899
|
value={importYaml}
|
|
704
900
|
onChange={e => setImportYaml(e.target.value)}
|
|
705
|
-
placeholder="Paste YAML workflow here
|
|
901
|
+
placeholder="Paste YAML workflow here, or pick a .yaml file →"
|
|
706
902
|
className="w-full h-40 text-xs font-mono bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-2 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)]"
|
|
707
903
|
spellCheck={false}
|
|
708
904
|
/>
|
|
709
905
|
<div className="flex gap-2">
|
|
906
|
+
<button
|
|
907
|
+
onClick={() => importFileRef.current?.click()}
|
|
908
|
+
className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
|
|
909
|
+
>Pick file…</button>
|
|
710
910
|
<button
|
|
711
911
|
onClick={async () => {
|
|
712
912
|
if (!importYaml.trim()) return;
|
|
@@ -850,6 +1050,45 @@ initial_prompt: "{{input.task}}"
|
|
|
850
1050
|
className="text-[8px] text-green-400 hover:underline shrink-0"
|
|
851
1051
|
title={w.builtin ? 'View YAML' : 'Edit'}
|
|
852
1052
|
>{w.builtin ? 'View' : 'Edit'}</button>
|
|
1053
|
+
<button
|
|
1054
|
+
onClick={async (e) => {
|
|
1055
|
+
e.stopPropagation();
|
|
1056
|
+
try {
|
|
1057
|
+
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
|
|
1058
|
+
const data = await res.json();
|
|
1059
|
+
if (!data.yaml) { alert('Failed to load yaml'); return; }
|
|
1060
|
+
// Trigger a file download — share = send this .yaml file.
|
|
1061
|
+
const blob = new Blob([data.yaml], { type: 'application/x-yaml' });
|
|
1062
|
+
const url = URL.createObjectURL(blob);
|
|
1063
|
+
const a = document.createElement('a');
|
|
1064
|
+
a.href = url; a.download = `${w.name}.yaml`; a.click();
|
|
1065
|
+
URL.revokeObjectURL(url);
|
|
1066
|
+
} catch { alert('Export failed'); }
|
|
1067
|
+
}}
|
|
1068
|
+
className="text-[8px] text-blue-400 hover:underline shrink-0"
|
|
1069
|
+
title="Download YAML to share"
|
|
1070
|
+
>Export</button>
|
|
1071
|
+
{!w.builtin && (
|
|
1072
|
+
<button
|
|
1073
|
+
onClick={async (e) => {
|
|
1074
|
+
e.stopPropagation();
|
|
1075
|
+
if (!confirm(`Delete workflow "${w.name}"? This removes the .yaml file from disk.`)) return;
|
|
1076
|
+
try {
|
|
1077
|
+
const res = await fetch('/api/pipelines', {
|
|
1078
|
+
method: 'POST',
|
|
1079
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1080
|
+
body: JSON.stringify({ action: 'delete-workflow', name: w.name }),
|
|
1081
|
+
});
|
|
1082
|
+
const data = await res.json();
|
|
1083
|
+
if (!res.ok || data.error) { alert(`Delete failed: ${data.error || res.status}`); return; }
|
|
1084
|
+
if (activeWorkflow === w.name) setActiveWorkflow(null);
|
|
1085
|
+
fetchData();
|
|
1086
|
+
} catch { alert('Delete failed'); }
|
|
1087
|
+
}}
|
|
1088
|
+
className="text-[8px] text-[var(--red)] hover:underline shrink-0"
|
|
1089
|
+
title="Delete this user workflow"
|
|
1090
|
+
>Del</button>
|
|
1091
|
+
)}
|
|
853
1092
|
</div>
|
|
854
1093
|
{/* Execution history for this workflow */}
|
|
855
1094
|
{isActive && (() => {
|
|
@@ -1018,11 +1257,15 @@ initial_prompt: "{{input.task}}"
|
|
|
1018
1257
|
Started: {new Date(selectedPipeline.createdAt).toLocaleString()}
|
|
1019
1258
|
{selectedPipeline.completedAt && ` · Completed: ${new Date(selectedPipeline.completedAt).toLocaleString()}`}
|
|
1020
1259
|
</div>
|
|
1021
|
-
{Object.keys(selectedPipeline.input).length > 0 && (
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1260
|
+
{Object.keys(selectedPipeline.input).length > 0 && (() => {
|
|
1261
|
+
const inputStr = Object.entries(selectedPipeline.input).map(([k, v]) => `${k}="${v}"`).join(', ');
|
|
1262
|
+
return (
|
|
1263
|
+
<div
|
|
1264
|
+
className="text-[9px] text-[var(--text-secondary)] mt-1 max-h-20 overflow-y-auto whitespace-pre-wrap break-words"
|
|
1265
|
+
title={inputStr}
|
|
1266
|
+
>Input: {inputStr}</div>
|
|
1267
|
+
);
|
|
1268
|
+
})()}
|
|
1026
1269
|
</div>
|
|
1027
1270
|
|
|
1028
1271
|
{/* Conversation or DAG visualization */}
|
|
@@ -1088,7 +1331,12 @@ initial_prompt: "{{input.task}}"
|
|
|
1088
1331
|
>{w.builtin ? 'View YAML' : 'Edit'}</button>
|
|
1089
1332
|
</div>
|
|
1090
1333
|
</div>
|
|
1091
|
-
{w.description &&
|
|
1334
|
+
{w.description && (
|
|
1335
|
+
<p
|
|
1336
|
+
className="text-[10px] text-[var(--text-secondary)] mt-1 max-h-20 overflow-y-auto whitespace-pre-wrap break-words"
|
|
1337
|
+
title={w.description}
|
|
1338
|
+
>{w.description}</p>
|
|
1339
|
+
)}
|
|
1092
1340
|
{Object.keys(w.input).length > 0 && (
|
|
1093
1341
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
1094
1342
|
{Object.entries(w.input).map(([k, v]) => (
|
|
@@ -860,9 +860,10 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
|
|
|
860
860
|
<select value={cfg.provider || 'anthropic'} onChange={e => onUpdate({ ...cfg, provider: e.target.value })} className={inputClass}>
|
|
861
861
|
<option value="anthropic">Anthropic</option>
|
|
862
862
|
<option value="openai">OpenAI</option>
|
|
863
|
+
<option value="grok">Grok / xAI</option>
|
|
864
|
+
<option value="google">Google / Gemini</option>
|
|
865
|
+
<option value="deepseek">DeepSeek</option>
|
|
863
866
|
<option value="litellm">LiteLLM (OpenAI-compatible proxy)</option>
|
|
864
|
-
<option value="grok">Grok</option>
|
|
865
|
-
<option value="google">Google</option>
|
|
866
867
|
</select>
|
|
867
868
|
</div>
|
|
868
869
|
<div className="flex-1">
|
|
@@ -871,11 +872,28 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
|
|
|
871
872
|
</div>
|
|
872
873
|
</div>
|
|
873
874
|
<div>
|
|
874
|
-
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
875
|
+
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
876
|
+
Base URL {(() => {
|
|
877
|
+
const p = (cfg.provider || '').toLowerCase();
|
|
878
|
+
if (p === 'litellm') return '— required (LiteLLM / proxy)';
|
|
879
|
+
if (p === '' || p === 'other') return '— optional';
|
|
880
|
+
return '— optional, leave empty to use the provider default below';
|
|
881
|
+
})()}
|
|
882
|
+
</label>
|
|
875
883
|
<input
|
|
876
884
|
value={cfg.baseUrl || ''}
|
|
877
885
|
onChange={e => onUpdate({ ...cfg, baseUrl: e.target.value })}
|
|
878
|
-
placeholder={
|
|
886
|
+
placeholder={(() => {
|
|
887
|
+
switch ((cfg.provider || '').toLowerCase()) {
|
|
888
|
+
case 'anthropic': return 'https://api.anthropic.com (default)';
|
|
889
|
+
case 'openai': return 'https://api.openai.com/v1 (default)';
|
|
890
|
+
case 'grok': case 'xai': return 'https://api.x.ai/v1 (default)';
|
|
891
|
+
case 'google': case 'gemini': return 'https://generativelanguage.googleapis.com/v1beta/openai (default)';
|
|
892
|
+
case 'deepseek': return 'https://api.deepseek.com (default)';
|
|
893
|
+
case 'litellm': return 'http://127.0.0.1:4000/v1';
|
|
894
|
+
default: return 'https://...';
|
|
895
|
+
}
|
|
896
|
+
})()}
|
|
879
897
|
className={inputClass + ' font-mono'}
|
|
880
898
|
/>
|
|
881
899
|
</div>
|
|
@@ -1085,9 +1103,10 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1085
1103
|
<select value={provider} onChange={e => setProvider(e.target.value)} className={inputClass}>
|
|
1086
1104
|
<option value="anthropic">Anthropic</option>
|
|
1087
1105
|
<option value="openai">OpenAI</option>
|
|
1106
|
+
<option value="grok">Grok / xAI</option>
|
|
1107
|
+
<option value="google">Google / Gemini</option>
|
|
1108
|
+
<option value="deepseek">DeepSeek</option>
|
|
1088
1109
|
<option value="litellm">LiteLLM (OpenAI-compatible proxy)</option>
|
|
1089
|
-
<option value="grok">Grok</option>
|
|
1090
|
-
<option value="google">Google</option>
|
|
1091
1110
|
</select>
|
|
1092
1111
|
</div>
|
|
1093
1112
|
<div className="flex-1">
|
|
@@ -1100,11 +1119,23 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1100
1119
|
<input type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder="sk-..." className={inputClass} />
|
|
1101
1120
|
</div>
|
|
1102
1121
|
<div>
|
|
1103
|
-
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
1122
|
+
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
1123
|
+
Base URL {provider === 'litellm' ? '(required)' : '(optional — leave empty for provider default)'}
|
|
1124
|
+
</label>
|
|
1104
1125
|
<input
|
|
1105
1126
|
value={baseUrl}
|
|
1106
1127
|
onChange={e => setBaseUrl(e.target.value)}
|
|
1107
|
-
placeholder={
|
|
1128
|
+
placeholder={(() => {
|
|
1129
|
+
switch (provider) {
|
|
1130
|
+
case 'anthropic': return 'https://api.anthropic.com (default)';
|
|
1131
|
+
case 'openai': return 'https://api.openai.com/v1 (default)';
|
|
1132
|
+
case 'grok': return 'https://api.x.ai/v1 (default)';
|
|
1133
|
+
case 'google': return 'https://generativelanguage.googleapis.com/v1beta/openai (default)';
|
|
1134
|
+
case 'deepseek': return 'https://api.deepseek.com (default)';
|
|
1135
|
+
case 'litellm': return 'http://127.0.0.1:4000/v1';
|
|
1136
|
+
default: return 'https://...';
|
|
1137
|
+
}
|
|
1138
|
+
})()}
|
|
1108
1139
|
className={inputClass + ' font-mono'}
|
|
1109
1140
|
/>
|
|
1110
1141
|
</div>
|
|
@@ -1175,9 +1206,13 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1175
1206
|
});
|
|
1176
1207
|
}
|
|
1177
1208
|
|
|
1178
|
-
// Add configured but not detected agents
|
|
1209
|
+
// Add configured but not detected agents. Skip rows that are
|
|
1210
|
+
// profiles (CLI profile with `base`, or API profile with
|
|
1211
|
+
// `type: 'api'`) — those have their own Profiles section below
|
|
1212
|
+
// and don't belong in the Agents list.
|
|
1179
1213
|
for (const [id, cfg] of Object.entries(configured) as [string, any][]) {
|
|
1180
1214
|
if (merged.find(a => a.id === id)) continue;
|
|
1215
|
+
if (cfg.type === 'api' || cfg.base) continue;
|
|
1181
1216
|
merged.push({
|
|
1182
1217
|
id,
|
|
1183
1218
|
name: cfg.name ?? id,
|
|
@@ -1751,7 +1786,7 @@ function ChatAgentSelect({ settings, setSettings }: { settings: any; setSettings
|
|
|
1751
1786
|
<div className="mt-4 pt-3 border-t border-[var(--border)]">
|
|
1752
1787
|
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase mb-2 block">Chat backend</label>
|
|
1753
1788
|
<div className="flex items-center gap-2">
|
|
1754
|
-
<span className="text-[9px] text-[var(--text-secondary)]">Default chat
|
|
1789
|
+
<span className="text-[9px] text-[var(--text-secondary)]">Default chat profile:</span>
|
|
1755
1790
|
<select
|
|
1756
1791
|
value={settings.chatAgent || ''}
|
|
1757
1792
|
onChange={e => setSettings({ ...settings, chatAgent: e.target.value })}
|