add version manager

This commit is contained in:
musistudio
2025-11-15 17:54:48 +08:00
parent ab03390e2f
commit af2ddecad0
10 changed files with 946 additions and 35 deletions

View File

@@ -0,0 +1,190 @@
import { existsSync, mkdirSync, rmSync } from 'fs';
import { join, dirname } from 'path';
import { execSync } from 'child_process';
interface DownloadOptions {
version?: string;
destination?: string;
onProgress?: (message: string) => void;
}
export class NpmDownloader {
private readonly packageName = '@anthropic-ai/claude-code';
/**
* 从tarball文件路径提取版本号
*/
private extractVersionFromTarballPath(tarballPath: string): string {
const tarballName = tarballPath.split('/').pop() || '';
// tarball文件名格式: anthropic-ai-claude-code-1.0.0.tgz
const match = tarballName.match(/anthropic-ai-claude-code-(.+)\.tgz$/);
if (match && match[1]) {
return match[1];
}
throw new Error(`Could not extract version from tarball path: ${tarballPath}`);
}
/**
* 获取可用版本列表
*/
async getAvailableVersions(): Promise<string[]> {
try {
const command = `npm view ${this.packageName} versions --json`;
const result = execSync(command, { encoding: 'utf8', stdio: 'pipe' });
const versions = JSON.parse(result);
return Array.isArray(versions) ? versions : [];
} catch (error: any) {
throw new Error(`Failed to get available versions: ${error.message}`);
}
}
/**
* 获取最新版本
*/
async getLatestVersion(): Promise<string> {
try {
const command = `npm view ${this.packageName} version`;
const result = execSync(command, { encoding: 'utf8', stdio: 'pipe' });
return result.trim();
} catch (error: any) {
throw new Error(`Failed to get latest version: ${error.message}`);
}
}
/**
* 下载指定版本的包
*/
async downloadVersion(options: DownloadOptions = {}): Promise<string> {
const { version: requestedVersion, destination, onProgress } = options;
// 设置下载目录
const packageNameSafe = this.packageName.replace('/', '-');
const defaultDestination = join(process.cwd(), 'downloads', packageNameSafe);
const downloadDir = destination || defaultDestination;
// 确保目录存在
if (!existsSync(downloadDir)) {
mkdirSync(downloadDir, { recursive: true });
}
// 确定版本
let version = requestedVersion;
if (!version) {
version = await this.getLatestVersion();
onProgress?.(`No version specified, using latest version: ${version}`);
}
onProgress?.(`Downloading ${this.packageName}@${version}...`);
try {
// 使用 npm pack 下载包
const packCommand = `npm pack ${this.packageName}@${version}`;
onProgress?.(`Running: ${packCommand}`);
// 在下载目录中执行 npm pack
const { spawn } = require('child_process');
return new Promise((resolve, reject) => {
const process = spawn(packCommand, [], {
shell: true,
cwd: downloadDir,
stdio: 'pipe'
});
let output = '';
let errorOutput = '';
process.stdout?.on('data', (data: Buffer) => {
output += data.toString();
onProgress?.(data.toString().trim());
});
process.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
onProgress?.(`Error: ${data.toString().trim()}`);
});
process.on('close', (code: number) => {
if (code !== 0) {
reject(new Error(`npm pack failed with code ${code}: ${errorOutput}`));
return;
}
// npm pack 会输出文件名,提取文件路径
const tarballName = output.trim().split('\n').pop();
if (!tarballName) {
reject(new Error('Could not determine downloaded file name'));
return;
}
const tarballPath = join(downloadDir, tarballName);
onProgress?.(`Successfully downloaded to: ${tarballPath}`);
resolve(tarballPath);
});
process.on('error', (error: Error) => {
reject(new Error(`Failed to execute npm pack: ${error.message}`));
});
});
} catch (error: any) {
throw new Error(`Failed to download package: ${error.message}`);
}
}
/**
* 解压下载的 tarball 文件到版本号目录
*/
async extractPackage(tarballPath: string, extractTo?: string): Promise<string> {
// 如果没有指定解压目录,则使用版本号作为目录名
const extractDir = extractTo || join(dirname(tarballPath), this.extractVersionFromTarballPath(tarballPath));
// 确保解压目录存在
if (!existsSync(extractDir)) {
mkdirSync(extractDir, { recursive: true });
}
try {
// 使用 tar 命令解压到版本号目录
const extractCommand = `tar -xzf "${tarballPath}" -C "${extractDir}" --strip-components=1`;
execSync(extractCommand, { stdio: 'inherit' });
// 删除 tarball 文件
rmSync(tarballPath);
return extractDir;
} catch (error: any) {
throw new Error(`Failed to extract package: ${error.message}`);
}
}
/**
* 下载并解压包
*/
async downloadAndExtract(options: DownloadOptions = {}): Promise<string> {
const tarballPath = await this.downloadVersion(options);
return this.extractPackage(tarballPath, options.destination);
}
}
// 导出便捷函数
export async function downloadClaudeCode(version?: string, options?: Omit<DownloadOptions, 'version'>): Promise<string> {
const downloader = new NpmDownloader();
return downloader.downloadVersion({ ...options, version });
}
export async function downloadAndExtractClaudeCode(version?: string, options?: Omit<DownloadOptions, 'version'>): Promise<string> {
const downloader = new NpmDownloader();
return downloader.downloadAndExtract({ ...options, version });
}
export async function getLatestClaudeCodeVersion(): Promise<string> {
const downloader = new NpmDownloader();
return downloader.getLatestVersion();
}
export async function getAvailableClaudeCodeVersions(): Promise<string[]> {
const downloader = new NpmDownloader();
return downloader.getAvailableVersions();
}

