mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(ai): add Codex CLI as a no-API-key AI provider for Desktop
* codex cli provider * fixes * mcp check * reasoning * simplify * minor fixes
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -405,7 +405,7 @@ function agentEventToStep(event: AgentEvent, index: number): AiAgentStepItem | u
|
||||
|
||||
if (event.type !== "tool_call_start" && event.type !== "tool_call_end") return undefined;
|
||||
|
||||
const isExecuteQuery = event.tool_name === "execute_query";
|
||||
const isExecuteQuery = event.tool_name === "execute_query" || event.tool_name === "dbx_execute_query";
|
||||
const labelKey = event.type === "tool_call_start" ? "ai.agentSteps.callingTool" : isExecuteQuery ? (event.is_error ? "ai.agentSteps.executeBlocked" : "ai.agentSteps.executeSafe") : event.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone";
|
||||
const tone = (event.type === "tool_call_start" ? "active" : event.is_error ? "danger" : "success") as AiAgentStepTone;
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type AiProvider,
|
||||
type AiApiStyle,
|
||||
type AiAuthMethod,
|
||||
type AiReasoningLevel,
|
||||
type EditorTheme,
|
||||
type DesktopIconTheme,
|
||||
type InterfaceLayout,
|
||||
@@ -1018,6 +1019,7 @@ watch(
|
||||
await refreshWebDavPasswordStatus();
|
||||
syncAiEditState();
|
||||
if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus();
|
||||
if (!isWeb && activeSettingsTab.value === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -1032,6 +1034,7 @@ watch(webdavRememberPassword, (val) => {
|
||||
|
||||
watch(activeSettingsTab, (tab) => {
|
||||
if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus();
|
||||
if (tab === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus();
|
||||
if (tab === "appearance") {
|
||||
checkLayoutDescTruncation();
|
||||
checkIconThemeDescTruncation();
|
||||
@@ -1085,7 +1088,7 @@ async function changePassword() {
|
||||
}
|
||||
|
||||
// ---------- AI Settings ----------
|
||||
const aiProviderOptions = Object.values(AI_PROVIDER_PRESETS);
|
||||
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || provider.provider !== "codex-cli"));
|
||||
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
|
||||
|
||||
const aiEditProvider = ref<AiProvider>(settingsStore.aiConfig.provider);
|
||||
@@ -1097,7 +1100,9 @@ const aiEditApiStyle = ref<AiApiStyle>(settingsStore.aiConfig.apiStyle || "compl
|
||||
const aiEditProxyEnabled = ref(!!settingsStore.aiConfig.proxyEnabled);
|
||||
const aiEditProxyUrl = ref(settingsStore.aiConfig.proxyUrl || "");
|
||||
const aiEditEnableThinking = ref(settingsStore.aiConfig.enableThinking ?? true);
|
||||
const aiEditReasoningLevel = ref<AiReasoningLevel>(settingsStore.aiConfig.reasoningLevel || "default");
|
||||
const aiEditContextWindow = ref<number | undefined>(settingsStore.aiConfig.contextWindow);
|
||||
const aiEditCodexCliPath = ref(settingsStore.aiConfig.codexCliPath || "");
|
||||
|
||||
const aiModelOptions = ref<AiModelInfo[]>([]);
|
||||
const aiModelLoading = ref(false);
|
||||
@@ -1106,11 +1111,23 @@ const aiModelLoadedSignature = ref("");
|
||||
let aiModelRequestToken = 0;
|
||||
|
||||
const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions");
|
||||
const aiReasoningLevelOptions: Array<{ value: AiReasoningLevel; labelKey: string }> = [
|
||||
{ value: "default", labelKey: "ai.reasoningLevelDefault" },
|
||||
{ value: "minimal", labelKey: "ai.reasoningLevelMinimal" },
|
||||
{ value: "low", labelKey: "ai.reasoningLevelLow" },
|
||||
{ value: "medium", labelKey: "ai.reasoningLevelMedium" },
|
||||
{ value: "high", labelKey: "ai.reasoningLevelHigh" },
|
||||
];
|
||||
|
||||
const aiTesting = ref(false);
|
||||
const aiTestResult = ref<"" | "success" | "error">("");
|
||||
const aiTestError = ref("");
|
||||
const aiTestLatency = ref<number | null>(null);
|
||||
const aiTestErrorCopied = ref(false);
|
||||
const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli");
|
||||
watch(aiIsCodexCli, (isCodex) => {
|
||||
if (isCodex) void ensureCodexMcpStatus();
|
||||
});
|
||||
const aiRequiresApiKey = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value].requiresApiKey);
|
||||
const aiSupportsAuthMethod = computed(() => aiEditProvider.value === "claude");
|
||||
const aiCredentialLabel = computed(() => (aiSupportsAuthMethod.value && aiEditAuthMethod.value === "bearer" ? "Auth Token" : "API Key"));
|
||||
@@ -1131,9 +1148,19 @@ const aiEndpointHint = computed(() => {
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const aiSupportsApiStyle = computed(() => aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom");
|
||||
const aiSupportsApiStyle = computed(() => !aiIsCodexCli.value && (aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom"));
|
||||
const aiCodexMcpNeedsInstall = computed(() => aiIsCodexCli.value && (!mcpStatus.value || !mcpStatus.value.installed));
|
||||
const aiCodexMcpCanInstall = computed(() => {
|
||||
const status = mcpStatus.value;
|
||||
return !mcpInstalling.value && !!status?.npm_available && (!status.installed || status.update_available);
|
||||
});
|
||||
const aiCodexMcpActionLabel = computed(() => {
|
||||
if (!mcpStatus.value?.installed) return t("settings.mcpInstallButton");
|
||||
if (mcpStatus.value.update_available) return t("settings.mcpUpdateButton");
|
||||
return t("settings.mcpUpToDate");
|
||||
});
|
||||
const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini");
|
||||
const aiCanListModels = computed(() => aiModelListSupported.value && !!aiEditEndpoint.value.trim() && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
|
||||
const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCodexCli.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
|
||||
const aiModelOptionIds = computed(() => aiModelOptions.value.map((model) => model.id));
|
||||
const aiModelEmptyText = computed(() => {
|
||||
if (aiModelError.value) return aiModelError.value;
|
||||
@@ -1157,6 +1184,7 @@ function aiModelConfigSignature() {
|
||||
authMethod: aiEditAuthMethod.value,
|
||||
proxyEnabled: aiEditProxyEnabled.value,
|
||||
proxyUrl: aiEditProxyUrl.value.trim(),
|
||||
codexCliPath: aiEditCodexCliPath.value.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1171,7 +1199,9 @@ function currentAiEditConfig() {
|
||||
proxyEnabled: aiEditProxyEnabled.value,
|
||||
proxyUrl: aiEditProxyUrl.value,
|
||||
enableThinking: aiEditEnableThinking.value,
|
||||
reasoningLevel: aiEditReasoningLevel.value,
|
||||
contextWindow: aiEditContextWindow.value || undefined,
|
||||
codexCliPath: aiEditCodexCliPath.value.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1197,7 +1227,7 @@ async function aiRefreshModels() {
|
||||
aiModelError.value = t("ai.modelListUnsupported");
|
||||
return;
|
||||
}
|
||||
if (!aiEditEndpoint.value.trim()) {
|
||||
if (!aiIsCodexCli.value && !aiEditEndpoint.value.trim()) {
|
||||
aiModelError.value = t("ai.modelListEndpointRequired");
|
||||
return;
|
||||
}
|
||||
@@ -1236,33 +1266,42 @@ function aiSelectModel(modelId: string) {
|
||||
}
|
||||
|
||||
function syncAiEditState() {
|
||||
aiEditProvider.value = settingsStore.aiConfig.provider;
|
||||
const provider = isWeb && settingsStore.aiConfig.provider === "codex-cli" ? "claude" : settingsStore.aiConfig.provider;
|
||||
aiEditProvider.value = provider;
|
||||
aiEditApiKey.value = settingsStore.aiConfig.apiKey;
|
||||
aiEditAuthMethod.value = settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[settingsStore.aiConfig.provider].authMethod;
|
||||
aiEditEndpoint.value = settingsStore.aiConfig.endpoint;
|
||||
aiEditModel.value = settingsStore.aiConfig.model;
|
||||
aiEditApiStyle.value = settingsStore.aiConfig.apiStyle || "completions";
|
||||
aiEditAuthMethod.value = settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[provider].authMethod;
|
||||
aiEditEndpoint.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.endpoint : AI_PROVIDER_PRESETS[provider].endpoint;
|
||||
aiEditModel.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.model : AI_PROVIDER_PRESETS[provider].model;
|
||||
aiEditApiStyle.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.apiStyle || "completions" : AI_PROVIDER_PRESETS[provider].apiStyle;
|
||||
aiEditProxyEnabled.value = !!settingsStore.aiConfig.proxyEnabled;
|
||||
aiEditProxyUrl.value = settingsStore.aiConfig.proxyUrl || "";
|
||||
aiEditEnableThinking.value = settingsStore.aiConfig.enableThinking ?? true;
|
||||
aiEditReasoningLevel.value = settingsStore.aiConfig.reasoningLevel || "default";
|
||||
aiEditContextWindow.value = settingsStore.aiConfig.contextWindow;
|
||||
aiEditCodexCliPath.value = settingsStore.aiConfig.codexCliPath || "";
|
||||
aiTestResult.value = "";
|
||||
aiTestError.value = "";
|
||||
aiTestLatency.value = null;
|
||||
aiTestErrorCopied.value = false;
|
||||
clearAiModelOptions();
|
||||
}
|
||||
|
||||
function aiSelectProvider(provider: AiProvider) {
|
||||
if (isWeb && provider === "codex-cli") return;
|
||||
aiEditProvider.value = provider;
|
||||
aiEditEndpoint.value = AI_PROVIDER_PRESETS[provider].endpoint;
|
||||
aiEditModel.value = AI_PROVIDER_PRESETS[provider].model;
|
||||
aiEditApiStyle.value = AI_PROVIDER_PRESETS[provider].apiStyle;
|
||||
aiEditAuthMethod.value = AI_PROVIDER_PRESETS[provider].authMethod;
|
||||
aiEditReasoningLevel.value = "default";
|
||||
if (!AI_PROVIDER_PRESETS[provider].requiresApiKey) aiEditApiKey.value = "";
|
||||
aiEditCodexCliPath.value = "";
|
||||
aiTestResult.value = "";
|
||||
aiTestError.value = "";
|
||||
aiTestLatency.value = null;
|
||||
aiTestErrorCopied.value = false;
|
||||
clearAiModelOptions();
|
||||
if (provider === "codex-cli") void ensureCodexMcpStatus();
|
||||
}
|
||||
|
||||
function aiHasChanges(): boolean {
|
||||
@@ -1276,7 +1315,9 @@ function aiHasChanges(): boolean {
|
||||
aiEditProxyEnabled.value !== !!settingsStore.aiConfig.proxyEnabled ||
|
||||
aiEditProxyUrl.value !== (settingsStore.aiConfig.proxyUrl || "") ||
|
||||
aiEditEnableThinking.value !== (settingsStore.aiConfig.enableThinking ?? true) ||
|
||||
aiEditContextWindow.value !== settingsStore.aiConfig.contextWindow
|
||||
aiEditReasoningLevel.value !== (settingsStore.aiConfig.reasoningLevel || "default") ||
|
||||
aiEditContextWindow.value !== settingsStore.aiConfig.contextWindow ||
|
||||
aiEditCodexCliPath.value !== (settingsStore.aiConfig.codexCliPath || "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1285,11 +1326,12 @@ function aiApplySettings() {
|
||||
}
|
||||
|
||||
async function aiTestConn() {
|
||||
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || !aiEditEndpoint.value.trim() || !aiEditModel.value.trim()) return;
|
||||
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCodexCli.value && !aiEditEndpoint.value.trim()) || (!aiIsCodexCli.value && !aiEditModel.value.trim())) return;
|
||||
aiTesting.value = true;
|
||||
aiTestResult.value = "";
|
||||
aiTestError.value = "";
|
||||
aiTestLatency.value = null;
|
||||
aiTestErrorCopied.value = false;
|
||||
try {
|
||||
const result = await aiTestConnection(currentAiEditConfig());
|
||||
aiTestResult.value = "success";
|
||||
@@ -1302,6 +1344,20 @@ async function aiTestConn() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAiTestError() {
|
||||
if (!aiTestError.value) return;
|
||||
await copyToClipboard(aiTestError.value);
|
||||
aiTestErrorCopied.value = true;
|
||||
window.setTimeout(() => {
|
||||
aiTestErrorCopied.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function ensureCodexMcpStatus() {
|
||||
if (isWeb || activeSettingsTab.value !== "ai" || !aiIsCodexCli.value || mcpStatus.value || mcpStatusLoading.value) return;
|
||||
await refreshMcpStatus();
|
||||
}
|
||||
|
||||
// ---------- CodeMirror preview ----------
|
||||
const previewRef = ref<HTMLDivElement>();
|
||||
const previewView = shallowRef<EditorViewType | null>(null);
|
||||
@@ -2358,7 +2414,40 @@ watch(
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="aiSupportsAuthMethod" class="grid grid-cols-3 items-center gap-3">
|
||||
<div v-if="aiIsCodexCli && !isWeb" class="rounded-md border px-3 py-2.5 text-xs" :class="aiCodexMcpNeedsInstall ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex min-w-0 items-center gap-2 font-medium">
|
||||
<Loader2 v-if="mcpStatusLoading" class="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
<AlertTriangle v-else-if="aiCodexMcpNeedsInstall || mcpStatus?.error || mcpStatusError" class="h-3.5 w-3.5 shrink-0" />
|
||||
<CheckCircle2 v-else class="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{{ t("ai.codexMcpRequiredTitle") }}</span>
|
||||
<Badge variant="outline" class="h-5 shrink-0 rounded-md border-current/30 px-1.5 text-[11px] font-normal">
|
||||
{{ mcpStatusLabel }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="leading-relaxed">
|
||||
{{ t("ai.codexMcpRequiredDescription") }}
|
||||
</p>
|
||||
<p v-if="mcpStatus?.error || mcpStatusError" class="select-text leading-relaxed">
|
||||
{{ mcpStatusError || mcpStatus?.error }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" size="sm" variant="outline" class="h-7 bg-background/80 px-2 text-xs" :disabled="mcpStatusLoading" @click="refreshMcpStatus">
|
||||
<Loader2 v-if="mcpStatusLoading" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<RefreshCw v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("settings.mcpRefresh") }}
|
||||
</Button>
|
||||
<Button v-if="aiCodexMcpNeedsInstall || mcpStatus?.update_available" type="button" size="sm" class="h-7 px-2 text-xs" :disabled="!aiCodexMcpCanInstall" @click="installMcp">
|
||||
<Loader2 v-if="mcpInstalling" class="mr-1 h-3 w-3 animate-spin" />
|
||||
{{ mcpInstalling ? t("settings.mcpInstalling") : aiCodexMcpActionLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!aiIsCodexCli && aiSupportsAuthMethod" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Authentication</Label>
|
||||
<Select v-model="aiEditAuthMethod">
|
||||
<SelectTrigger class="col-span-2" inputClass="h-8 text-xs">
|
||||
@@ -2371,12 +2460,12 @@ watch(
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ aiCredentialLabel }}</Label>
|
||||
<PasswordInput v-model="aiEditApiKey" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" :placeholder="aiCredentialPlaceholder" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">Endpoint</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
<Input v-model="aiEditEndpoint" :placeholder="aiEndpointPlaceholder" autocomplete="off" class="h-8 text-xs" />
|
||||
@@ -2384,6 +2473,14 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.codexCliPath") }}</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
<Input v-model="aiEditCodexCliPath" autocomplete="off" class="h-8 text-xs" placeholder="codex" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("ai.codexCliPathHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.model") }}</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
@@ -2425,6 +2522,23 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.reasoningLevel") }}</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
<Select v-model="aiEditReasoningLevel">
|
||||
<SelectTrigger inputClass="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in aiReasoningLevelOptions" :key="option.value" :value="option.value">
|
||||
{{ t(option.labelKey) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("ai.reasoningLevelHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="aiSupportsApiStyle" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">API</Label>
|
||||
<div class="col-span-2 flex gap-2">
|
||||
@@ -2433,7 +2547,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.enableThinking") }}</Label>
|
||||
<div class="col-span-2 flex items-center gap-2">
|
||||
<label class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
@@ -2451,7 +2565,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.contextWindow") }}</Label>
|
||||
<div class="col-span-2">
|
||||
<Input v-model.number="aiEditContextWindow" type="number" min="1000" step="1000" class="h-8 text-xs" :placeholder="t('ai.contextWindowAuto')" />
|
||||
@@ -2459,7 +2573,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.proxy") }}</Label>
|
||||
<label class="col-span-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input v-model="aiEditProxyEnabled" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
|
||||
@@ -2467,7 +2581,7 @@ watch(
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.proxyUrl") }}</Label>
|
||||
<Input v-model="aiEditProxyUrl" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" placeholder="socks5://127.0.0.1:7890" :disabled="!aiEditProxyEnabled" />
|
||||
</div>
|
||||
@@ -2737,7 +2851,7 @@ watch(
|
||||
|
||||
<DialogFooter v-else-if="activeSettingsTab === 'ai'" class="mx-0 mb-0 shrink-0 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 gap-3 sm:gap-3">
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<Button size="sm" variant="outline" :disabled="aiTesting || (aiRequiresApiKey && !aiEditApiKey?.trim()) || !aiEditEndpoint?.trim() || !aiEditModel?.trim()" @click="aiTestConn">
|
||||
<Button size="sm" variant="outline" :disabled="aiTesting || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCodexCli && !aiEditEndpoint?.trim()) || (!aiIsCodexCli && !aiEditModel?.trim())" @click="aiTestConn">
|
||||
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
|
||||
{{ t("connection.test") }}
|
||||
</Button>
|
||||
@@ -2745,8 +2859,20 @@ watch(
|
||||
<span>{{ t("connection.testSuccess") }}</span>
|
||||
<span v-if="aiTestLatency != null" class="text-green-500/70">{{ aiTestLatency }}ms</span>
|
||||
</span>
|
||||
<span v-else-if="aiTestResult === 'error'" class="text-xs text-destructive truncate max-w-[200px]" :title="aiTestError">
|
||||
{{ aiTestError }}
|
||||
<span v-else-if="aiTestResult === 'error'" class="min-w-0 max-w-[360px] flex items-center gap-1.5 text-xs text-destructive">
|
||||
<span class="select-text truncate" :title="aiTestError">{{ aiTestError }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0 text-destructive/80 hover:text-destructive"
|
||||
:title="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
|
||||
:aria-label="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
|
||||
@click="copyAiTestError"
|
||||
>
|
||||
<CheckCircle2 v-if="aiTestErrorCopied" class="h-3.5 w-3.5" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" @click="emit('update:open', false)">{{ t("common.close") }}</Button>
|
||||
|
||||
@@ -1020,6 +1020,17 @@ export default {
|
||||
contextWindow: "Context Window",
|
||||
contextWindowAuto: "Auto (detect from model name)",
|
||||
contextWindowHint: "Tokens. Leave empty to auto-detect. Set manually for local/custom models.",
|
||||
codexCliPath: "Codex CLI Path",
|
||||
codexCliPathHint: "Leave empty to use codex from PATH. Sign in separately with codex login.",
|
||||
codexMcpRequiredTitle: "DBX MCP Server required",
|
||||
codexMcpRequiredDescription: "Codex CLI uses the DBX MCP Server to access database schema and query tools. Install it before using Codex in the AI assistant.",
|
||||
reasoningLevel: "Reasoning level",
|
||||
reasoningLevelDefault: "Default",
|
||||
reasoningLevelMinimal: "Minimal",
|
||||
reasoningLevelLow: "Low",
|
||||
reasoningLevelMedium: "Medium",
|
||||
reasoningLevelHigh: "High",
|
||||
reasoningLevelHint: "Controls Codex CLI model_reasoning_effort. Default uses your Codex config.",
|
||||
actions: {
|
||||
generate: "Generate SQL",
|
||||
explain: "Explain SQL",
|
||||
|
||||
@@ -827,6 +827,15 @@ export default {
|
||||
modelListUnsupported: "Este proveedor no admite obtener una lista de modelos.",
|
||||
modelListEndpointRequired: "Ingresa un endpoint antes de cargar modelos.",
|
||||
modelListApiKeyRequired: "Ingresa una clave API antes de cargar modelos.",
|
||||
codexMcpRequiredTitle: "Se requiere DBX MCP Server",
|
||||
codexMcpRequiredDescription: "Codex CLI usa DBX MCP Server para acceder al esquema de la base de datos y a las herramientas de consulta. Instálalo antes de usar Codex en el asistente de IA.",
|
||||
reasoningLevel: "Nivel de razonamiento",
|
||||
reasoningLevelDefault: "Predeterminado",
|
||||
reasoningLevelMinimal: "Mínimo",
|
||||
reasoningLevelLow: "Bajo",
|
||||
reasoningLevelMedium: "Medio",
|
||||
reasoningLevelHigh: "Alto",
|
||||
reasoningLevelHint: "Controla model_reasoning_effort de Codex CLI. Predeterminado usa tu configuración de Codex.",
|
||||
run: "Ejecutar",
|
||||
readingSchema: "Leyendo esquema",
|
||||
noConnection: "No hay una conexión disponible para esta pestaña",
|
||||
|
||||
@@ -888,6 +888,15 @@ export default {
|
||||
modelListUnsupported: "Questo provider non supporta il recupero di un elenco di modelli.",
|
||||
modelListEndpointRequired: "Inserisci un endpoint prima di caricare i modelli.",
|
||||
modelListApiKeyRequired: "Inserisci una chiave API prima di caricare i modelli.",
|
||||
codexMcpRequiredTitle: "DBX MCP Server richiesto",
|
||||
codexMcpRequiredDescription: "Codex CLI usa il DBX MCP Server per accedere allo schema del database e agli strumenti di query. Installalo prima di usare Codex nell'assistente AI.",
|
||||
reasoningLevel: "Livello di ragionamento",
|
||||
reasoningLevelDefault: "Predefinito",
|
||||
reasoningLevelMinimal: "Minimo",
|
||||
reasoningLevelLow: "Basso",
|
||||
reasoningLevelMedium: "Medio",
|
||||
reasoningLevelHigh: "Alto",
|
||||
reasoningLevelHint: "Controlla model_reasoning_effort di Codex CLI. Predefinito usa la configurazione Codex.",
|
||||
run: "Esegui",
|
||||
readingSchema: "Lettura schema",
|
||||
noConnection: "Nessuna connessione disponibile per questa scheda",
|
||||
|
||||
@@ -940,6 +940,15 @@ export default {
|
||||
modelListUnsupported: "このプロバイダーはモデル一覧の取得をサポートしていません。",
|
||||
modelListEndpointRequired: "モデルを読み込む前にエンドポイントを入力してください。",
|
||||
modelListApiKeyRequired: "モデルを読み込む前にAPIキーを入力してください。",
|
||||
codexMcpRequiredTitle: "DBX MCP Serverが必要です",
|
||||
codexMcpRequiredDescription: "Codex CLIはDBX MCP Server経由でデータベーススキーマとクエリツールにアクセスします。AIアシスタントでCodexを使う前にインストールしてください。",
|
||||
reasoningLevel: "推論レベル",
|
||||
reasoningLevelDefault: "デフォルト",
|
||||
reasoningLevelMinimal: "最小",
|
||||
reasoningLevelLow: "低",
|
||||
reasoningLevelMedium: "中",
|
||||
reasoningLevelHigh: "高",
|
||||
reasoningLevelHint: "Codex CLIのmodel_reasoning_effortを制御します。デフォルトではCodex設定を使用します。",
|
||||
run: "実行",
|
||||
readingSchema: "スキーマを読み取り中",
|
||||
noConnection: "このタブに利用可能な接続がありません",
|
||||
|
||||
@@ -888,6 +888,15 @@ export default {
|
||||
modelListUnsupported: "Este provedor não oferece suporte à obtenção de uma lista de modelos.",
|
||||
modelListEndpointRequired: "Informe um endpoint antes de carregar os modelos.",
|
||||
modelListApiKeyRequired: "Informe uma API key antes de carregar os modelos.",
|
||||
codexMcpRequiredTitle: "DBX MCP Server obrigatório",
|
||||
codexMcpRequiredDescription: "O Codex CLI usa o DBX MCP Server para acessar o schema do banco e as ferramentas de consulta. Instale-o antes de usar o Codex no assistente de AI.",
|
||||
reasoningLevel: "Nível de raciocínio",
|
||||
reasoningLevelDefault: "Padrão",
|
||||
reasoningLevelMinimal: "Mínimo",
|
||||
reasoningLevelLow: "Baixo",
|
||||
reasoningLevelMedium: "Médio",
|
||||
reasoningLevelHigh: "Alto",
|
||||
reasoningLevelHint: "Controla model_reasoning_effort do Codex CLI. Padrão usa sua configuração do Codex.",
|
||||
run: "Executar",
|
||||
readingSchema: "Lendo schema",
|
||||
noConnection: "Nenhuma conexão disponível para esta aba",
|
||||
|
||||
@@ -1019,6 +1019,17 @@ export default {
|
||||
contextWindow: "上下文窗口",
|
||||
contextWindowAuto: "自动(根据模型名称推断)",
|
||||
contextWindowHint: "单位:token。留空自动推断,本地/自定义模型建议手动设置。",
|
||||
codexCliPath: "Codex CLI 路径",
|
||||
codexCliPathHint: "留空则使用 PATH 中的 codex。请先在终端执行 codex login 登录。",
|
||||
codexMcpRequiredTitle: "需要 DBX MCP Server",
|
||||
codexMcpRequiredDescription: "Codex CLI 通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用 Codex。",
|
||||
reasoningLevel: "推理级别",
|
||||
reasoningLevelDefault: "默认",
|
||||
reasoningLevelMinimal: "最小",
|
||||
reasoningLevelLow: "低",
|
||||
reasoningLevelMedium: "中",
|
||||
reasoningLevelHigh: "高",
|
||||
reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。默认使用你的 Codex 配置。",
|
||||
actions: {
|
||||
generate: "生成 SQL",
|
||||
explain: "解释 SQL",
|
||||
|
||||
@@ -939,6 +939,15 @@ export default {
|
||||
enableThinkingOn: "已啟用",
|
||||
enableThinkingOff: "已停用",
|
||||
enableThinkingHint: "此選項僅對 /chat/completions API 且部分支援的模型生效。設為停用後可大幅節省 token,但產生結果品質可能會略微下降。",
|
||||
codexMcpRequiredTitle: "需要 DBX MCP Server",
|
||||
codexMcpRequiredDescription: "Codex CLI 會透過 DBX MCP Server 存取資料庫結構與查詢工具。請先安裝後再於 AI 助理中使用 Codex。",
|
||||
reasoningLevel: "推理層級",
|
||||
reasoningLevelDefault: "預設",
|
||||
reasoningLevelMinimal: "最小",
|
||||
reasoningLevelLow: "低",
|
||||
reasoningLevelMedium: "中",
|
||||
reasoningLevelHigh: "高",
|
||||
reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。預設會使用你的 Codex 設定。",
|
||||
actions: {
|
||||
generate: "產生 SQL",
|
||||
explain: "解釋 SQL",
|
||||
|
||||
@@ -11,9 +11,10 @@ import type { SqlSnippet } from "@/types/database";
|
||||
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sqlCompletion";
|
||||
import { setDebugLoggingEnabled } from "@/lib/debugLog";
|
||||
|
||||
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "custom";
|
||||
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "codex-cli" | "custom";
|
||||
export type AiApiStyle = "completions" | "responses";
|
||||
export type AiAuthMethod = "api-key" | "bearer";
|
||||
export type AiReasoningLevel = "default" | "minimal" | "low" | "medium" | "high";
|
||||
|
||||
export interface AiConfig {
|
||||
provider: AiProvider;
|
||||
@@ -25,7 +26,9 @@ export interface AiConfig {
|
||||
proxyEnabled?: boolean;
|
||||
proxyUrl?: string;
|
||||
enableThinking?: boolean;
|
||||
reasoningLevel?: AiReasoningLevel;
|
||||
contextWindow?: number;
|
||||
codexCliPath?: string | null;
|
||||
}
|
||||
|
||||
export interface AiTestConnectionResult {
|
||||
@@ -156,6 +159,16 @@ export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
|
||||
authMethod: "bearer",
|
||||
requiresApiKey: true,
|
||||
},
|
||||
"codex-cli": {
|
||||
label: "Codex CLI",
|
||||
iconSlug: "codex",
|
||||
provider: "codex-cli",
|
||||
endpoint: "",
|
||||
model: "default",
|
||||
apiStyle: "completions",
|
||||
authMethod: "bearer",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
custom: {
|
||||
label: "Custom",
|
||||
provider: "custom",
|
||||
@@ -174,6 +187,12 @@ const defaultConfigs: Record<AiProvider, Omit<AiConfig, "apiKey">> = Object.from
|
||||
}),
|
||||
) as Record<AiProvider, Omit<AiConfig, "apiKey">>;
|
||||
|
||||
const AI_REASONING_LEVELS: AiReasoningLevel[] = ["default", "minimal", "low", "medium", "high"];
|
||||
|
||||
function normalizeAiReasoningLevel(value: unknown): AiReasoningLevel {
|
||||
return typeof value === "string" && AI_REASONING_LEVELS.includes(value as AiReasoningLevel) ? (value as AiReasoningLevel) : "default";
|
||||
}
|
||||
|
||||
export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined): AiConfig {
|
||||
const provider = config?.provider && config.provider in AI_PROVIDER_PRESETS ? config.provider : inferAiProviderFromConfig(config);
|
||||
return {
|
||||
@@ -186,7 +205,9 @@ export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined):
|
||||
proxyEnabled: !!config?.proxyEnabled,
|
||||
proxyUrl: config?.proxyUrl ?? "",
|
||||
enableThinking: config?.enableThinking ?? true,
|
||||
reasoningLevel: normalizeAiReasoningLevel(config?.reasoningLevel),
|
||||
contextWindow: config?.contextWindow ?? undefined,
|
||||
codexCliPath: config?.codexCliPath?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -669,6 +690,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
|
||||
function isConfigured(): boolean {
|
||||
const preset = AI_PROVIDER_PRESETS[aiConfig.value.provider];
|
||||
if (aiConfig.value.provider === "codex-cli") return true;
|
||||
return !!aiConfig.value.endpoint && !!aiConfig.value.model && (!preset.requiresApiKey || !!aiConfig.value.apiKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use tokio::sync::Notify;
|
||||
use crate::agent_events::{AgentEvent, ToolCall, ToolDefinition, ToolResult};
|
||||
use crate::agent_tools;
|
||||
use crate::ai::{self, AiCompletionRequest, AiConfig, AiMessage, AiProvider, AiStreamChunk};
|
||||
use crate::ai_cli_agent::CliAgentCommandSpec;
|
||||
use crate::connection::AppState;
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::token_usage::TokenUsage;
|
||||
@@ -27,6 +28,7 @@ pub struct AgentLoopContext {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub db_type: DatabaseType,
|
||||
pub cli_mcp_server_command: Option<CliAgentCommandSpec>,
|
||||
}
|
||||
|
||||
/// Check if the provider supports function calling / tool use.
|
||||
@@ -59,6 +61,31 @@ pub async fn run_agent_loop(
|
||||
temperature: Option<f32>,
|
||||
is_agent_mode: bool,
|
||||
) -> Result<String, String> {
|
||||
if matches!(config.provider, AiProvider::CodexCli) {
|
||||
let connection_name = {
|
||||
let configs = agent_ctx.state.configs.read().await;
|
||||
configs
|
||||
.get(&agent_ctx.connection_id)
|
||||
.map(|config| config.name.clone())
|
||||
.unwrap_or_else(|| agent_ctx.connection_id.clone())
|
||||
};
|
||||
let prompt = crate::ai_codex_cli::build_codex_prompt(system_prompt, messages);
|
||||
return crate::ai_codex_cli::run_codex_agent(
|
||||
config,
|
||||
&prompt,
|
||||
crate::ai_codex_cli::CodexRunOptions {
|
||||
connection_id: agent_ctx.connection_id.clone(),
|
||||
connection_name,
|
||||
database: agent_ctx.database.clone(),
|
||||
agent_mode: is_agent_mode,
|
||||
mcp_server_command: agent_ctx.cli_mcp_server_command.clone(),
|
||||
},
|
||||
cancelled,
|
||||
on_event,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Auto-degrade: providers without function calling fall back to text-only completion.
|
||||
if !provider_supports_function_calling(config) {
|
||||
return run_agent_loop_text_only(
|
||||
|
||||
@@ -50,6 +50,8 @@ pub enum AiProvider {
|
||||
Ollama,
|
||||
#[serde(rename = "openai-compatible")]
|
||||
OpenaiCompatible,
|
||||
#[serde(rename = "codex-cli")]
|
||||
CodexCli,
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -69,6 +71,29 @@ pub enum AiAuthMethod {
|
||||
Bearer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AiReasoningLevel {
|
||||
#[default]
|
||||
Default,
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
impl AiReasoningLevel {
|
||||
pub fn as_codex_effort(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
AiReasoningLevel::Default => None,
|
||||
AiReasoningLevel::Minimal => Some("minimal"),
|
||||
AiReasoningLevel::Low => Some("low"),
|
||||
AiReasoningLevel::Medium => Some("medium"),
|
||||
AiReasoningLevel::High => Some("high"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiConfig {
|
||||
@@ -90,7 +115,11 @@ pub struct AiConfig {
|
||||
#[serde(default = "default_enable_thinking")]
|
||||
pub enable_thinking: bool,
|
||||
#[serde(default)]
|
||||
pub reasoning_level: AiReasoningLevel,
|
||||
#[serde(default)]
|
||||
pub context_window: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub codex_cli_path: Option<String>,
|
||||
}
|
||||
|
||||
fn default_enable_thinking() -> bool {
|
||||
@@ -234,6 +263,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
|
||||
}
|
||||
match config.provider {
|
||||
AiProvider::Claude => format!("{ep}/messages"),
|
||||
AiProvider::CodexCli => unreachable!(),
|
||||
AiProvider::Openai
|
||||
| AiProvider::Deepseek
|
||||
| AiProvider::Qwen
|
||||
@@ -465,6 +495,9 @@ pub fn build_responses_input(system_prompt: &str, messages: &[AiMessage]) -> ser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn validate_config(config: &AiConfig) -> Result<(), String> {
|
||||
if matches!(config.provider, AiProvider::CodexCli) {
|
||||
return Ok(());
|
||||
}
|
||||
if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() {
|
||||
return Err("API key is required".to_string());
|
||||
}
|
||||
@@ -478,6 +511,9 @@ fn validate_config(config: &AiConfig) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn validate_model_list_config(config: &AiConfig) -> Result<(), String> {
|
||||
if matches!(config.provider, AiProvider::CodexCli) {
|
||||
return Ok(());
|
||||
}
|
||||
if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() {
|
||||
return Err("API key is required".to_string());
|
||||
}
|
||||
@@ -611,6 +647,9 @@ async fn list_openai_compatible_models(
|
||||
}
|
||||
|
||||
pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
|
||||
if matches!(config.provider, AiProvider::CodexCli) {
|
||||
return crate::ai_codex_cli::list_codex_models(config).await;
|
||||
}
|
||||
validate_model_list_config(config)?;
|
||||
|
||||
let client = build_ai_http_client(config, 30)?;
|
||||
@@ -623,6 +662,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
|
||||
| AiProvider::Ollama
|
||||
| AiProvider::OpenaiCompatible
|
||||
| AiProvider::Custom => list_openai_compatible_models(&client, config).await,
|
||||
AiProvider::CodexCli => unreachable!(),
|
||||
AiProvider::Gemini => {
|
||||
Err("Model listing is only supported for OpenAI-compatible and Claude providers".to_string())
|
||||
}
|
||||
@@ -844,6 +884,9 @@ fn claude_system_prompt(system_prompt: &str) -> &str {
|
||||
}
|
||||
|
||||
pub async fn test_connection_core(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
|
||||
if matches!(config.provider, AiProvider::CodexCli) {
|
||||
return crate::ai_codex_cli::test_codex_connection(config).await;
|
||||
}
|
||||
validate_config(config)?;
|
||||
|
||||
let client = build_ai_http_client(config, 15)?;
|
||||
@@ -973,11 +1016,16 @@ fn classify_error(msg: &str) -> &'static str {
|
||||
pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
|
||||
validate_config(&request.config)?;
|
||||
|
||||
if matches!(request.config.provider, AiProvider::CodexCli) {
|
||||
return Err("Codex CLI provider is only supported in DBX AI agent mode".to_string());
|
||||
}
|
||||
|
||||
let client = build_ai_http_client(&request.config, 60)?;
|
||||
|
||||
match request.config.provider {
|
||||
AiProvider::Claude => call_claude(&client, request.clone()).await,
|
||||
AiProvider::Gemini => call_gemini(&client, request.clone()).await,
|
||||
AiProvider::CodexCli => unreachable!(),
|
||||
AiProvider::Openai
|
||||
| AiProvider::Deepseek
|
||||
| AiProvider::Qwen
|
||||
@@ -1005,12 +1053,17 @@ pub async fn stream(
|
||||
) -> Result<(), String> {
|
||||
validate_config(&request.config)?;
|
||||
|
||||
if matches!(request.config.provider, AiProvider::CodexCli) {
|
||||
return Err("Codex CLI provider is only supported in DBX AI agent mode".to_string());
|
||||
}
|
||||
|
||||
let stream_timeout = if request.config.enable_thinking { 600 } else { 120 };
|
||||
let client = build_ai_http_client(&request.config, stream_timeout)?;
|
||||
|
||||
match request.config.provider {
|
||||
AiProvider::Claude => stream_claude(&client, session_id, request, cancelled, &on_chunk).await,
|
||||
AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await,
|
||||
AiProvider::CodexCli => unreachable!(),
|
||||
AiProvider::Openai
|
||||
| AiProvider::Deepseek
|
||||
| AiProvider::Qwen
|
||||
@@ -2025,7 +2078,7 @@ mod tests {
|
||||
build_ai_http_client, claude_headers, claude_system_prompt, gemini_text, is_kimi_model, openai_response_text,
|
||||
openai_stream_reasoning, openai_stream_text, parse_model_list_response, resolve_endpoint,
|
||||
resolve_model_list_endpoint, responses_max_output_tokens, responses_text, supports_temperature,
|
||||
validate_config, AiApiStyle, AiAuthMethod, AiConfig, AiModelInfo, AiProvider, AUTHORIZATION,
|
||||
validate_config, AiApiStyle, AiAuthMethod, AiConfig, AiModelInfo, AiProvider, AiReasoningLevel, AUTHORIZATION,
|
||||
CLAUDE_DEFAULT_SYSTEM,
|
||||
};
|
||||
|
||||
@@ -2058,7 +2111,9 @@ mod tests {
|
||||
proxy_enabled: true,
|
||||
proxy_url: "not a proxy url".to_string(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
|
||||
let err = build_ai_http_client(&config, 1).unwrap_err();
|
||||
@@ -2078,7 +2133,9 @@ mod tests {
|
||||
proxy_enabled: true,
|
||||
proxy_url: "127.0.0.1:7890".to_string(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
|
||||
build_ai_http_client(&config, 1).unwrap();
|
||||
@@ -2096,7 +2153,9 @@ mod tests {
|
||||
proxy_enabled: true,
|
||||
proxy_url: "not a proxy url".to_string(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
|
||||
build_ai_http_client(&config, 1).unwrap();
|
||||
@@ -2114,7 +2173,9 @@ mod tests {
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -2132,7 +2193,9 @@ mod tests {
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
|
||||
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
|
||||
@@ -2151,7 +2214,9 @@ mod tests {
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
|
||||
|
||||
@@ -2165,7 +2230,9 @@ mod tests {
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
|
||||
}
|
||||
@@ -2183,7 +2250,9 @@ mod tests {
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions");
|
||||
assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models");
|
||||
@@ -2236,7 +2305,9 @@ mod tests {
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
|
||||
let api_key_headers = claude_headers(&config).unwrap();
|
||||
@@ -2305,7 +2376,9 @@ mod tests {
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
};
|
||||
|
||||
assert!(!supports_temperature(&config));
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiMessage, AiModelInfo};
|
||||
use crate::token_usage::TokenUsage;
|
||||
use serde_json::Value;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CliAgentRunOptions {
|
||||
pub connection_id: String,
|
||||
pub connection_name: String,
|
||||
pub database: String,
|
||||
pub agent_mode: bool,
|
||||
pub mcp_server_command: Option<CliAgentCommandSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CliAgentCommandSpec {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CliAgentJsonlDialect {
|
||||
CodexExec,
|
||||
}
|
||||
|
||||
pub struct CliAgentProcessSpec {
|
||||
pub command: CliAgentCommandSpec,
|
||||
pub dialect: CliAgentJsonlDialect,
|
||||
pub classify_spawn_error: fn(&str) -> String,
|
||||
pub classify_run_error: fn(&str) -> String,
|
||||
}
|
||||
|
||||
pub fn toml_string(value: &str) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
|
||||
}
|
||||
|
||||
pub fn toml_string_array(values: &[&str]) -> String {
|
||||
let items = values.iter().map(|value| toml_string(value)).collect::<Vec<_>>().join(", ");
|
||||
format!("[{items}]")
|
||||
}
|
||||
|
||||
pub fn dbx_mcp_enabled_tools(agent_mode: bool) -> Vec<&'static str> {
|
||||
let mut tools = vec!["dbx_list_connections", "dbx_list_tables", "dbx_describe_table", "dbx_get_schema_context"];
|
||||
if agent_mode {
|
||||
tools.push("dbx_execute_query");
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
pub fn dbx_mcp_scope_env(options: &CliAgentRunOptions) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("DBX_MCP_ALLOW_WRITES", "0".to_string()),
|
||||
("DBX_MCP_SCOPE_CONNECTION_ID", options.connection_id.clone()),
|
||||
("DBX_MCP_SCOPE_CONNECTION_NAME", options.connection_name.clone()),
|
||||
("DBX_MCP_SCOPE_DATABASE", options.database.clone()),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn append_config_overrides(args: &mut Vec<String>, overrides: impl IntoIterator<Item = String>) {
|
||||
for override_arg in overrides {
|
||||
args.push("-c".to_string());
|
||||
args.push(override_arg);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_cli_agent_prompt(provider_label: &str, system_prompt: &str, messages: &[AiMessage]) -> String {
|
||||
let mut sections = vec![
|
||||
format!("You are running inside DBX Desktop as the {provider_label} CLI provider."),
|
||||
"Use the DBX MCP tools when you need live database schema or read-only query results.".to_string(),
|
||||
"Do not modify files or run shell commands. The DBX MCP server is the only intended tool surface.".to_string(),
|
||||
String::new(),
|
||||
"## System instructions".to_string(),
|
||||
system_prompt.to_string(),
|
||||
String::new(),
|
||||
"## Conversation".to_string(),
|
||||
];
|
||||
|
||||
for message in messages {
|
||||
sections.push(format!("### {}", message.role));
|
||||
sections.push(message.content.clone());
|
||||
if !message.tool_calls.is_empty() {
|
||||
sections.push(format!("[Previous tool calls omitted from CLI replay: {}]", message.tool_calls.len()));
|
||||
}
|
||||
sections.push(String::new());
|
||||
}
|
||||
|
||||
sections.join("\n")
|
||||
}
|
||||
|
||||
pub fn model_infos(ids: &[&str]) -> Vec<AiModelInfo> {
|
||||
ids.iter().map(|id| AiModelInfo { id: (*id).to_string(), display_name: None }).collect()
|
||||
}
|
||||
|
||||
pub async fn list_json_models_or_default(
|
||||
program: String,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
default_models: &[&str],
|
||||
) -> Result<Vec<AiModelInfo>, String> {
|
||||
let output = Command::new(program).args(args).output().await;
|
||||
|
||||
let Ok(output) = output else {
|
||||
return Ok(model_infos(default_models));
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Ok(model_infos(default_models));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let Ok(data) = serde_json::from_str::<Value>(&stdout) else {
|
||||
return Ok(model_infos(default_models));
|
||||
};
|
||||
|
||||
let mut models = Vec::new();
|
||||
collect_model_ids(&data, &mut models);
|
||||
models.sort();
|
||||
models.dedup();
|
||||
|
||||
if models.is_empty() {
|
||||
Ok(model_infos(default_models))
|
||||
} else {
|
||||
let mut result = vec![AiModelInfo { id: "default".to_string(), display_name: None }];
|
||||
result.extend(models.into_iter().map(|id| AiModelInfo { id, display_name: None }));
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_model_ids(value: &Value, out: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if let Some(id) = map.get("id").and_then(Value::as_str).or_else(|| map.get("name").and_then(Value::as_str))
|
||||
{
|
||||
if !id.trim().is_empty() {
|
||||
out.push(id.to_string());
|
||||
}
|
||||
}
|
||||
for child in map.values() {
|
||||
collect_model_ids(child, out);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_model_ids(item, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ParsedCliAgentEvent {
|
||||
events: Vec<AgentEvent>,
|
||||
final_text: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_cli_jsonl_event(line: &str, dialect: CliAgentJsonlDialect) -> Option<Vec<AgentEvent>> {
|
||||
let parsed = parse_cli_jsonl_line(line, dialect);
|
||||
if parsed.events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parsed.events)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliAgentEvent {
|
||||
match dialect {
|
||||
CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_codex_jsonl_line(line: &str) -> ParsedCliAgentEvent {
|
||||
let Ok(value) = serde_json::from_str::<Value>(line) else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
};
|
||||
|
||||
let event_type = value.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
match event_type {
|
||||
"item.started" => parse_codex_item_started(&value),
|
||||
"item.completed" => parse_codex_item_completed(&value),
|
||||
"turn.completed" => parse_codex_turn_completed(&value),
|
||||
"turn.failed" | "error" => {
|
||||
let message = codex_error_message(&value);
|
||||
ParsedCliAgentEvent {
|
||||
error: Some(message.clone()),
|
||||
events: vec![AgentEvent::Error { message }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
_ => ParsedCliAgentEvent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_codex_item_started(value: &Value) -> ParsedCliAgentEvent {
|
||||
let item = &value["item"];
|
||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
if is_mcp_tool_item(item_type) {
|
||||
ParsedCliAgentEvent {
|
||||
events: vec![AgentEvent::ToolCallStart {
|
||||
tool_call_id: cli_item_id(item),
|
||||
tool_name: cli_tool_name(item),
|
||||
args: cli_tool_args(item),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
ParsedCliAgentEvent::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_codex_item_completed(value: &Value) -> ParsedCliAgentEvent {
|
||||
let item = &value["item"];
|
||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
|
||||
if item_type == "agent_message" {
|
||||
if let Some(text) = cli_text(item) {
|
||||
return ParsedCliAgentEvent {
|
||||
final_text: Some(text.clone()),
|
||||
events: vec![AgentEvent::TextDelta { delta: text }],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if item_type == "reasoning" {
|
||||
if let Some(text) = cli_text(item) {
|
||||
return ParsedCliAgentEvent {
|
||||
events: vec![AgentEvent::ReasoningDelta { delta: text }],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if is_mcp_tool_item(item_type) {
|
||||
return ParsedCliAgentEvent {
|
||||
events: vec![AgentEvent::ToolCallEnd {
|
||||
tool_call_id: cli_item_id(item),
|
||||
tool_name: cli_tool_name(item),
|
||||
result: cli_tool_result(item),
|
||||
is_error: item.get("status").and_then(Value::as_str).map(|status| status == "failed").unwrap_or(false),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
ParsedCliAgentEvent::default()
|
||||
}
|
||||
|
||||
fn parse_codex_turn_completed(value: &Value) -> ParsedCliAgentEvent {
|
||||
let usage = value.get("usage").and_then(|usage| {
|
||||
let input =
|
||||
usage.get("input_tokens").or_else(|| usage.get("prompt_tokens")).and_then(Value::as_u64).unwrap_or(0)
|
||||
as u32;
|
||||
let output =
|
||||
usage.get("output_tokens").or_else(|| usage.get("completion_tokens")).and_then(Value::as_u64).unwrap_or(0)
|
||||
as u32;
|
||||
(input > 0 || output > 0).then_some(TokenUsage { input_tokens: input, output_tokens: output })
|
||||
});
|
||||
|
||||
ParsedCliAgentEvent {
|
||||
events: vec![AgentEvent::AgentEnd {
|
||||
input_tokens: usage.as_ref().and_then(|u| (u.input_tokens > 0).then_some(u.input_tokens)),
|
||||
output_tokens: usage.as_ref().and_then(|u| (u.output_tokens > 0).then_some(u.output_tokens)),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mcp_tool_item(item_type: &str) -> bool {
|
||||
item_type == "mcp_tool_call" || item_type == "mcp_tool" || item_type == "tool_call"
|
||||
}
|
||||
|
||||
fn cli_item_id(item: &Value) -> String {
|
||||
item.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| item.get("call_id").and_then(Value::as_str))
|
||||
.unwrap_or("cli-tool-call")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn cli_tool_name(item: &Value) -> String {
|
||||
item.get("tool_name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| item.get("name").and_then(Value::as_str))
|
||||
.or_else(|| item.get("server_tool_name").and_then(Value::as_str))
|
||||
.unwrap_or("cli_tool")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn cli_tool_args(item: &Value) -> Value {
|
||||
item.get("arguments")
|
||||
.or_else(|| item.get("args"))
|
||||
.or_else(|| item.get("input"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Object(Default::default()))
|
||||
}
|
||||
|
||||
fn cli_tool_result(item: &Value) -> Value {
|
||||
item.get("result").or_else(|| item.get("output")).or_else(|| item.get("content")).cloned().unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn cli_text(item: &Value) -> Option<String> {
|
||||
item.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| item.get("message").and_then(Value::as_str))
|
||||
.or_else(|| item.get("content").and_then(Value::as_str))
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn codex_error_message(value: &Value) -> String {
|
||||
value
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.get("error").and_then(Value::as_str))
|
||||
.or_else(|| value.get("error").and_then(|e| e.get("message")).and_then(Value::as_str))
|
||||
.unwrap_or("CLI agent failed")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub async fn run_cli_jsonl_agent(
|
||||
spec: CliAgentProcessSpec,
|
||||
cancelled: &Notify,
|
||||
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
|
||||
) -> Result<String, String> {
|
||||
let mut child = Command::new(&spec.command.program)
|
||||
.args(&spec.command.args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| (spec.classify_spawn_error)(&e.to_string()))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or_else(|| "Failed to capture CLI agent stdout".to_string())?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| "Failed to capture CLI agent stderr".to_string())?;
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr);
|
||||
let mut buf = String::new();
|
||||
let _ = reader.read_to_string(&mut buf).await;
|
||||
buf
|
||||
});
|
||||
|
||||
let mut final_text = String::new();
|
||||
let mut saw_agent_end = false;
|
||||
let mut terminal_error: Option<String> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = lines.next_line() => {
|
||||
let line = match line {
|
||||
Ok(Some(line)) => line,
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
terminal_error = Some(format!("CLI agent stream read failed: {e}"));
|
||||
let _ = child.kill().await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
let parsed = parse_cli_jsonl_line(&line, spec.dialect);
|
||||
if let Some(text) = parsed.final_text {
|
||||
final_text.push_str(&text);
|
||||
}
|
||||
for event in parsed.events {
|
||||
if matches!(event, AgentEvent::AgentEnd { .. }) {
|
||||
saw_agent_end = true;
|
||||
}
|
||||
on_event(event);
|
||||
}
|
||||
if let Some(error) = parsed.error {
|
||||
terminal_error = Some(error);
|
||||
let _ = child.kill().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = cancelled.notified() => {
|
||||
terminal_error = Some("Agent loop cancelled".to_string());
|
||||
let _ = child.kill().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| format!("CLI agent wait failed: {e}"))?;
|
||||
let stderr = stderr_task.await.unwrap_or_default();
|
||||
|
||||
if let Some(error) = terminal_error {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if !status.success() {
|
||||
return Err((spec.classify_run_error)(&stderr));
|
||||
}
|
||||
|
||||
if !saw_agent_end {
|
||||
on_event(AgentEvent::AgentEnd { input_tokens: None, output_tokens: None });
|
||||
}
|
||||
|
||||
Ok(final_text)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
|
||||
fn classify_spawn_error(message: &str) -> String {
|
||||
message.to_string()
|
||||
}
|
||||
|
||||
fn classify_run_error(message: &str) -> String {
|
||||
message.to_string()
|
||||
}
|
||||
|
||||
fn process_is_alive(pid: &str) -> bool {
|
||||
StdCommand::new("kill")
|
||||
.args(["-0", pid])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jsonl_error_kills_and_waits_for_child() {
|
||||
let pid_file = std::env::temp_dir().join(format!(
|
||||
"dbx-cli-agent-error-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
let script = format!(
|
||||
"echo $$ > {}; printf '%s\\n' '{{\"type\":\"turn.failed\",\"message\":\"boom\"}}'; exec sleep 30",
|
||||
pid_file.display()
|
||||
);
|
||||
|
||||
let spec = CliAgentProcessSpec {
|
||||
command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] },
|
||||
dialect: CliAgentJsonlDialect::CodexExec,
|
||||
classify_spawn_error,
|
||||
classify_run_error,
|
||||
};
|
||||
|
||||
let result = timeout(Duration::from_secs(3), run_cli_jsonl_agent(spec, &Notify::new(), |_| {}))
|
||||
.await
|
||||
.expect("runner should return after JSONL error");
|
||||
|
||||
assert_eq!(result.unwrap_err(), "boom");
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
let pid = std::fs::read_to_string(&pid_file).expect("child pid should be captured");
|
||||
assert!(!process_is_alive(pid.trim()));
|
||||
let _ = std::fs::remove_file(pid_file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiConfig, AiModelInfo, AiTestConnectionResult};
|
||||
use crate::ai_cli_agent::{
|
||||
append_config_overrides, build_cli_agent_prompt, dbx_mcp_enabled_tools, dbx_mcp_scope_env, model_infos,
|
||||
parse_cli_jsonl_event, run_cli_jsonl_agent, toml_string, toml_string_array, CliAgentCommandSpec,
|
||||
CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::time::Instant;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const DEFAULT_CODEX_MODELS: &[&str] = &["default", "gpt-5.5", "gpt-5.4-mini"];
|
||||
|
||||
pub type CodexRunOptions = CliAgentRunOptions;
|
||||
pub type CodexCommandSpec = CliAgentCommandSpec;
|
||||
|
||||
fn codex_program(config: &AiConfig) -> String {
|
||||
config.codex_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("codex").to_string()
|
||||
}
|
||||
|
||||
pub fn codex_enabled_tools(agent_mode: bool) -> Vec<&'static str> {
|
||||
dbx_mcp_enabled_tools(agent_mode)
|
||||
}
|
||||
|
||||
fn codex_mcp_config_overrides(options: &CodexRunOptions) -> Vec<String> {
|
||||
let mcp_command =
|
||||
options.mcp_server_command.as_ref().map(|command| command.program.as_str()).unwrap_or("dbx-mcp-server");
|
||||
let mut overrides = vec![
|
||||
format!("mcp_servers.dbx.command={}", toml_string(mcp_command)),
|
||||
"mcp_servers.dbx.required=true".to_string(),
|
||||
"mcp_servers.dbx.startup_timeout_sec=20".to_string(),
|
||||
"mcp_servers.dbx.tool_timeout_sec=120".to_string(),
|
||||
"mcp_servers.dbx.default_tools_approval_mode=\"auto\"".to_string(),
|
||||
format!("mcp_servers.dbx.enabled_tools={}", toml_string_array(&dbx_mcp_enabled_tools(options.agent_mode))),
|
||||
];
|
||||
if let Some(command) = options.mcp_server_command.as_ref().filter(|command| !command.args.is_empty()) {
|
||||
let args = command.args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
overrides.push(format!("mcp_servers.dbx.args={}", toml_string_array(&args)));
|
||||
}
|
||||
overrides.extend(
|
||||
dbx_mcp_scope_env(options)
|
||||
.into_iter()
|
||||
.map(|(name, value)| format!("mcp_servers.dbx.env.{name}={}", toml_string(&value))),
|
||||
);
|
||||
overrides
|
||||
}
|
||||
|
||||
pub fn build_codex_exec_command(config: &AiConfig, prompt: &str, options: &CodexRunOptions) -> CodexCommandSpec {
|
||||
let mut args = vec![
|
||||
"exec".to_string(),
|
||||
"--json".to_string(),
|
||||
"--skip-git-repo-check".to_string(),
|
||||
"--sandbox".to_string(),
|
||||
"read-only".to_string(),
|
||||
];
|
||||
let mut config_overrides = vec!["features.shell_tool=false".to_string(), "web_search=\"disabled\"".to_string()];
|
||||
if let Some(reasoning_effort) = config.reasoning_level.as_codex_effort() {
|
||||
config_overrides.push(format!("model_reasoning_effort={}", toml_string(reasoning_effort)));
|
||||
}
|
||||
append_config_overrides(&mut args, config_overrides.into_iter().chain(codex_mcp_config_overrides(options)));
|
||||
|
||||
let model = config.model.trim();
|
||||
if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
|
||||
args.push("--model".to_string());
|
||||
args.push(model.to_string());
|
||||
}
|
||||
|
||||
args.push(prompt.to_string());
|
||||
|
||||
CodexCommandSpec { program: codex_program(config), args }
|
||||
}
|
||||
|
||||
pub fn build_codex_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage]) -> String {
|
||||
build_cli_agent_prompt("Codex", system_prompt, messages)
|
||||
}
|
||||
|
||||
pub async fn list_codex_models(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
|
||||
let output = Command::new(codex_program(config)).args(["debug", "models"]).output().await;
|
||||
|
||||
let Ok(output) = output else {
|
||||
return Ok(model_infos(DEFAULT_CODEX_MODELS));
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Ok(model_infos(DEFAULT_CODEX_MODELS));
|
||||
}
|
||||
|
||||
Ok(parse_codex_models(&String::from_utf8_lossy(&output.stdout))
|
||||
.unwrap_or_else(|| model_infos(DEFAULT_CODEX_MODELS)))
|
||||
}
|
||||
|
||||
fn parse_codex_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
|
||||
let json_start = stdout.find('{')?;
|
||||
let data = serde_json::from_str::<Value>(&stdout[json_start..]).ok()?;
|
||||
let models = data.get("models").and_then(Value::as_array)?;
|
||||
|
||||
let mut result = vec![AiModelInfo { id: "default".to_string(), display_name: Some("Default".to_string()) }];
|
||||
for model in models {
|
||||
let Some(id) = model
|
||||
.get("slug")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| model.get("id").and_then(Value::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if result.iter().any(|existing| existing.id == id) {
|
||||
continue;
|
||||
}
|
||||
let display_name = model
|
||||
.get("display_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string);
|
||||
result.push(AiModelInfo { id: id.to_string(), display_name });
|
||||
}
|
||||
|
||||
(result.len() > 1).then_some(result)
|
||||
}
|
||||
|
||||
pub async fn test_codex_connection(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
|
||||
let start = Instant::now();
|
||||
let mut command = Command::new(codex_program(config));
|
||||
command.args(["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only"]);
|
||||
|
||||
let model = config.model.trim();
|
||||
if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
|
||||
command.args(["--model", model]);
|
||||
}
|
||||
|
||||
let output = command
|
||||
.arg("Reply with exactly: DBX Codex OK")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| classify_codex_spawn_error(&e.to_string()))?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.contains("DBX Codex OK") {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"[codexRunFailed] Codex CLI smoke test returned unexpected output. stdout: {stdout} stderr: {stderr}"
|
||||
));
|
||||
}
|
||||
Ok(AiTestConnectionResult {
|
||||
success: true,
|
||||
message: format!("OK - {}ms", start.elapsed().as_millis()),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
model_used: config.model.trim().to_string(),
|
||||
error_category: None,
|
||||
})
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(classify_codex_run_error(&stderr))
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_codex_spawn_error(message: &str) -> String {
|
||||
if message.contains("No such file") || message.contains("not found") {
|
||||
"[codexNotInstalled] Codex CLI was not found. Install Codex CLI or set the Codex CLI path in DBX AI settings."
|
||||
.to_string()
|
||||
} else {
|
||||
format!("[codexRunFailed] Failed to start Codex CLI: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_codex_run_error(stderr: &str) -> String {
|
||||
let lower = stderr.to_ascii_lowercase();
|
||||
if lower.contains("not authenticated") || lower.contains("login") || lower.contains("auth") {
|
||||
format!("[codexNotAuthenticated] Codex CLI is not authenticated. Run `codex login` and try again. {stderr}")
|
||||
} else if lower.contains("dbx-mcp-server") || lower.contains("enoent") {
|
||||
format!("[dbxMcpMissing] DBX MCP server was not found. Install @dbx-app/mcp-server and try again. {stderr}")
|
||||
} else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) {
|
||||
format!("[codexMcpStartupFailed] Codex could not start the DBX MCP server. {stderr}")
|
||||
} else {
|
||||
format!("[codexRunFailed] Codex CLI failed. {stderr}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_codex_jsonl_event(line: &str) -> Option<Vec<AgentEvent>> {
|
||||
parse_cli_jsonl_event(line, CliAgentJsonlDialect::CodexExec)
|
||||
}
|
||||
|
||||
pub async fn run_codex_agent(
|
||||
config: &AiConfig,
|
||||
prompt: &str,
|
||||
options: CodexRunOptions,
|
||||
cancelled: &Notify,
|
||||
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
|
||||
) -> Result<String, String> {
|
||||
let command = build_codex_exec_command(config, prompt, &options);
|
||||
run_cli_jsonl_agent(
|
||||
CliAgentProcessSpec {
|
||||
command,
|
||||
dialect: CliAgentJsonlDialect::CodexExec,
|
||||
classify_spawn_error: classify_codex_spawn_error,
|
||||
classify_run_error: classify_codex_run_error,
|
||||
},
|
||||
cancelled,
|
||||
on_event,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_codex_exec_command, codex_enabled_tools, parse_codex_jsonl_event, parse_codex_models, CodexRunOptions,
|
||||
DEFAULT_CODEX_MODELS,
|
||||
};
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiProvider, AiReasoningLevel};
|
||||
use crate::ai_cli_agent::{model_infos, CliAgentCommandSpec};
|
||||
|
||||
fn codex_config(model: &str) -> AiConfig {
|
||||
AiConfig {
|
||||
provider: AiProvider::CodexCli,
|
||||
api_key: String::new(),
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: String::new(),
|
||||
model: model.to_string(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_options() -> CodexRunOptions {
|
||||
CodexRunOptions {
|
||||
connection_id: "conn-1".to_string(),
|
||||
connection_name: "local".to_string(),
|
||||
database: "demo".to_string(),
|
||||
agent_mode: true,
|
||||
mcp_server_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_codex_command_with_scoped_mcp_and_omits_default_model() {
|
||||
let spec = build_codex_exec_command(&codex_config("default"), "hello", &run_options());
|
||||
|
||||
assert_eq!(spec.program, "codex");
|
||||
assert!(spec.args.contains(&"--json".to_string()));
|
||||
assert!(!spec.args.contains(&"--model".to_string()));
|
||||
assert!(!spec.args.contains(&"--ask-for-approval".to_string()));
|
||||
assert!(spec.args.contains(&"mcp_servers.dbx.command=\"dbx-mcp-server\"".to_string()));
|
||||
assert!(spec.args.contains(&"mcp_servers.dbx.env.DBX_MCP_ALLOW_WRITES=\"0\"".to_string()));
|
||||
assert!(spec.args.contains(&"mcp_servers.dbx.env.DBX_MCP_SCOPE_CONNECTION_ID=\"conn-1\"".to_string()));
|
||||
assert!(spec.args.iter().any(|arg| arg.contains("dbx_execute_query")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_codex_command_with_non_default_model_and_ask_tools() {
|
||||
let mut options = run_options();
|
||||
options.agent_mode = false;
|
||||
let spec = build_codex_exec_command(&codex_config("gpt-5.5"), "hello", &options);
|
||||
|
||||
let model_pos = spec.args.iter().position(|arg| arg == "--model").unwrap();
|
||||
assert_eq!(spec.args[model_pos + 1], "gpt-5.5");
|
||||
assert!(!codex_enabled_tools(false).contains(&"dbx_execute_query"));
|
||||
assert!(spec.args.iter().any(|arg| arg.contains("dbx_get_schema_context")));
|
||||
assert!(!spec.args.iter().any(|arg| arg.contains("dbx_execute_query")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_codex_command_with_resolved_mcp_server_path() {
|
||||
let mut options = run_options();
|
||||
options.mcp_server_command = Some(CliAgentCommandSpec {
|
||||
program: "/opt/dbx/bin/dbx-mcp-server".to_string(),
|
||||
args: vec!["--stdio".to_string()],
|
||||
});
|
||||
let spec = build_codex_exec_command(&codex_config("default"), "hello", &options);
|
||||
|
||||
assert!(spec.args.contains(&"mcp_servers.dbx.command=\"/opt/dbx/bin/dbx-mcp-server\"".to_string()));
|
||||
assert!(spec.args.contains(&"mcp_servers.dbx.args=[\"--stdio\"]".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_codex_command_with_reasoning_effort_override() {
|
||||
let mut config = codex_config("default");
|
||||
config.reasoning_level = AiReasoningLevel::High;
|
||||
let spec = build_codex_exec_command(&config, "hello", &run_options());
|
||||
|
||||
assert!(!spec.args.contains(&"--reasoning-effort".to_string()));
|
||||
assert!(spec.args.contains(&"model_reasoning_effort=\"high\"".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_model_list_matches_plan() {
|
||||
assert_eq!(model_infos(DEFAULT_CODEX_MODELS), model_infos(&["default", "gpt-5.5", "gpt-5.4-mini"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_codex_model_catalog_without_service_tiers() {
|
||||
let models = parse_codex_models(
|
||||
r#"{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5","service_tiers":[{"id":"priority","name":"Priority"}]},{"slug":"gpt-5.4-mini","display_name":"GPT-5.4 mini"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
models.iter().map(|model| model.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["default", "gpt-5.5", "gpt-5.4-mini"]
|
||||
);
|
||||
assert_eq!(models[1].display_name.as_deref(), Some("GPT-5.5"));
|
||||
assert!(!models.iter().any(|model| model.id == "priority" || model.id == "Priority"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_codex_jsonl_events() {
|
||||
assert!(parse_codex_jsonl_event(r#"{"type":"thread.started","thread_id":"t"}"#).is_none());
|
||||
|
||||
let started = parse_codex_jsonl_event(
|
||||
r#"{"type":"item.started","item":{"id":"tool-1","type":"mcp_tool_call","tool_name":"dbx_list_tables","arguments":{"schema":"public"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&started[0], AgentEvent::ToolCallStart { tool_name, .. } if tool_name == "dbx_list_tables"));
|
||||
|
||||
let completed = parse_codex_jsonl_event(
|
||||
r#"{"type":"item.completed","item":{"id":"msg-1","type":"agent_message","text":"Done"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&completed[0], AgentEvent::TextDelta { delta } if delta == "Done"));
|
||||
|
||||
let tool_done = parse_codex_jsonl_event(
|
||||
r#"{"type":"item.completed","item":{"id":"tool-1","type":"mcp_tool_call","tool_name":"dbx_list_tables","result":{"content":"users"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&tool_done[0], AgentEvent::ToolCallEnd { tool_name, is_error, .. } if tool_name == "dbx_list_tables" && !is_error)
|
||||
);
|
||||
|
||||
let turn_done =
|
||||
parse_codex_jsonl_event(r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3}}"#)
|
||||
.unwrap();
|
||||
assert!(matches!(&turn_done[0], AgentEvent::AgentEnd { input_tokens: Some(12), output_tokens: Some(3) }));
|
||||
|
||||
let failed = parse_codex_jsonl_event(r#"{"type":"turn.failed","message":"boom"}"#).unwrap();
|
||||
assert!(matches!(&failed[0], AgentEvent::Error { message } if message == "boom"));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ pub mod agent_runtime;
|
||||
pub mod agent_service;
|
||||
pub mod agent_tools;
|
||||
pub mod ai;
|
||||
pub mod ai_cli_agent;
|
||||
pub mod ai_codex_cli;
|
||||
pub mod cloud_sync;
|
||||
pub mod connection;
|
||||
pub mod connection_secrets;
|
||||
|
||||
@@ -8,7 +8,9 @@ use serde::Deserialize;
|
||||
|
||||
use dbx_core::agent_events::AgentEvent;
|
||||
use dbx_core::agent_loop::{run_agent_loop, AgentLoopContext};
|
||||
use dbx_core::ai::{AiCompletionRequest, AiConfig, AiConversation, AiModelInfo, AiStreamChunk, AiTestConnectionResult};
|
||||
use dbx_core::ai::{
|
||||
AiCompletionRequest, AiConfig, AiConversation, AiModelInfo, AiProvider, AiStreamChunk, AiTestConnectionResult,
|
||||
};
|
||||
use dbx_core::models::connection::DatabaseType;
|
||||
|
||||
use crate::error::AppError;
|
||||
@@ -79,6 +81,13 @@ fn default_agent_mode() -> String {
|
||||
"ask".to_string()
|
||||
}
|
||||
|
||||
fn reject_web_unsupported_ai_provider(config: &AiConfig) -> Result<(), AppError> {
|
||||
if matches!(config.provider, AiProvider::CodexCli) {
|
||||
return Err(AppError::bad_request("Codex CLI provider is only supported in DBX Desktop."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,6 +96,7 @@ pub async fn save_ai_config(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveAiConfigRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
reject_web_unsupported_ai_provider(&body.config)?;
|
||||
state.app.storage.save_ai_config(&body.config).await.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
@@ -126,6 +136,7 @@ pub async fn delete_ai_conversation(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn ai_complete(Json(body): Json<AiCompleteRequest>) -> Result<Json<String>, AppError> {
|
||||
reject_web_unsupported_ai_provider(&body.request.config)?;
|
||||
let result = dbx_core::ai::complete(&body.request).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
@@ -137,11 +148,13 @@ pub async fn ai_complete(Json(body): Json<AiCompleteRequest>) -> Result<Json<Str
|
||||
pub async fn ai_test_connection(
|
||||
Json(body): Json<AiTestConnectionRequest>,
|
||||
) -> Result<Json<AiTestConnectionResult>, AppError> {
|
||||
reject_web_unsupported_ai_provider(&body.config)?;
|
||||
let result = dbx_core::ai::test_connection_core(&body.config).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn ai_list_models(Json(body): Json<AiListModelsRequest>) -> Result<Json<Vec<AiModelInfo>>, AppError> {
|
||||
reject_web_unsupported_ai_provider(&body.config)?;
|
||||
let result = dbx_core::ai::list_models_core(&body.config).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
@@ -164,6 +177,7 @@ pub async fn ai_stream(
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
|
||||
let session_id = body.session_id;
|
||||
let request = body.request;
|
||||
reject_web_unsupported_ai_provider(&request.config)?;
|
||||
|
||||
let cancelled = dbx_core::ai::register_stream(&session_id).await;
|
||||
let (tx, rx) = tokio::sync::broadcast::channel::<String>(256);
|
||||
@@ -205,6 +219,7 @@ pub async fn ai_agent_stream(
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
|
||||
let session_id = body.session_id;
|
||||
let request = body.request;
|
||||
reject_web_unsupported_ai_provider(&request.config)?;
|
||||
|
||||
let cancelled = dbx_core::ai::register_stream(&session_id).await;
|
||||
let (tx, rx) = tokio::sync::broadcast::channel::<String>(256);
|
||||
@@ -217,6 +232,7 @@ pub async fn ai_agent_stream(
|
||||
connection_id: body.connection_id,
|
||||
database: body.database,
|
||||
db_type: parsed_db_type,
|
||||
cli_mcp_server_command: None,
|
||||
};
|
||||
|
||||
let sid = session_id.clone();
|
||||
|
||||
@@ -49,15 +49,65 @@ export const DBX_CONNECTION_TYPE_DESCRIPTION =
|
||||
"Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, etcd, doris, starrocks, manticoresearch, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, questdb, gbase, h2, snowflake, trino, hive, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, iotdb, xugu, jdbc, access, mq";
|
||||
const FILE_CAPABLE_CONNECTION_TYPES = new Set(["sqlite", "duckdb", "access", "h2"]);
|
||||
|
||||
interface McpScope {
|
||||
connectionId?: string;
|
||||
connectionName?: string;
|
||||
database?: string;
|
||||
}
|
||||
|
||||
function scopedValue(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function mcpScopeFromEnv(): McpScope {
|
||||
return {
|
||||
connectionId: scopedValue(process.env.DBX_MCP_SCOPE_CONNECTION_ID),
|
||||
connectionName: scopedValue(process.env.DBX_MCP_SCOPE_CONNECTION_NAME),
|
||||
database: scopedValue(process.env.DBX_MCP_SCOPE_DATABASE),
|
||||
};
|
||||
}
|
||||
|
||||
function scopeEnabled(scope: McpScope): boolean {
|
||||
return !!(scope.connectionId || scope.connectionName);
|
||||
}
|
||||
|
||||
function connectionMatchesScope(config: ConnectionConfig, scope: McpScope): boolean {
|
||||
return (!!scope.connectionId && config.id === scope.connectionId) || (!!scope.connectionName && config.name === scope.connectionName);
|
||||
}
|
||||
|
||||
async function loadScopedConnections(backend: Backend, scope: McpScope): Promise<ConnectionConfig[]> {
|
||||
const connections = await backend.loadConnections();
|
||||
if (!scopeEnabled(scope)) return connections;
|
||||
return connections.filter((config) => connectionMatchesScope(config, scope));
|
||||
}
|
||||
|
||||
async function resolveConnection(backend: Backend, scope: McpScope, requestedName?: string): Promise<{ config?: ConnectionConfig; error?: ReturnType<typeof toolError> }> {
|
||||
if (!scopeEnabled(scope)) {
|
||||
if (!requestedName?.trim()) return { error: toolError("CONNECTION_NOT_FOUND", "Connection name is required.") };
|
||||
const config = await backend.findConnection(requestedName);
|
||||
return config ? { config } : { error: toolError("CONNECTION_NOT_FOUND", `Connection "${requestedName}" not found.`) };
|
||||
}
|
||||
|
||||
const [scopedConfig] = await loadScopedConnections(backend, scope);
|
||||
if (!scopedConfig) return { error: toolError("CONNECTION_NOT_FOUND", "Scoped DBX connection was not found.") };
|
||||
if (requestedName?.trim() && requestedName !== scopedConfig.name && requestedName !== scopedConfig.id) {
|
||||
return { error: toolError("CONNECTION_OUT_OF_SCOPE", `Connection "${requestedName}" is outside this DBX AI session scope.`) };
|
||||
}
|
||||
return { config: scopedConfig };
|
||||
}
|
||||
|
||||
export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer {
|
||||
const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL;
|
||||
const scope = mcpScopeFromEnv();
|
||||
const scoped = scopeEnabled(scope);
|
||||
const server = new McpServer({
|
||||
name: "dbx",
|
||||
version: DBX_MCP_PACKAGE_VERSION,
|
||||
});
|
||||
|
||||
server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => {
|
||||
const connections = await backend.loadConnections();
|
||||
const connections = await loadScopedConnections(backend, scope);
|
||||
if (connections.length === 0) return text("No connections configured in DBX.");
|
||||
const rows = connections.map((c) => [c.name, c.db_type, c.host, String(c.port), c.database || ""]);
|
||||
return text(mdTable(["Name", "Type", "Host", "Port", "Database"], rows));
|
||||
@@ -67,14 +117,14 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
||||
"dbx_list_tables",
|
||||
"List tables and views for a database connection",
|
||||
{
|
||||
connection_name: z.string().describe("Name of the DBX connection"),
|
||||
connection_name: z.string().optional().describe("Name of the DBX connection"),
|
||||
database: z.string().optional().describe("Database name"),
|
||||
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"),
|
||||
},
|
||||
async ({ connection_name, database, schema }) => {
|
||||
const config = await backend.findConnection(connection_name);
|
||||
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
|
||||
const tables = await backend.listTables(withDatabase(config, database), schema);
|
||||
const { config, error } = await resolveConnection(backend, scope, connection_name);
|
||||
if (error) return error;
|
||||
const tables = await backend.listTables(withDatabase(config!, database ?? scope.database), schema);
|
||||
if (tables.length === 0) return text("No tables found.");
|
||||
const rows = tables.map((t) => [t.name, t.type]);
|
||||
return text(mdTable(["Table", "Type"], rows));
|
||||
@@ -85,15 +135,15 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
||||
"dbx_describe_table",
|
||||
"Get column definitions for a table",
|
||||
{
|
||||
connection_name: z.string().describe("Name of the DBX connection"),
|
||||
connection_name: z.string().optional().describe("Name of the DBX connection"),
|
||||
table: z.string().describe("Table name"),
|
||||
database: z.string().optional().describe("Database name"),
|
||||
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"),
|
||||
},
|
||||
async ({ connection_name, table, database, schema }) => {
|
||||
const config = await backend.findConnection(connection_name);
|
||||
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
|
||||
const columns = await backend.describeTable(withDatabase(config, database), table, schema);
|
||||
const { config, error } = await resolveConnection(backend, scope, connection_name);
|
||||
if (error) return error;
|
||||
const columns = await backend.describeTable(withDatabase(config!, database ?? scope.database), table, schema);
|
||||
if (columns.length === 0) return text("No columns found.");
|
||||
const rows = columns.map((c) => [c.is_primary_key ? `${c.name} (PK)` : c.name, c.data_type, c.is_nullable ? "YES" : "NO", c.column_default ?? "", c.comment ?? ""]);
|
||||
return text(mdTable(["Column", "Type", "Nullable", "Default", "Comment"], rows));
|
||||
@@ -104,24 +154,25 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
||||
"dbx_execute_query",
|
||||
"Execute a SQL query on a database connection (max 100 rows returned)",
|
||||
{
|
||||
connection_name: z.string().describe("Name of the DBX connection"),
|
||||
connection_name: z.string().optional().describe("Name of the DBX connection"),
|
||||
database: z.string().optional().describe("Database name"),
|
||||
sql: z.string().describe("SQL query to execute"),
|
||||
},
|
||||
async ({ connection_name, database, sql }) => {
|
||||
const config = await backend.findConnection(connection_name);
|
||||
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
|
||||
if (config.db_type !== "mongodb") {
|
||||
const { config, error } = await resolveConnection(backend, scope, connection_name);
|
||||
if (error) return error;
|
||||
const scopedConfig = config!;
|
||||
if (scopedConfig.db_type !== "mongodb") {
|
||||
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true });
|
||||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
|
||||
}
|
||||
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
|
||||
// (node-core executeQuery) applies command-aware read/write gating.
|
||||
try {
|
||||
const statements = config.db_type === "mongodb" ? [sql] : splitSqlStatements(sql);
|
||||
const statements = scopedConfig.db_type === "mongodb" ? [sql] : splitSqlStatements(sql);
|
||||
const results = [];
|
||||
for (const statement of statements) {
|
||||
results.push(await backend.executeQuery(withDatabase(config, database), statement));
|
||||
results.push(await backend.executeQuery(withDatabase(scopedConfig, database ?? scope.database), statement));
|
||||
}
|
||||
if (results.length === 1) return formatQueryToolResult(results[0]);
|
||||
return text(results.map((result, index) => formatQueryToolResult(result, `Statement ${index + 1}`).content[0].text).join("\n\n"));
|
||||
@@ -136,16 +187,16 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
||||
"dbx_get_schema_context",
|
||||
"Get compact table and column context for writing SQL",
|
||||
{
|
||||
connection_name: z.string().describe("Name of the DBX connection"),
|
||||
connection_name: z.string().optional().describe("Name of the DBX connection"),
|
||||
database: z.string().optional().describe("Database name"),
|
||||
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"),
|
||||
tables: z.array(z.string()).optional().describe("Specific table names to include"),
|
||||
max_tables: z.number().int().min(1).max(20).default(8).describe("Maximum number of tables to include"),
|
||||
},
|
||||
async ({ connection_name, database, schema, tables, max_tables }) => {
|
||||
const config = await backend.findConnection(connection_name);
|
||||
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
|
||||
const context = await buildSchemaContext(backend, withDatabase(config, database), {
|
||||
const { config, error } = await resolveConnection(backend, scope, connection_name);
|
||||
if (error) return error;
|
||||
const context = await buildSchemaContext(backend, withDatabase(config!, database ?? scope.database), {
|
||||
schema,
|
||||
tables,
|
||||
maxTables: max_tables,
|
||||
@@ -155,65 +206,67 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"dbx_add_connection",
|
||||
"Add a new database connection to DBX",
|
||||
{
|
||||
name: z.string().describe("Connection name"),
|
||||
db_type: z.string().describe(DBX_CONNECTION_TYPE_DESCRIPTION),
|
||||
host: z.string().describe("Database host"),
|
||||
port: z.number().optional().describe("Database port (TDengine defaults to 6041, IoTDB defaults to 6667, XuguDB defaults to 5138)"),
|
||||
username: z.string().default("").describe("Username"),
|
||||
password: z.string().default("").describe("Password"),
|
||||
database: z.string().optional().describe("Default database name"),
|
||||
ssl: z.boolean().default(false).describe("Enable SSL"),
|
||||
driver_profile: z.string().optional().describe("Driver profile (e.g. 'gbase8a', 'gbase8s')"),
|
||||
},
|
||||
async ({ name, db_type, host, port, username, password, database, ssl, driver_profile }) => {
|
||||
const existing = await backend.findConnection(name);
|
||||
if (existing) return text(`Connection "${name}" already exists.`);
|
||||
const DEFAULT_PORTS: Record<string, number> = {
|
||||
kwdb: 26257,
|
||||
rqlite: 4001,
|
||||
tdengine: 6041,
|
||||
iotdb: 6667,
|
||||
xugu: 5138,
|
||||
};
|
||||
const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_CAPABLE_CONNECTION_TYPES.has(db_type) ? 0 : undefined);
|
||||
if (resolvedPort === undefined) return text("Port is required for this database type.");
|
||||
const config = await backend.addConnection({
|
||||
name,
|
||||
db_type,
|
||||
host,
|
||||
port: resolvedPort,
|
||||
username,
|
||||
password,
|
||||
database,
|
||||
ssl,
|
||||
driver_profile,
|
||||
ssh_enabled: false,
|
||||
} as Omit<ConnectionConfig, "id">);
|
||||
await notifyReload();
|
||||
return text(`Connection "${config.name}" added (id: ${config.id}).`);
|
||||
},
|
||||
);
|
||||
if (!scoped) {
|
||||
server.tool(
|
||||
"dbx_add_connection",
|
||||
"Add a new database connection to DBX",
|
||||
{
|
||||
name: z.string().describe("Connection name"),
|
||||
db_type: z.string().describe(DBX_CONNECTION_TYPE_DESCRIPTION),
|
||||
host: z.string().describe("Database host"),
|
||||
port: z.number().optional().describe("Database port (TDengine defaults to 6041, IoTDB defaults to 6667, XuguDB defaults to 5138)"),
|
||||
username: z.string().default("").describe("Username"),
|
||||
password: z.string().default("").describe("Password"),
|
||||
database: z.string().optional().describe("Default database name"),
|
||||
ssl: z.boolean().default(false).describe("Enable SSL"),
|
||||
driver_profile: z.string().optional().describe("Driver profile (e.g. 'gbase8a', 'gbase8s')"),
|
||||
},
|
||||
async ({ name, db_type, host, port, username, password, database, ssl, driver_profile }) => {
|
||||
const existing = await backend.findConnection(name);
|
||||
if (existing) return text(`Connection "${name}" already exists.`);
|
||||
const DEFAULT_PORTS: Record<string, number> = {
|
||||
kwdb: 26257,
|
||||
rqlite: 4001,
|
||||
tdengine: 6041,
|
||||
iotdb: 6667,
|
||||
xugu: 5138,
|
||||
};
|
||||
const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_CAPABLE_CONNECTION_TYPES.has(db_type) ? 0 : undefined);
|
||||
if (resolvedPort === undefined) return text("Port is required for this database type.");
|
||||
const config = await backend.addConnection({
|
||||
name,
|
||||
db_type,
|
||||
host,
|
||||
port: resolvedPort,
|
||||
username,
|
||||
password,
|
||||
database,
|
||||
ssl,
|
||||
driver_profile,
|
||||
ssh_enabled: false,
|
||||
} as Omit<ConnectionConfig, "id">);
|
||||
await notifyReload();
|
||||
return text(`Connection "${config.name}" added (id: ${config.id}).`);
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"dbx_remove_connection",
|
||||
"Remove a database connection from DBX",
|
||||
{
|
||||
connection_name: z.string().describe("Name of the connection to remove"),
|
||||
},
|
||||
async ({ connection_name }) => {
|
||||
const removed = await backend.removeConnection(connection_name);
|
||||
if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
|
||||
await notifyReload();
|
||||
return text(`Connection "${connection_name}" removed.`);
|
||||
},
|
||||
);
|
||||
server.tool(
|
||||
"dbx_remove_connection",
|
||||
"Remove a database connection from DBX",
|
||||
{
|
||||
connection_name: z.string().describe("Name of the connection to remove"),
|
||||
},
|
||||
async ({ connection_name }) => {
|
||||
const removed = await backend.removeConnection(connection_name);
|
||||
if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
|
||||
await notifyReload();
|
||||
return text(`Connection "${connection_name}" removed.`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop-only tools: open table and execute-and-show require the Tauri bridge
|
||||
if (!isWebMode) {
|
||||
if (!isWebMode && !scoped) {
|
||||
server.tool(
|
||||
"dbx_open_table",
|
||||
"Open a table in DBX desktop app UI. Requires DBX to be running.",
|
||||
|
||||
@@ -29,6 +29,22 @@ const backend: Backend = {
|
||||
executeQuery: async () => ({ columns: ["total"], rows: [{ total: 1 }], row_count: 1 }),
|
||||
};
|
||||
|
||||
async function withScopedEnv<T>(env: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
|
||||
const oldValues = new Map<string, string | undefined>();
|
||||
for (const key of Object.keys(env)) {
|
||||
oldValues.set(key, process.env[key]);
|
||||
process.env[key] = env[key];
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const [key, value] of oldValues) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("creates an MCP server without starting stdio transport", () => {
|
||||
const server = createDbxMcpServer(backend, { isWebMode: true });
|
||||
|
||||
@@ -107,6 +123,75 @@ test("execute query reports the blocked statement number for unsafe multi-statem
|
||||
assert.match(result.content[0].text, /WHERE/);
|
||||
});
|
||||
|
||||
test("scoped MCP lists only the active connection", async () => {
|
||||
const other: ConnectionConfig = { ...connection, id: "2", name: "other", database: "other_db" };
|
||||
const scopedBackend: Backend = {
|
||||
...backend,
|
||||
loadConnections: async () => [connection, other],
|
||||
};
|
||||
|
||||
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
|
||||
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
||||
return (server as any)._registeredTools.dbx_list_connections.handler({});
|
||||
});
|
||||
|
||||
assert.match(result.content[0].text, /local/);
|
||||
assert.doesNotMatch(result.content[0].text, /other/);
|
||||
});
|
||||
|
||||
test("scoped MCP rejects out-of-scope connection tool calls", async () => {
|
||||
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
|
||||
const server = createDbxMcpServer(backend, { isWebMode: true });
|
||||
return (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "other" });
|
||||
});
|
||||
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(result.content[0].text, /CONNECTION_OUT_OF_SCOPE:/);
|
||||
});
|
||||
|
||||
test("scoped MCP defaults connection-taking tools to the active connection and database", async () => {
|
||||
let usedDatabase = "";
|
||||
const scopedBackend: Backend = {
|
||||
...backend,
|
||||
listTables: async (config) => {
|
||||
usedDatabase = config.database || "";
|
||||
return [{ name: "users", type: "BASE TABLE" }];
|
||||
},
|
||||
};
|
||||
|
||||
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_SCOPE_DATABASE: "scoped_db" }, () => {
|
||||
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
||||
return (server as any)._registeredTools.dbx_list_tables.handler({});
|
||||
});
|
||||
|
||||
assert.match(result.content[0].text, /users/);
|
||||
assert.equal(usedDatabase, "scoped_db");
|
||||
});
|
||||
|
||||
test("scoped MCP does not register mutation or desktop bridge tools", async () => {
|
||||
await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
|
||||
const server = createDbxMcpServer(backend, { isWebMode: false });
|
||||
const tools = (server as any)._registeredTools;
|
||||
|
||||
assert.equal(tools.dbx_add_connection, undefined);
|
||||
assert.equal(tools.dbx_remove_connection, undefined);
|
||||
assert.equal(tools.dbx_open_table, undefined);
|
||||
assert.equal(tools.dbx_execute_and_show, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("scoped MCP with writes disabled blocks write SQL", async () => {
|
||||
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_ALLOW_WRITES: "0" }, () => {
|
||||
const server = createDbxMcpServer(backend, { isWebMode: true });
|
||||
return (server as any)._registeredTools.dbx_execute_query.handler({
|
||||
sql: "update users set name = 'x' where id = 1",
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(result.content[0].text, /SQL_BLOCKED:/);
|
||||
});
|
||||
|
||||
test("mongodb list tables returns collections from the selected database", async () => {
|
||||
let usedDatabase = "";
|
||||
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb", database: "admin" };
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true
|
||||
|
||||
@@ -44,6 +44,7 @@ pub async fn ai_stream(app: AppHandle, session_id: String, request: AiCompletion
|
||||
|
||||
use dbx_core::agent_events::AgentEvent;
|
||||
use dbx_core::agent_loop::{run_agent_loop, AgentLoopContext};
|
||||
use dbx_core::ai_cli_agent::CliAgentCommandSpec;
|
||||
use dbx_core::models::connection::DatabaseType;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -68,7 +69,18 @@ pub async fn ai_agent_stream(
|
||||
let parsed_db_type: DatabaseType =
|
||||
serde_json::from_str(&format!("\"{}\"", db_type)).map_err(|_| format!("Unknown database type: {db_type}"))?;
|
||||
|
||||
let agent_ctx = AgentLoopContext { state: state.inner().clone(), connection_id, database, db_type: parsed_db_type };
|
||||
let cli_mcp_server_command = if matches!(request.config.provider, AiProvider::CodexCli) {
|
||||
super::mcp::resolve_mcp_server_command().map(|(program, args)| CliAgentCommandSpec { program, args })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let agent_ctx = AgentLoopContext {
|
||||
state: state.inner().clone(),
|
||||
connection_id,
|
||||
database,
|
||||
db_type: parsed_db_type,
|
||||
cli_mcp_server_command,
|
||||
};
|
||||
let is_agent_mode = mode.as_deref() == Some("agent");
|
||||
|
||||
let result = run_agent_loop(
|
||||
|
||||
@@ -114,6 +114,12 @@ fn installed_mcp_version() -> Option<String> {
|
||||
value.get("version")?.as_str().map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_mcp_server_command() -> Option<(String, Vec<String>)> {
|
||||
locate_mcp_bin()
|
||||
.map(|path| (path, Vec::new()))
|
||||
.or_else(|| installed_mcp_bin_script().map(|script| ("node".to_string(), vec![script])))
|
||||
}
|
||||
|
||||
fn locate_mcp_bin() -> Option<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -125,6 +131,12 @@ fn locate_mcp_bin() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_mcp_bin_script() -> Option<String> {
|
||||
let root = command_stdout("npm", &["root", "-g"]).ok()?;
|
||||
let script = Path::new(root.trim()).join(MCP_PACKAGE_NAME).join("dist").join("index.js");
|
||||
script.is_file().then(|| script.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn locate_windows_command(command: &str) -> Option<String> {
|
||||
command_stdout("where", &[command]).ok().and_then(first_non_empty_line).or_else(|| {
|
||||
|
||||
Reference in New Issue
Block a user