feat(provider): run OpenAI-compatible models through Claude Code proxy

Replace the hand-written direct-provider runtime with a local Anthropic-to-OpenAI-compatible proxy so Qwen, DeepSeek, GLM, Gemini, and other compatible providers use the Claude Code CLI tool/session experience.

Normalize strict OpenAI tool_call/tool_result pairing to avoid provider 400 errors, keep session titles stable and synced, allow switching sessions while another chat is streaming, show running indicators, add provider deletion with confirmation, and auto-refresh the file sidebar with refresh feedback.
This commit is contained in:
Gerard-Devlin 2026-06-09 20:41:20 +08:00
parent 1b8ec314e5
commit 07c8acaeb6
10 changed files with 1843 additions and 6685 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
#![recursion_limit = "512"]
mod anthropic_proxy;
mod claude;
mod history;
mod latex;
@ -606,6 +607,7 @@ pub fn run() {
claude::list_openai_compatible_credential_models,
claude::clear_anthropic_api_key,
claude::list_openai_compatible_credentials,
claude::delete_openai_compatible_credential,
claude::set_active_openai_compatible_credential,
claude::execute_claude_code,
claude::continue_claude_code,

View file

@ -139,7 +139,7 @@ function WorkspaceWithClaude() {
return;
}
current.resumeSession(latest.session_id).catch((err) => {
current.resumeSession(latest.session_id, latest.title).catch((err) => {
log.warn("Failed to auto-resume latest chat session", {
sessionId: latest.session_id,
error: String(err),

View file

@ -1,5 +1,6 @@
import {
type FC,
type KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
@ -25,6 +26,8 @@ import {
RabbitIcon,
LayersIcon,
PlusIcon,
Trash2Icon,
Loader2Icon,
} from "lucide-react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs";
@ -35,7 +38,10 @@ import {
useClaudeChatStore,
offsetToLineCol,
} from "@/stores/claude-chat-store";
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
import {
useClaudeSetupStore,
type OpenAiCompatibleCredentialInfo,
} from "@/stores/claude-setup-store";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { getUniqueTargetName } from "@/lib/tauri/fs";
import { createPdfTextSidecar, isPdfPath } from "@/lib/pdf-text-extractor";
@ -49,9 +55,11 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker";
import { createLogger } from "@/lib/debug/logger";
@ -110,6 +118,9 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
const setActiveApiCredential = useClaudeSetupStore(
(s) => s.setActiveApiCredential,
);
const deleteApiCredential = useClaudeSetupStore(
(s) => s.deleteApiCredential,
);
const activeProviderCredential =
openAiCredentials.find(
(credential) => credential.id === activeOpenAiCredentialId,
@ -156,6 +167,14 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
null,
);
const [providerSetupOpen, setProviderSetupOpen] = useState(false);
const [providerDeleteTarget, setProviderDeleteTarget] =
useState<OpenAiCompatibleCredentialInfo | null>(null);
const [providerDeleteError, setProviderDeleteError] = useState<string | null>(
null,
);
const [deletingProviderId, setDeletingProviderId] = useState<string | null>(
null,
);
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -202,6 +221,57 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
setSelectedProviderCredentialId,
]);
const handleDeleteProviderCredential = useCallback(
async (credentialId: string) => {
if (deletingProviderId) return;
const remainingCredentials = openAiCredentials.filter(
(credential) => credential.id !== credentialId,
);
const deletingSelected =
selectedProviderCredentialId === credentialId ||
selectedProviderCredential?.id === credentialId;
setDeletingProviderId(credentialId);
setProviderDeleteError(null);
try {
const success = await deleteApiCredential(credentialId);
if (!success) {
setProviderDeleteError("Failed to delete this provider.");
return;
}
setProviderModelOptions((prev) => {
const next = { ...prev };
delete next[credentialId];
return next;
});
if (deletingSelected) {
const nextCredential = remainingCredentials[0] ?? null;
if (nextCredential) {
setSelectedProviderCredentialId(nextCredential.id);
void setActiveApiCredential(nextCredential.id);
} else {
setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID);
}
}
setProviderDeleteTarget(null);
} finally {
setDeletingProviderId(null);
}
},
[
deleteApiCredential,
deletingProviderId,
openAiCredentials,
selectedProviderCredential?.id,
selectedProviderCredentialId,
setActiveApiCredential,
setSelectedProviderCredentialId,
],
);
useEffect(() => {
if (!modelPickerOpen || !selectedProviderCredential) return;
@ -990,18 +1060,31 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
const currentModel =
selectedProviderModels[credential.id] || credential.model;
const isDeleting = deletingProviderId === credential.id;
const selectCredential = () => {
if (isDeleting) return;
setSelectedProviderCredentialId(credential.id);
void setActiveApiCredential(credential.id);
};
return (
<button
<div
key={credential.id}
role="button"
tabIndex={0}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
"group/provider flex w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pr-1 pl-3 text-left text-sm transition-colors",
active
? "bg-accent text-accent-foreground"
: "hover:bg-muted",
isDeleting && "pointer-events-none opacity-70",
)}
onClick={() => {
setSelectedProviderCredentialId(credential.id);
void setActiveApiCredential(credential.id);
onClick={selectCredential}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectCredential();
}
}}
>
{iconSrc ? (
@ -1021,8 +1104,29 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
{currentModel}
</div>
</div>
{active && <CheckIcon className="size-3 shrink-0" />}
</button>
<div className="flex shrink-0 items-center gap-1">
{active && <CheckIcon className="size-3 shrink-0" />}
<button
type="button"
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Delete ${displayName}`}
title="Delete provider"
disabled={!!deletingProviderId}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setProviderDeleteError(null);
setProviderDeleteTarget(credential);
}}
>
{isDeleting ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
</button>
</div>
</div>
);
})}
<button
@ -1169,6 +1273,73 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
</DialogContent>
</Dialog>
<Dialog
open={!!providerDeleteTarget}
onOpenChange={(open) => {
if (!open && !deletingProviderId) {
setProviderDeleteTarget(null);
setProviderDeleteError(null);
}
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Provider</DialogTitle>
<DialogDescription>
Delete{" "}
<span className="font-medium text-foreground">
{providerDeleteTarget
? getProviderDisplayName({
label: providerDeleteTarget.label,
baseUrl: providerDeleteTarget.base_url,
model: providerDeleteTarget.model,
})
: "this provider"}
</span>{" "}
with model{" "}
<span className="font-mono text-foreground">
{providerDeleteTarget?.model || "unknown"}
</span>
? The API key will be removed from ClaudePrism.
</DialogDescription>
</DialogHeader>
{providerDeleteError && (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-xs">
{providerDeleteError}
</p>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
if (deletingProviderId) return;
setProviderDeleteTarget(null);
setProviderDeleteError(null);
}}
disabled={!!deletingProviderId}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (providerDeleteTarget) {
void handleDeleteProviderCredential(providerDeleteTarget.id);
}
}}
disabled={!providerDeleteTarget || !!deletingProviderId}
>
{deletingProviderId ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* @ mention dropdown */}
{slashQuery === null &&
mentionQuery !== null &&

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
HistoryIcon,
@ -58,10 +58,20 @@ export function SessionSelector() {
);
const [deleteError, setDeleteError] = useState<string | null>(null);
const sessionId = useClaudeChatStore((s) => s.sessionId);
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const tabs = useClaudeChatStore((s) => s.tabs);
const newSession = useClaudeChatStore((s) => s.newSession);
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
const setSessionTitle = useClaudeChatStore((s) => s._setSessionTitle);
const projectRoot = useDocumentStore((s) => s.projectRoot);
const streamingSessionIds = useMemo(
() =>
new Set(
tabs
.filter((tab) => tab.isStreaming && tab.sessionId)
.map((tab) => tab.sessionId as string),
),
[tabs],
);
const loadSessions = useCallback(async () => {
if (!projectRoot) return;
@ -73,13 +83,16 @@ export function SessionSelector() {
});
log.debug("loaded sessions", { count: result.length });
setSessions(result);
for (const session of result) {
setSessionTitle(session.session_id, session.title);
}
} catch (err) {
log.error("Failed to load sessions", { error: String(err) });
setSessions([]);
} finally {
setIsLoading(false);
}
}, [projectRoot]);
}, [projectRoot, setSessionTitle]);
const handleOpenChange = useCallback(
(open: boolean) => {
@ -91,18 +104,18 @@ export function SessionSelector() {
);
const handleSelectSession = useCallback(
(sid: string) => {
if (isStreaming) return;
if (sid === sessionId) return;
log.debug(`selecting session: ${sid}`);
resumeSession(sid);
(session: ClaudeSessionInfo) => {
if (deletingId === session.session_id) return;
if (session.session_id === sessionId) return;
log.debug(`selecting session: ${session.session_id}`);
resumeSession(session.session_id, session.title);
},
[isStreaming, sessionId, resumeSession],
[deletingId, sessionId, resumeSession],
);
const handleDeleteSession = useCallback(
async (sid: string) => {
if (isStreaming || deletingId || !projectRoot) return;
if (deletingId || !projectRoot || streamingSessionIds.has(sid)) return;
setDeleteError(null);
setDeletingId(sid);
@ -128,13 +141,12 @@ export function SessionSelector() {
setDeletingId((current) => (current === sid ? null : current));
}
},
[deletingId, isStreaming, newSession, projectRoot, sessionId],
[deletingId, newSession, projectRoot, sessionId, streamingSessionIds],
);
const handleNewChat = useCallback(() => {
if (isStreaming) return;
newSession();
}, [isStreaming, newSession]);
}, [newSession]);
return (
<>
@ -158,7 +170,7 @@ export function SessionSelector() {
>
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
<DropdownMenuItem onSelect={handleNewChat} disabled={isStreaming}>
<DropdownMenuItem onSelect={handleNewChat}>
<PlusIcon className="size-4" />
<span>New Chat</span>
</DropdownMenuItem>
@ -174,46 +186,62 @@ export function SessionSelector() {
No previous sessions
</div>
) : (
sessions.map((session) => (
<DropdownMenuItem
key={session.session_id}
onSelect={() => handleSelectSession(session.session_id)}
disabled={isStreaming || deletingId === session.session_id}
className="group flex items-start gap-2"
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{session.title}</span>
<span className="text-muted-foreground text-xs">
{formatRelativeTime(session.last_modified)}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{session.session_id === sessionId && (
<CheckIcon className="size-4 text-primary" />
)}
<button
type="button"
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Delete ${session.title}`}
title="Delete session"
disabled={isStreaming || deletingId === session.session_id}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setDeleteError(null);
setDeleteTarget(session);
}}
>
{deletingId === session.session_id ? (
<Loader2Icon className="size-3.5 animate-spin" />
sessions.map((session) => {
const sessionIsStreaming = streamingSessionIds.has(
session.session_id,
);
return (
<DropdownMenuItem
key={session.session_id}
onSelect={() => handleSelectSession(session)}
disabled={deletingId === session.session_id}
className="group flex items-start gap-2"
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{session.title}</span>
<span className="text-muted-foreground text-xs">
{formatRelativeTime(session.last_modified)}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{sessionIsStreaming ? (
<Loader2Icon className="size-4 animate-spin text-primary" />
) : (
<Trash2Icon className="size-3.5" />
session.session_id === sessionId && (
<CheckIcon className="size-4 text-primary" />
)
)}
</button>
</div>
</DropdownMenuItem>
))
<button
type="button"
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
aria-label={`Delete ${session.title}`}
title={
sessionIsStreaming
? "Cannot delete a running session"
: "Delete session"
}
disabled={
sessionIsStreaming ||
deletingId === session.session_id
}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setDeleteError(null);
setDeleteTarget(session);
}}
>
{deletingId === session.session_id ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
</button>
</div>
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
@ -259,7 +287,11 @@ export function SessionSelector() {
void handleDeleteSession(deleteTarget.session_id);
}
}}
disabled={!deleteTarget || !!deletingId}
disabled={
!deleteTarget ||
!!deletingId ||
streamingSessionIds.has(deleteTarget.session_id)
}
>
{deletingId ? (
<Loader2Icon className="size-3.5 animate-spin" />

View file

@ -74,6 +74,8 @@ import { UvSetupDialog } from "@/components/uv-setup";
import { createLogger } from "@/lib/debug/logger";
const log = createLogger("sidebar");
const FILES_AUTO_REFRESH_INTERVAL_MS = 12_000;
const FILES_REFRESH_MIN_SPIN_MS = 400;
// ─── Table of Contents ───
@ -232,12 +234,73 @@ export function Sidebar() {
const refreshFiles = useDocumentStore((s) => s.refreshFiles);
const projectRoot = useDocumentStore((s) => s.projectRoot);
const folders = useDocumentStore((s) => s.folders);
const [isRefreshingFiles, setIsRefreshingFiles] = useState(false);
const refreshFilesInFlightRef = useRef<Promise<void> | null>(null);
const { theme, setTheme } = useTheme();
const projectName = useMemo(() => {
const normalized = projectRoot?.replace(/[\\/]+$/, "");
return normalized?.split(/[/\\]/).pop() || "Desktop";
}, [projectRoot]);
const runRefreshFiles = useCallback(
async ({ showSpinner = true }: { showSpinner?: boolean } = {}) => {
if (!projectRoot) return;
if (refreshFilesInFlightRef.current) {
await refreshFilesInFlightRef.current;
return;
}
const startedAt = Date.now();
if (showSpinner) setIsRefreshingFiles(true);
const refreshTask = (async () => {
try {
await refreshFiles();
} catch (err) {
log.error("Refresh files failed", { error: String(err) });
} finally {
if (showSpinner) {
const elapsed = Date.now() - startedAt;
if (elapsed < FILES_REFRESH_MIN_SPIN_MS) {
await new Promise((resolve) =>
window.setTimeout(resolve, FILES_REFRESH_MIN_SPIN_MS - elapsed),
);
}
setIsRefreshingFiles(false);
}
refreshFilesInFlightRef.current = null;
}
})();
refreshFilesInFlightRef.current = refreshTask;
await refreshTask;
},
[projectRoot, refreshFiles],
);
useEffect(() => {
if (!projectRoot) return;
const refreshIfVisible = () => {
if (document.visibilityState === "visible") {
void runRefreshFiles({ showSpinner: false });
}
};
const intervalId = window.setInterval(
refreshIfVisible,
FILES_AUTO_REFRESH_INTERVAL_MS,
);
window.addEventListener("focus", refreshIfVisible);
document.addEventListener("visibilitychange", refreshIfVisible);
return () => {
window.clearInterval(intervalId);
window.removeEventListener("focus", refreshIfVisible);
document.removeEventListener("visibilitychange", refreshIfVisible);
};
}, [projectRoot, runRefreshFiles]);
// ─── Native OS file drop (Tauri onDragDropEvent) ───
const sidebarFilesRef = useRef<HTMLDivElement>(null);
const nativeDropTargetRef = useRef<string | null>(null);
@ -701,9 +764,12 @@ export function Sidebar() {
size="icon"
className="size-5"
title="Refresh"
onClick={() => refreshFiles()}
disabled={isRefreshingFiles}
onClick={() => void runRefreshFiles()}
>
<RefreshCwIcon className="size-3" />
<RefreshCwIcon
className={cn("size-3", isRefreshingFiles && "animate-spin")}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -36,6 +36,12 @@ interface ClaudeErrorPayload {
data: string;
}
interface ClaudeSessionInfo {
session_id: string;
title: string;
last_modified: number;
}
/**
* Hook that manages Tauri event listeners for Claude CLI streaming output.
*
@ -386,10 +392,37 @@ export function useClaudeEvents() {
lastErrorRef.current.delete(tabId);
directProviderTabRef.current.delete(tabId);
const completedSessionId = tab.sessionId;
chatStore._setStreaming(tabId, false);
// Snapshot after Claude edit
const projectPath = useDocumentStore.getState().projectRoot;
if (projectPath && completedSessionId) {
void (async () => {
try {
const sessions = await invoke<ClaudeSessionInfo[]>(
"list_claude_sessions",
{
projectPath,
generateTitles: true,
},
);
const session = sessions.find(
(item) => item.session_id === completedSessionId,
);
if (session?.title) {
useClaudeChatStore
.getState()
._setSessionTitle(completedSessionId, session.title);
}
} catch (err) {
log.warn("failed to refresh completed session title", {
error: String(err),
});
}
})();
}
if (projectPath) {
try {
await useHistoryStore

View file

@ -493,7 +493,7 @@ interface ClaudeChatState {
cancelExecution: () => Promise<void>;
clearMessages: () => void;
newSession: () => void;
resumeSession: (sessionId: string) => Promise<void>;
resumeSession: (sessionId: string, title?: string) => Promise<void>;
// Tab actions
createTab: () => string;
@ -507,6 +507,7 @@ interface ClaudeChatState {
// Internal actions (called by event hook, routed by tabId)
_appendMessage: (tabId: string, msg: ClaudeStreamMessage) => void;
_setSessionId: (tabId: string, id: string) => void;
_setSessionTitle: (sessionId: string, title: string) => void;
_setStreaming: (tabId: string, streaming: boolean) => void;
_setError: (tabId: string, error: string | null) => void;
_cancelledByUser: boolean;
@ -801,7 +802,24 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
newSession: () => {
log.info("Starting new session");
const { activeTabId } = get();
const { activeTabId, tabs } = get();
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab?.isStreaming) {
const id = nextTabId();
const newTab = makeDefaultTab(id);
set({
tabs: [...tabs, newTab],
activeTabId: id,
messages: newTab.messages,
sessionId: newTab.sessionId,
isStreaming: newTab.isStreaming,
error: newTab.error,
totalInputTokens: newTab.totalInputTokens,
totalOutputTokens: newTab.totalOutputTokens,
});
return;
}
set((s) =>
applyTabUpdate(s, activeTabId, {
messages: [],
@ -816,9 +834,51 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
);
},
resumeSession: async (sessionId: string) => {
resumeSession: async (sessionId: string, title?: string) => {
log.info(`Resuming session: ${sessionId.slice(0, 8)}`);
const { activeTabId } = get();
const sessionTitle = title?.trim() || undefined;
const state = get();
let { activeTabId } = state;
let { tabs } = state;
const existingTab = tabs.find((tab) => tab.sessionId === sessionId);
if (existingTab) {
const nextTitle = sessionTitle ?? existingTab.title;
activeTabId = existingTab.id;
const nextTabs = tabs.map((tab) =>
tab.id === existingTab.id ? { ...tab, title: nextTitle } : tab,
);
set({
tabs: nextTabs,
activeTabId: existingTab.id,
messages: existingTab.messages,
sessionId: existingTab.sessionId,
isStreaming: existingTab.isStreaming,
error: existingTab.error,
totalInputTokens: existingTab.totalInputTokens,
totalOutputTokens: existingTab.totalOutputTokens,
});
if (existingTab.isStreaming) return;
} else {
const activeTab = tabs.find((tab) => tab.id === activeTabId);
if (activeTab?.isStreaming) {
const id = nextTabId();
const newTab = makeDefaultTab(id);
tabs = [...tabs, newTab];
activeTabId = id;
set({
tabs,
activeTabId,
messages: newTab.messages,
sessionId: newTab.sessionId,
isStreaming: newTab.isStreaming,
error: newTab.error,
totalInputTokens: newTab.totalInputTokens,
totalOutputTokens: newTab.totalOutputTokens,
});
}
}
const projectPath = useDocumentStore.getState().projectRoot;
// Reset state with new session ID
@ -831,6 +891,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
isStreaming: false,
totalInputTokens: 0,
totalOutputTokens: 0,
title: sessionTitle ?? "New Chat",
}),
);
@ -862,7 +923,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
...applyTabUpdate(s, activeTabId, {
messages,
providerKey,
title: titleForMessages(messages) ?? "New Chat",
title: sessionTitle ?? titleForMessages(messages) ?? "New Chat",
totalInputTokens: totals.inputTokens,
totalOutputTokens: totals.outputTokens,
}),
@ -1008,6 +1069,16 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
set((state) => applyTabUpdate(state, tabId, { sessionId: id }));
},
_setSessionTitle: (sessionId: string, title: string) => {
const cleanTitle = title.trim();
if (!cleanTitle) return;
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.sessionId === sessionId ? { ...tab, title: cleanTitle } : tab,
),
}));
},
_setStreaming: (tabId: string, streaming: boolean) => {
set((state) => applyTabUpdate(state, tabId, { isStreaming: streaming }));
},

View file

@ -74,6 +74,7 @@ interface ClaudeSetupState {
) => Promise<boolean>;
clearApiKey: () => Promise<boolean>;
listApiCredentials: () => Promise<void>;
deleteApiCredential: (credentialId: string) => Promise<boolean>;
setActiveApiCredential: (credentialId: string) => Promise<boolean>;
fetchProviderModels: (apiKey: string, baseUrl: string) => Promise<string[]>;
toggleInstallLogs: () => void;
@ -392,6 +393,19 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
}));
},
deleteApiCredential: async (credentialId: string) => {
try {
await invoke("delete_openai_compatible_credential", {
credentialId,
});
await get().checkStatus();
return true;
} catch (err: any) {
set({ error: err?.message || String(err) });
return false;
}
},
setActiveApiCredential: async (credentialId: string) => {
try {
await invoke("set_active_openai_compatible_credential", {