View File

View File

@@ -3,9 +3,11 @@ import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
import { checkForUpdates, performUpdate } from "./utils";
import { join } from "path";
import fastifyStatic from "@fastify/static";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { homedir } from "os";
import {calculateTokenCount} from "./utils/router";
import { NpmDownloader } from "./codeManager/downloder";
import { HOME_DIR } from "./constants";
export const createServer = (config: any): Server => {
const server = new Server(config);
@@ -198,5 +200,195 @@ export const createServer = (config: any): Server => {
}
});
// Claude Code 版本管理相关 API
const versionsDir = join(HOME_DIR, 'versions');
const currentVersionPath = join(HOME_DIR, 'current-version.json');
// 确保版本目录存在
if (!existsSync(versionsDir)) {
mkdirSync(versionsDir, { recursive: true });
}
// 初始化下载器
const downloader = new NpmDownloader();
// 辅助函数:读取当前版本
const readCurrentVersion = () => {
try {
if (existsSync(currentVersionPath)) {
const content = readFileSync(currentVersionPath, 'utf8');
const data = JSON.parse(content);
return data.currentVersion || '';
}
} catch (error) {
console.error('Failed to read current version:', error);
}
return '';
};
// 辅助函数:保存当前版本
const saveCurrentVersion = (version: string) => {
try {
writeFileSync(currentVersionPath, JSON.stringify({ currentVersion: version }, null, 2));
} catch (error) {
console.error('Failed to save current version:', error);
}
};
// 辅助函数:获取已下载的版本列表(扫描 versions 文件夹)
const getDownloadedVersions = () => {
try {
const versions = [];
if (existsSync(versionsDir)) {
const folders = readdirSync(versionsDir);
for (const folder of folders) {
const folderPath = join(versionsDir, folder);
const stats = statSync(folderPath);
if (stats.isDirectory()) {
versions.push({
version: folder,
downloadPath: folderPath,
downloadedAt: stats.birthtime.toISOString()
});
}
}
// 按下载时间倒序排列
versions.sort((a, b) => new Date(b.downloadedAt).getTime() - new Date(a.downloadedAt).getTime());
}
return versions;
} catch (error) {
console.error('Failed to get downloaded versions:', error);
return [];
}
};
// 获取可用版本列表
server.app.get("/api/claude-code/versions", async (req, reply) => {
try {
const versions = await downloader.getAvailableVersions();
// 按版本号倒序排列(最新在前)
const sortedVersions = versions.sort((a, b) => b.localeCompare(a));
return { versions: sortedVersions };
} catch (error) {
console.error("Failed to get available versions:", error);
reply.status(500).send({ error: "Failed to get available versions" });
}
});
// 获取已下载的版本列表
server.app.get("/api/claude-code/downloaded", async (req, reply) => {
try {
const currentVersion = readCurrentVersion();
const downloadedVersions = getDownloadedVersions();
const versionsWithStatus = downloadedVersions.map(v => ({
...v,
isCurrent: v.version === currentVersion
}));
return { versions: versionsWithStatus, currentVersion };
} catch (error) {
console.error("Failed to get downloaded versions:", error);
reply.status(500).send({ error: "Failed to get downloaded versions" });
}
});
// 下载指定版本
server.app.post("/api/claude-code/download/:version", async (req, reply) => {
try {
const { version } = req.params as any;
const currentVersion = readCurrentVersion();
const downloadedVersions = getDownloadedVersions();
// 检查是否已下载
if (downloadedVersions.some((v: any) => v.version === version)) {
reply.status(400).send({ error: "Version already downloaded" });
return;
}
// 创建版本号目录
const versionDir = join(versionsDir, version);
if (!existsSync(versionDir)) {
mkdirSync(versionDir, { recursive: true });
}
// 开始下载到版本号目录
const extractedPath = await downloader.downloadAndExtract({
version,
destination: versionDir,
onProgress: (message) => console.log(`Download progress for ${version}:`, message)
});
// 如果没有当前版本,设置当前版本
if (!currentVersion) {
saveCurrentVersion(version);
}
return { success: true, version, path: extractedPath };
} catch (error) {
console.error("Failed to download version:", error);
reply.status(500).send({ error: `Failed to download version: ${(error as Error).message}` });
}
});
// 删除指定版本
server.app.post("/api/claude-code/version/delete", async (req, reply) => {
try {
const { version } = req.body as any;
const currentVersion = readCurrentVersion();
const downloadedVersions = getDownloadedVersions();
// 不能删除当前版本
if (version === currentVersion) {
reply.status(400).send({ error: "Cannot delete current version" });
return;
}
const versionDir = join(versionsDir, version);
// 检查版本目录是否存在
if (!existsSync(versionDir)) {
reply.status(404).send({ error: "Version not found" });
return;
}
// 删除版本目录
const { execSync } = require('child_process');
try {
execSync(`rm -rf "${versionDir}"`, { stdio: 'pipe' });
} catch (error) {
console.warn('Failed to delete directory for version', version, ':', error);
}
return { success: true, version };
} catch (error) {
console.error("Failed to delete version:", error);
reply.status(500).send({ error: "Failed to delete version" });
}
});
// 切换到指定版本
server.app.post("/api/claude-code/switch/:version", async (req, reply) => {
try {
const { version } = req.params as any;
const downloadedVersions = getDownloadedVersions();
// 检查版本是否已下载
const versionExists = downloadedVersions.some((v: any) => v.version === version);
if (!versionExists) {
reply.status(404).send({ error: "Version not downloaded" });
return;
}
// 更新当前版本
saveCurrentVersion(version);
return { success: true, currentVersion: version };
} catch (error) {
console.error("Failed to switch version:", error);
reply.status(500).send({ error: "Failed to switch version" });
}
});
return server;
};

View File

@@ -7,10 +7,11 @@ import { Providers } from "@/components/Providers";
import { Router } from "@/components/Router";
import { JsonEditor } from "@/components/JsonEditor";
import { LogViewer } from "@/components/LogViewer";
import { VersionManagerDialog } from "@/components/VersionManagerDialog";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/components/ConfigProvider";
import { api } from "@/lib/api";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText, Package } from "lucide-react";
import {
Popover,
PopoverContent,
@@ -34,6 +35,7 @@ function App() {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState(false);
const [isLogViewerOpen, setIsLogViewerOpen] = useState(false);
const [isVersionManagerOpen, setIsVersionManagerOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
// 版本检查状态
@@ -281,6 +283,9 @@ function App() {
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
<FileText className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsVersionManagerOpen(true)} className="transition-all-ease hover:scale-110">
<Package className="h-5 w-5" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
@@ -355,10 +360,15 @@ function App() {
onOpenChange={setIsJsonEditorOpen}
showToast={(message, type) => setToast({ message, type })}
/>
<LogViewer
open={isLogViewerOpen}
onOpenChange={setIsLogViewerOpen}
showToast={(message, type) => setToast({ message, type })}
<LogViewer
open={isLogViewerOpen}
onOpenChange={setIsLogViewerOpen}
showToast={(message, type) => setToast({ message, type })}
/>
<VersionManagerDialog
open={isVersionManagerOpen}
onOpenChange={setIsVersionManagerOpen}
showToast={(message, type) => setToast({ message, type })}
/>
{/* 版本更新对话框 */}
<Dialog open={isUpdateDialogOpen} onOpenChange={setIsUpdateDialogOpen}>

View File

@@ -0,0 +1,407 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Package,
Download,
CheckCircle,
Clock,
AlertCircle,
RefreshCw,
Trash2,
AlertTriangle
} from 'lucide-react';
import { api } from '@/lib/api';
interface Version {
version: string;
isCurrent?: boolean;
isDownloaded?: boolean;
downloadPath?: string;
status?: 'available' | 'downloading' | 'downloaded' | 'error';
}
interface VersionManagerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
}
export const VersionManagerDialog: React.FC<VersionManagerDialogProps> = ({
open,
onOpenChange,
showToast
}) => {
const { t } = useTranslation();
const [downloadedVersions, setDownloadedVersions] = useState<Version[]>([]);
const [availableVersions, setAvailableVersions] = useState<string[]>([]);
const [currentVersion, setCurrentVersion] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [downloadingVersion, setDownloadingVersion] = useState<string | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [versionToDelete, setVersionToDelete] = useState<string>('');
const [deleteError, setDeleteError] = useState<string>('');
// 加载已下载的版本数据
const loadDownloadedVersions = async () => {
try {
const response = await api.getDownloadedClaudeCodeVersions();
setDownloadedVersions(response.versions.map(v => ({
...v,
isDownloaded: true,
status: 'downloaded' as const
})));
setCurrentVersion(response.currentVersion);
} catch (error) {
console.error('Failed to load downloaded versions:', error);
showToast?.('获取已下载版本失败', 'error');
}
};
// 获取可用的版本列表
const fetchAvailableVersions = async () => {
setIsLoading(true);
try {
const response = await api.getClaudeCodeVersions();
setAvailableVersions(response.versions);
} catch (error) {
console.error('Failed to fetch available versions:', error);
showToast?.(t('version_manager.get_versions_failed'), 'error');
} finally {
setIsLoading(false);
}
};
// 下载指定版本
const downloadVersion = async (version: string) => {
setDownloadingVersion(version);
try {
const response = await api.downloadClaudeCodeVersion(version);
if (response.success) {
// 重新加载已下载版本列表
await loadDownloadedVersions();
showToast?.(`版本 ${version} 下载成功`, 'success');
} else {
showToast?.(`下载版本 ${version} 失败`, 'error');
}
} catch (error) {
console.error('Failed to download version:', error);
showToast?.(`下载版本 ${version} 失败`, 'error');
} finally {
setDownloadingVersion(null);
}
};
// 删除下载的版本
const handleDeleteVersion = async (version: string) => {
try {
const response = await api.deleteClaudeCodeVersion(version);
if (response.success) {
// 重新加载已下载版本列表
await loadDownloadedVersions();
showToast?.(`版本 ${version} 已删除`, 'success');
setDeleteConfirmOpen(false);
setVersionToDelete('');
setDeleteError('');
} else {
setDeleteError(t('version_manager.delete_server_error'));
showToast?.(t('version_manager.delete_failed', { version }), 'error');
}
} catch (error: any) {
console.error('Failed to remove version:', error);
// 只使用接口返回的 error 字段内容,不显示 HTTP 层面的错误信息
const errorMessage = error?.response?.data?.error || t('version_manager.delete_unknown_error');
setDeleteError(errorMessage);
showToast?.(t('version_manager.delete_failed', { version }), 'error');
}
};
// 触发删除确认对话框
const removeVersion = (version: string) => {
setVersionToDelete(version);
setDeleteError('');
// 使用 setTimeout 确保 state 更新完成后再打开对话框
setTimeout(() => {
setDeleteConfirmOpen(true);
}, 0);
};
// 确认删除
const confirmDelete = () => {
if (versionToDelete) {
handleDeleteVersion(versionToDelete);
}
};
// 取消删除
const cancelDelete = () => {
setDeleteConfirmOpen(false);
setVersionToDelete('');
setDeleteError('');
};
// 切换当前使用的版本
const switchToVersion = async (version: string) => {
try {
const response = await api.switchClaudeCodeVersion(version);
if (response.success) {
// 重新加载已下载版本列表
await loadDownloadedVersions();
showToast?.(`已切换到版本 ${version}`, 'success');
} else {
showToast?.(`切换到版本 ${version} 失败`, 'error');
}
} catch (error) {
console.error('Failed to switch version:', error);
showToast?.(`切换到版本 ${version} 失败`, 'error');
}
};
useEffect(() => {
if (open) {
loadDownloadedVersions();
fetchAvailableVersions();
}
}, [open]);
const getVersionIcon = (version: Version) => {
if (version.isCurrent) {
return <CheckCircle className="h-4 w-4 text-green-500" />;
}
if (version.status === 'downloading') {
return <Clock className="h-4 w-4 text-yellow-500 animate-pulse" />;
}
if (version.status === 'error') {
return <AlertCircle className="h-4 w-4 text-red-500" />;
}
return <Package className="h-4 w-4 text-blue-500" />;
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
{t('version_manager.title')}
</DialogTitle>
<DialogDescription>
{t('version_manager.description')}
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="installed" className="flex-1">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="installed">{t('version_manager.installed_versions')}</TabsTrigger>
<TabsTrigger value="available">{t('version_manager.available_versions')}</TabsTrigger>
</TabsList>
<TabsContent value="installed" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('version_manager.installed_versions')}</CardTitle>
<CardDescription>
{t('version_manager.current_version')}: <Badge variant="outline">{currentVersion}</Badge>
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
<div className="space-y-2">
{downloadedVersions.map((version) => (
<div
key={version.version}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center gap-3">
{getVersionIcon(version)}
<div>
<span className="font-medium">{version.version}</span>
{version.isCurrent && (
<Badge className="ml-2" variant="default">
{t('version_manager.current_version')}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
{!version.isCurrent && (
<Button
size="sm"
variant="outline"
onClick={() => switchToVersion(version.version)}
>
{t('version_manager.switch_to_version')}
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => removeVersion(version.version)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{downloadedVersions.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Package className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>{t('version_manager.no_downloaded_versions')}</p>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="available" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{t('version_manager.available_versions')}</CardTitle>
<Button
size="sm"
variant="outline"
onClick={fetchAvailableVersions}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
{t('version_manager.refresh')}
</Button>
</div>
<CardDescription>
{t('version_manager.download_description')}
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
<div className="grid gap-2">
{availableVersions.map((version) => {
const isDownloaded = downloadedVersions.some(v => v.version === version);
const isDownloading = downloadingVersion === version;
return (
<div
key={version}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<Package className="h-4 w-4 text-blue-500" />
<span className="font-medium">{version}</span>
{isDownloaded && (
<Badge variant="secondary">{t('version_manager.downloaded')}</Badge>
)}
</div>
<Button
size="sm"
onClick={() => downloadVersion(version)}
disabled={isDownloaded || isDownloading}
>
{isDownloading ? (
<>
<Clock className="h-4 w-4 mr-1 animate-pulse" />
{t('version_manager.downloading')}
</>
) : isDownloaded ? (
<>
<CheckCircle className="h-4 w-4 mr-1" />
{t('version_manager.downloaded')}
</>
) : (
<>
<Download className="h-4 w-4 mr-1" />
{t('version_manager.download')}
</>
)}
</Button>
</div>
);
})}
{availableVersions.length === 0 && !isLoading && (
<div className="text-center py-8 text-gray-500">
<Package className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>{t('version_manager.no_available_versions')}</p>
</div>
)}
{isLoading && (
<div className="text-center py-8 text-gray-500">
<RefreshCw className="h-12 w-12 mx-auto mb-2 animate-spin" />
<p>{t('version_manager.loading_versions')}</p>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('version_manager.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
{t('version_manager.confirm_delete_title')}
</DialogTitle>
<DialogDescription>
{(() => {
if (!versionToDelete) {
return t('version_manager.confirm_delete_message', { version: '未知版本' });
}
// 直接使用字符串插值作为后备方案以防i18next插值失败
const message = t('version_manager.confirm_delete_message', { version: versionToDelete });
if (message.includes('{version}')) {
// i18next插值失败手动替换
return message.replace('{version}', versionToDelete);
}
return message;
})()}
{deleteError && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{deleteError}</p>
</div>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={cancelDelete}>
{t('version_manager.cancel')}
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
className="text-white bg-red-600 hover:bg-red-700"
>
{t('version_manager.confirm_delete_button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn("relative overflow-auto", className)}
{...props}
>
{children}
</div>
))
ScrollArea.displayName = "ScrollArea"
export { ScrollArea }

View File

@@ -45,7 +45,7 @@ class ApiClient {
localStorage.removeItem('apiKey');
}
}
// Update temp API key
setTempApiKey(tempApiKey: string | null) {
this.tempApiKey = tempApiKey;
@@ -56,25 +56,25 @@ class ApiClient {
const headers: Record<string, string> = {
'Accept': 'application/json',
};
// Use temp API key if available, otherwise use regular API key
if (this.tempApiKey) {
headers['X-Temp-API-Key'] = this.tempApiKey;
} else if (this.apiKey) {
headers['X-API-Key'] = this.apiKey;
}
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
}
// Generic fetch wrapper with base URL and authentication
private async apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const config: RequestInit = {
...options,
headers: {
@@ -82,10 +82,10 @@ class ApiClient {
...options.headers,
},
};
try {
const response = await fetch(url, config);
// Handle 401 Unauthorized responses
if (response.status === 401) {
// Remove API key when it's invalid
@@ -99,13 +99,14 @@ class ApiClient {
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
const data = await response.json();
throw new Error(`API request failed: ${response.status} ${response.statusText} ${data?.error}`);
}
if (response.status === 204) {
return {} as T;
}
const text = await response.text();
return text ? JSON.parse(text) : ({} as T);
@@ -142,6 +143,7 @@ class ApiClient {
async delete<T>(endpoint: string): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'DELETE',
headers: this.createHeaders(''), // 不设置Content-Type因为没有请求体
});
}
@@ -150,95 +152,122 @@ class ApiClient {
async getConfig(): Promise<Config> {
return this.get<Config>('/config');
}
// Update entire configuration
async updateConfig(config: Config): Promise<Config> {
return this.post<Config>('/config', config);
}
// Get providers
async getProviders(): Promise<Provider[]> {
return this.get<Provider[]>('/api/providers');
}
// Add a new provider
async addProvider(provider: Provider): Promise<Provider> {
return this.post<Provider>('/api/providers', provider);
}
// Update a provider
async updateProvider(index: number, provider: Provider): Promise<Provider> {
return this.post<Provider>(`/api/providers/${index}`, provider);
}
// Delete a provider
async deleteProvider(index: number): Promise<void> {
return this.delete<void>(`/api/providers/${index}`);
}
// Get transformers
async getTransformers(): Promise<Transformer[]> {
return this.get<Transformer[]>('/api/transformers');
}
// Add a new transformer
async addTransformer(transformer: Transformer): Promise<Transformer> {
return this.post<Transformer>('/api/transformers', transformer);
}
// Update a transformer
async updateTransformer(index: number, transformer: Transformer): Promise<Transformer> {
return this.post<Transformer>(`/api/transformers/${index}`, transformer);
}
// Delete a transformer
async deleteTransformer(index: number): Promise<void> {
return this.delete<void>(`/api/transformers/${index}`);
}
// Get configuration (new endpoint)
async getConfigNew(): Promise<Config> {
return this.get<Config>('/config');
}
// Save configuration (new endpoint)
async saveConfig(config: Config): Promise<unknown> {
return this.post<Config>('/config', config);
}
// Restart service
async restartService(): Promise<unknown> {
return this.post<void>('/restart', {});
}
// Check for updates
async checkForUpdates(): Promise<{ hasUpdate: boolean; latestVersion?: string; changelog?: string }> {
return this.get<{ hasUpdate: boolean; latestVersion?: string; changelog?: string }>('/update/check');
}
// Perform update
async performUpdate(): Promise<{ success: boolean; message: string }> {
return this.post<{ success: boolean; message: string }>('/api/update/perform', {});
}
// Get log files list
async getLogFiles(): Promise<Array<{ name: string; path: string; size: number; lastModified: string }>> {
return this.get<Array<{ name: string; path: string; size: number; lastModified: string }>>('/logs/files');
}
// Get logs from specific file
async getLogs(filePath: string): Promise<string[]> {
return this.get<string[]>(`/logs?file=${encodeURIComponent(filePath)}`);
}
// Clear logs from specific file
async clearLogs(filePath: string): Promise<void> {
return this.delete<void>(`/logs?file=${encodeURIComponent(filePath)}`);
}
// Claude Code 版本管理 API
// 获取所有可用的 Claude Code 版本
async getClaudeCodeVersions(): Promise<{ versions: string[] }> {
return this.get<{ versions: string[] }>('/claude-code/versions');
}
// 获取已下载的 Claude Code 版本
async getDownloadedClaudeCodeVersions(): Promise<{ versions: Array<{ version: string; isCurrent: boolean; downloadPath: string; downloadedAt: string }>; currentVersion: string }> {
return this.get<{ versions: Array<{ version: string; isCurrent: boolean; downloadPath: string; downloadedAt: string }>; currentVersion: string }>('/claude-code/downloaded');
}
// 下载指定版本的 Claude Code
async downloadClaudeCodeVersion(version: string): Promise<{ success: boolean; version: string; path: string }> {
return this.post<{ success: boolean; version: string; path: string }>(`/claude-code/download/${version}`, {});
}
// 删除指定版本的 Claude Code
async deleteClaudeCodeVersion(version: string): Promise<{ success: boolean; version: string }> {
return this.post<{ success: boolean; version: string }>(`/claude-code/version/delete`, { version });
}
// 切换到指定版本的 Claude Code
async switchClaudeCodeVersion(version: string): Promise<{ success: boolean; currentVersion: string }> {
return this.post<{ success: boolean; currentVersion: string }>(`/claude-code/switch/${version}`, {});
}
}
// Create a default instance of the API client
export const api = new ApiClient();
// Export the class for creating custom instances
export default ApiClient;
export default ApiClient;

View File

@@ -224,5 +224,37 @@
"worker_init_failed": "Failed to initialize worker",
"grouping_not_supported": "Log grouping not supported by server",
"back": "Back"
},
"version_manager": {
"title": "Claude Code Version Manager",
"description": "Manage Claude Code versions, download new versions or switch between installed versions",
"installed_versions": "Installed Versions",
"available_versions": "Available Versions",
"current_version": "Current Version",
"no_available_versions": "No available versions",
"downloaded": "Downloaded",
"downloading": "Downloading",
"download": "Download",
"switch_to_version": "Switch to this version",
"delete_version": "Delete version",
"cannot_delete_current": "Cannot delete version {version}",
"download_success": "Version {version} downloaded successfully",
"download_failed": "Failed to download version {version}",
"switch_success": "Switched to version {version} successfully",
"switch_failed": "Failed to switch to version {version}",
"get_versions_failed": "Failed to get version list",
"version_deleted": "Version {version} deleted",
"delete_failed": "Failed to delete version {version}",
"confirm_delete_title": "Confirm Delete Version",
"confirm_delete_message": "Are you sure you want to delete version {version}?",
"confirm_delete_button": "Confirm Delete",
"delete_server_error": "Failed to delete version: Server returned error",
"delete_unknown_error": "Failed to delete version: Unknown error occurred",
"no_downloaded_versions": "No downloaded versions available",
"refresh": "Refresh",
"download_description": "Download available versions of @anthropic-ai/claude-code from NPM",
"loading_versions": "Loading version list...",
"close": "Close",
"cancel": "Cancel"
}
}

View File

@@ -224,5 +224,37 @@
"worker_init_failed": "Worker初始化失败",
"grouping_not_supported": "服务器不支持日志分组",
"back": "返回"
},
"version_manager": {
"title": "claude code版本管理",
"description": "管理 Claude Code 版本,下载新版本或切换已安装的版本",
"installed_versions": "已下载版本",
"available_versions": "可下载版本",
"current_version": "当前版本",
"no_available_versions": "暂无可用的版本",
"downloaded": "已下载",
"downloading": "下载中",
"download": "下载",
"switch_to_version": "切换到此版本",
"delete_version": "删除版本",
"cannot_delete_current": "版本 {version} 不能删除",
"download_success": "版本 {version} 下载成功",
"download_failed": "下载版本 {version} 失败",
"switch_success": "切换到版本 {version} 成功",
"switch_failed": "切换到版本 {version} 失败",
"get_versions_failed": "获取版本列表失败",
"version_deleted": "版本 {version} 已删除",
"delete_failed": "删除版本 {version} 失败",
"confirm_delete_title": "确认删除版本",
"confirm_delete_message": "您确定要删除版本 {version} 吗?",
"confirm_delete_button": "确认删除",
"delete_server_error": "删除版本失败:服务器返回错误",
"delete_unknown_error": "删除版本失败:发生未知错误",
"no_downloaded_versions": "暂无已下载的版本",
"refresh": "刷新",
"download_description": "从 NPM 下载 @anthropic-ai/claude-code 的可用版本",
"loading_versions": "加载版本列表中...",
"close": "关闭",
"cancel": "取消"
}
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/debugpage.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/requesthistorydrawer.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/db.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/debugpage.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/requesthistorydrawer.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/versionmanagerdialog.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/db.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}