mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-17 02:51:35 +00:00
Compare commits
10 Commits
feature/un
...
v1.29.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b00b58de | ||
|
|
6a13e1773b | ||
|
|
6102b74455 | ||
|
|
9ef1ab533d | ||
|
|
e9c7a5041c | ||
|
|
289520814c | ||
|
|
09486016e6 | ||
|
|
4c106a5083 | ||
|
|
63e996bb77 | ||
|
|
fbad3a90f8 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -3,6 +3,41 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.29.5](https://github.com/siteboon/claudecodeui/compare/v1.29.4...v1.29.5) (2026-04-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update node-pty to latest version ([6a13e17](https://github.com/siteboon/claudecodeui/commit/6a13e1773b145049ade512aa6e5cac21c2e5c4de))
|
||||
|
||||
## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16)
|
||||
|
||||
### New Features
|
||||
|
||||
* deleting from sidebar will now ask whether to remove all data as well ([e9c7a50](https://github.com/siteboon/claudecodeui/commit/e9c7a5041c31a6f7b2032f06abe19c52d3d4cd8c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set ([4c106a5](https://github.com/siteboon/claudecodeui/commit/4c106a5083d90989bbeedaefdbb68f5b3fa6fd58)), closes [#468](https://github.com/siteboon/claudecodeui/issues/468)
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove the sqlite3 dependency ([2895208](https://github.com/siteboon/claudecodeui/commit/289520814cf3ca36403056739ef22021f78c6033))
|
||||
* **server:** extract URL detection and color utils from index.js ([#657](https://github.com/siteboon/claudecodeui/issues/657)) ([63e996b](https://github.com/siteboon/claudecodeui/commit/63e996bb77cfa97b1f55f6bdccc50161a75a3eee))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* upgrade commit lint to 20.5.0 ([0948601](https://github.com/siteboon/claudecodeui/commit/09486016e67d97358c228ebc6eb4502ccb0012e4))
|
||||
|
||||
## [1.29.3](https://github.com/siteboon/claudecodeui/compare/v1.29.2...v1.29.3) (2026-04-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **version-upgrade-modal:** implement reload countdown and update UI messages ([#655](https://github.com/siteboon/claudecodeui/issues/655)) ([6413042](https://github.com/siteboon/claudecodeui/commit/641304242d7705b54aab65faa4a7673438c92c60))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* remove unused route (migrated to providers already) ([31f28a2](https://github.com/siteboon/claudecodeui/commit/31f28a2c183f6ead50941027632d7ab64b7bb2d4))
|
||||
|
||||
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
1049
package-lock.json
generated
1049
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.29.2",
|
||||
"version": "1.29.5",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
@@ -104,7 +104,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-pty": "^1.1.0-beta34",
|
||||
"node-pty": "^1.2.0-beta.12",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
@@ -117,15 +117,13 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.3",
|
||||
"@commitlint/config-conventional": "^20.4.3",
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@types/node": "^22.19.7",
|
||||
|
||||
@@ -26,13 +26,14 @@ import {
|
||||
} from './services/notification-orchestrator.js';
|
||||
import { claudeAdapter } from './providers/claude/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { getStatusChecker } from './providers/registry.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
|
||||
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||
|
||||
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
|
||||
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
||||
|
||||
function createRequestId() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
@@ -148,6 +149,10 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
|
||||
const sdkOptions = {};
|
||||
|
||||
if (process.env.CLAUDE_CLI_PATH) {
|
||||
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH;
|
||||
}
|
||||
|
||||
// Map working directory
|
||||
if (cwd) {
|
||||
sdkOptions.cwd = cwd;
|
||||
@@ -701,8 +706,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
// Clean up temporary image files on error
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Check if Claude CLI is installed for a clearer error message
|
||||
const installed = getStatusChecker('claude')?.checkInstalled() ?? true;
|
||||
const errorContent = !installed
|
||||
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
||||
: error.message;
|
||||
|
||||
// Send error to WebSocket
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'claude',
|
||||
@@ -710,8 +721,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
sessionName: sessionSummary,
|
||||
error
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import crossSpawn from 'cross-spawn';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { cursorAdapter } from './providers/cursor/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { getStatusChecker } from './providers/registry.js';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
@@ -294,7 +295,13 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
||||
// Check if Cursor CLI is installed for a clearer error message
|
||||
const installed = getStatusChecker('cursor')?.checkInstalled() ?? true;
|
||||
const errorContent = !installed
|
||||
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
|
||||
: error.message;
|
||||
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
||||
notifyTerminalState({ error });
|
||||
|
||||
settleOnce(() => reject(error));
|
||||
|
||||
@@ -10,6 +10,7 @@ import sessionManager from './sessionManager.js';
|
||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { getStatusChecker } from './providers/registry.js';
|
||||
|
||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
@@ -380,6 +381,15 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
notifyTerminalState({ code });
|
||||
resolve();
|
||||
} else {
|
||||
// code 127 = shell "command not found" — check installation
|
||||
if (code === 127) {
|
||||
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
|
||||
if (!installed) {
|
||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
|
||||
}
|
||||
}
|
||||
|
||||
notifyTerminalState({
|
||||
code,
|
||||
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
|
||||
@@ -394,8 +404,14 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeGeminiProcesses.delete(finalSessionId);
|
||||
|
||||
// Check if Gemini CLI is installed for a clearer error message
|
||||
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
|
||||
const errorContent = !installed
|
||||
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
|
||||
: error.message;
|
||||
|
||||
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' }));
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
|
||||
notifyTerminalState({ error });
|
||||
|
||||
reject(error);
|
||||
|
||||
@@ -11,25 +11,7 @@ const __dirname = getModuleDir(import.meta.url);
|
||||
const APP_ROOT = findAppRoot(__dirname);
|
||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
cyan: '\x1b[36m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
dim: '\x1b[2m',
|
||||
};
|
||||
|
||||
const c = {
|
||||
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
||||
ok: (text) => `${colors.green}${text}${colors.reset}`,
|
||||
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
|
||||
tip: (text) => `${colors.blue}${text}${colors.reset}`,
|
||||
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||
};
|
||||
import { c } from './utils/colors.js';
|
||||
|
||||
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
||||
|
||||
@@ -226,68 +208,7 @@ const server = http.createServer(app);
|
||||
const ptySessionsMap = new Map();
|
||||
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
||||
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
|
||||
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
|
||||
|
||||
function stripAnsiSequences(value = '') {
|
||||
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
|
||||
}
|
||||
|
||||
function normalizeDetectedUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
|
||||
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
|
||||
if (!cleaned) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(cleaned);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlsFromText(value = '') {
|
||||
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
|
||||
|
||||
// Handle wrapped terminal URLs split across lines by terminal width.
|
||||
const wrappedMatches = [];
|
||||
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
|
||||
const lines = value.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
|
||||
if (!startMatch) continue;
|
||||
|
||||
let combined = startMatch[0];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const continuation = lines[j].trim();
|
||||
if (!continuation) break;
|
||||
if (!continuationRegex.test(continuation)) break;
|
||||
combined += continuation;
|
||||
j++;
|
||||
}
|
||||
|
||||
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
|
||||
}
|
||||
|
||||
return Array.from(new Set([...directMatches, ...wrappedMatches]));
|
||||
}
|
||||
|
||||
function shouldAutoOpenUrlFromOutput(value = '') {
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes('browser didn\'t open') ||
|
||||
normalized.includes('open this url') ||
|
||||
normalized.includes('continue in your browser') ||
|
||||
normalized.includes('press enter to open') ||
|
||||
normalized.includes('open_url:')
|
||||
);
|
||||
}
|
||||
import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
|
||||
|
||||
// Single WebSocket server that handles both paths
|
||||
const wss = new WebSocketServer({
|
||||
@@ -573,12 +494,15 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
|
||||
}
|
||||
});
|
||||
|
||||
// Delete project endpoint (force=true to delete with sessions)
|
||||
// Delete project endpoint
|
||||
// force=true to allow removal even when sessions exist
|
||||
// deleteData=true to also delete session/memory files on disk (destructive)
|
||||
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const force = req.query.force === 'true';
|
||||
await deleteProject(projectName, force);
|
||||
const deleteData = req.query.deleteData === 'true';
|
||||
await deleteProject(projectName, force, deleteData);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Codex } from '@openai/codex-sdk';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { codexAdapter } from './providers/codex/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { getStatusChecker } from './providers/registry.js';
|
||||
|
||||
// Track active sessions
|
||||
const activeCodexSessions = new Map();
|
||||
@@ -308,7 +309,14 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
|
||||
if (!wasAborted) {
|
||||
console.error('[Codex] Error:', error);
|
||||
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' }));
|
||||
|
||||
// Check if Codex SDK is available for a clearer error message
|
||||
const installed = getStatusChecker('codex')?.checkInstalled() ?? true;
|
||||
const errorContent = !installed
|
||||
? 'Codex CLI is not configured. Please set up authentication first.'
|
||||
: error.message;
|
||||
|
||||
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' }));
|
||||
if (!terminalFailure) {
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
|
||||
@@ -62,8 +62,7 @@ import fsSync from 'fs';
|
||||
import path from 'path';
|
||||
import readline from 'readline';
|
||||
import crypto from 'crypto';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import Database from 'better-sqlite3';
|
||||
import os from 'os';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import { applyCustomSessionNames } from './database/db.js';
|
||||
@@ -1164,8 +1163,9 @@ async function isProjectEmpty(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a project (force=true to delete even with sessions)
|
||||
async function deleteProject(projectName, force = false) {
|
||||
// Remove a project from the UI.
|
||||
// When deleteData=true, also delete session/memory files on disk (destructive).
|
||||
async function deleteProject(projectName, force = false, deleteData = false) {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
@@ -1175,48 +1175,50 @@ async function deleteProject(projectName, force = false) {
|
||||
}
|
||||
|
||||
const config = await loadProjectConfig();
|
||||
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
|
||||
|
||||
// Fallback to extractProjectDirectory if projectPath is not in config
|
||||
if (!projectPath) {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
}
|
||||
// Destructive path: delete underlying data when explicitly requested
|
||||
if (deleteData) {
|
||||
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
|
||||
if (!projectPath) {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
}
|
||||
|
||||
// Remove the project directory (includes all Claude sessions)
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
// Remove the Claude project directory (session logs, memory, subagent data)
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
|
||||
// Delete all Codex sessions associated with this project
|
||||
if (projectPath) {
|
||||
try {
|
||||
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
|
||||
for (const session of codexSessions) {
|
||||
try {
|
||||
await deleteCodexSession(session.id);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
|
||||
// Delete Codex sessions associated with this project
|
||||
if (projectPath) {
|
||||
try {
|
||||
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
|
||||
for (const session of codexSessions) {
|
||||
try {
|
||||
await deleteCodexSession(session.id);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete Codex sessions:', err.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete Codex sessions:', err.message);
|
||||
}
|
||||
|
||||
// Delete Cursor sessions directory if it exists
|
||||
try {
|
||||
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
|
||||
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
|
||||
await fs.rm(cursorProjectDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Cursor dir may not exist, ignore
|
||||
// Delete Cursor sessions directory if it exists
|
||||
try {
|
||||
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
|
||||
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
|
||||
await fs.rm(cursorProjectDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Cursor dir may not exist, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from project config
|
||||
// Always remove from project config
|
||||
delete config[projectName];
|
||||
await saveProjectConfig(config);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting project ${projectName}:`, error);
|
||||
console.error(`Error removing project ${projectName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1305,16 +1307,10 @@ async function getCursorSessions(projectPath) {
|
||||
} catch (_) { }
|
||||
|
||||
// Open SQLite database
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY
|
||||
});
|
||||
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
// Get metadata from meta table
|
||||
const metaRows = await db.all(`
|
||||
SELECT key, value FROM meta
|
||||
`);
|
||||
const metaRows = db.prepare('SELECT key, value FROM meta').all();
|
||||
|
||||
// Parse metadata
|
||||
let metadata = {};
|
||||
@@ -1336,11 +1332,9 @@ async function getCursorSessions(projectPath) {
|
||||
}
|
||||
|
||||
// Get message count
|
||||
const messageCountResult = await db.get(`
|
||||
SELECT COUNT(*) as count FROM blobs
|
||||
`);
|
||||
const messageCountResult = db.prepare('SELECT COUNT(*) as count FROM blobs').get();
|
||||
|
||||
await db.close();
|
||||
db.close();
|
||||
|
||||
// Extract session info
|
||||
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
|
||||
|
||||
136
server/providers/claude/status.js
Normal file
136
server/providers/claude/status.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Claude Provider Status
|
||||
*
|
||||
* Checks whether Claude Code CLI is installed and whether the user
|
||||
* has valid authentication credentials.
|
||||
*
|
||||
* @module providers/claude/status
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Check if Claude Code CLI is installed and available.
|
||||
* Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkInstalled() {
|
||||
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
try {
|
||||
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status check: installation + authentication.
|
||||
* @returns {Promise<import('../types.js').ProviderStatus>}
|
||||
*/
|
||||
export async function checkStatus() {
|
||||
const installed = checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Claude Code CLI is not installed'
|
||||
};
|
||||
}
|
||||
|
||||
const credentialsResult = await checkCredentials();
|
||||
|
||||
if (credentialsResult.authenticated) {
|
||||
return {
|
||||
installed,
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: credentialsResult.method || null,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
installed,
|
||||
authenticated: false,
|
||||
email: credentialsResult.email || null,
|
||||
method: credentialsResult.method || null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function loadSettingsEnv() {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
if (settings?.env && typeof settings.env === 'object') {
|
||||
return settings.env;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing or malformed settings.
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Claude authentication credentials.
|
||||
*
|
||||
* Priority 1: ANTHROPIC_API_KEY environment variable
|
||||
* Priority 1b: ~/.claude/settings.json env values
|
||||
* Priority 2: ~/.claude/.credentials.json OAuth tokens
|
||||
*/
|
||||
async function checkCredentials() {
|
||||
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const settingsEnv = await loadSettingsEnv();
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
|
||||
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
|
||||
}
|
||||
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await fs.readFile(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth && oauth.accessToken) {
|
||||
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file',
|
||||
error: 'OAuth token has expired. Please re-authenticate with claude login'
|
||||
};
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null };
|
||||
} catch {
|
||||
return { authenticated: false, email: null, method: null };
|
||||
}
|
||||
}
|
||||
78
server/providers/codex/status.js
Normal file
78
server/providers/codex/status.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Codex Provider Status
|
||||
*
|
||||
* Checks whether the user has valid Codex authentication credentials.
|
||||
* Codex uses an SDK that makes direct API calls (no external binary),
|
||||
* so installation check always returns true if the server is running.
|
||||
*
|
||||
* @module providers/codex/status
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Check if Codex is installed.
|
||||
* Codex SDK is bundled with this application — no external binary needed.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkInstalled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status check: installation + authentication.
|
||||
* @returns {Promise<import('../types.js').ProviderStatus>}
|
||||
*/
|
||||
export async function checkStatus() {
|
||||
const installed = checkInstalled();
|
||||
const result = await checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
authenticated: result.authenticated,
|
||||
email: result.email || null,
|
||||
error: result.error || null
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function checkCredentials() {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await fs.readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content);
|
||||
|
||||
const tokens = auth.tokens || {};
|
||||
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
let email = 'Authenticated';
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
const parts = tokens.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
email = payload.email || payload.user || 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
email = 'Authenticated';
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated: true, email };
|
||||
}
|
||||
|
||||
if (auth.OPENAI_API_KEY) {
|
||||
return { authenticated: true, email: 'API Key Auth' };
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, error: 'No valid tokens found' };
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return { authenticated: false, email: null, error: 'Codex not configured' };
|
||||
}
|
||||
return { authenticated: false, email: null, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -20,21 +20,16 @@ const PROVIDER = 'cursor';
|
||||
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
|
||||
*/
|
||||
async function loadCursorBlobs(sessionId, projectPath) {
|
||||
// Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable
|
||||
const { default: sqlite3 } = await import('sqlite3');
|
||||
const { open } = await import('sqlite');
|
||||
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
||||
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY,
|
||||
});
|
||||
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
try {
|
||||
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs');
|
||||
const allBlobs = db.prepare('SELECT rowid, id, data FROM blobs').all();
|
||||
|
||||
const blobMap = new Map();
|
||||
const parentRefs = new Map();
|
||||
@@ -129,7 +124,7 @@ async function loadCursorBlobs(sessionId, projectPath) {
|
||||
|
||||
return messages;
|
||||
} finally {
|
||||
await db.close();
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
server/providers/cursor/status.js
Normal file
128
server/providers/cursor/status.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Cursor Provider Status
|
||||
*
|
||||
* Checks whether cursor-agent CLI is installed and whether the user
|
||||
* is logged in.
|
||||
*
|
||||
* @module providers/cursor/status
|
||||
*/
|
||||
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
|
||||
/**
|
||||
* Check if cursor-agent CLI is installed.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkInstalled() {
|
||||
try {
|
||||
execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status check: installation + authentication.
|
||||
* @returns {Promise<import('../types.js').ProviderStatus>}
|
||||
*/
|
||||
export async function checkStatus() {
|
||||
const installed = checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI is not installed'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await checkCursorLogin();
|
||||
|
||||
return {
|
||||
installed,
|
||||
authenticated: result.authenticated,
|
||||
email: result.email || null,
|
||||
error: result.error || null
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function checkCursorLogin() {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
if (childProcess) {
|
||||
childProcess.kill();
|
||||
}
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Command timeout'
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
let childProcess;
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (emailMatch) {
|
||||
resolve({ authenticated: true, email: emailMatch[1] });
|
||||
} else if (stdout.includes('Logged in')) {
|
||||
resolve({ authenticated: true, email: 'Logged in' });
|
||||
} else {
|
||||
resolve({ authenticated: false, email: null, error: 'Not logged in' });
|
||||
}
|
||||
} else {
|
||||
resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' });
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', () => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
111
server/providers/gemini/status.js
Normal file
111
server/providers/gemini/status.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Gemini Provider Status
|
||||
*
|
||||
* Checks whether Gemini CLI is installed and whether the user
|
||||
* has valid authentication credentials.
|
||||
*
|
||||
* @module providers/gemini/status
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Check if Gemini CLI is installed.
|
||||
* Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkInstalled() {
|
||||
const cliPath = process.env.GEMINI_PATH || 'gemini';
|
||||
try {
|
||||
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status check: installation + authentication.
|
||||
* @returns {Promise<import('../types.js').ProviderStatus>}
|
||||
*/
|
||||
export async function checkStatus() {
|
||||
const installed = checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Gemini CLI is not installed'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
authenticated: result.authenticated,
|
||||
email: result.email || null,
|
||||
error: result.error || null
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function checkCredentials() {
|
||||
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth' };
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const content = await fs.readFile(credsPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
if (creds.access_token) {
|
||||
let email = 'OAuth Session';
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
|
||||
if (tokenRes.ok) {
|
||||
const tokenInfo = await tokenRes.json();
|
||||
if (tokenInfo.email) {
|
||||
email = tokenInfo.email;
|
||||
}
|
||||
} else if (!creds.refresh_token) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Access token invalid and no refresh token found'
|
||||
};
|
||||
} else {
|
||||
// Token might be expired but we have a refresh token, so CLI will refresh it
|
||||
email = await getActiveAccountEmail() || email;
|
||||
}
|
||||
} catch {
|
||||
// Network error, fallback to checking local accounts file
|
||||
email = await getActiveAccountEmail() || email;
|
||||
}
|
||||
|
||||
return { authenticated: true, email };
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' };
|
||||
} catch {
|
||||
return { authenticated: false, email: null, error: 'Gemini CLI not configured' };
|
||||
}
|
||||
}
|
||||
|
||||
async function getActiveAccountEmail() {
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
return accounts.active || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Provider Registry
|
||||
*
|
||||
* Centralizes provider adapter lookup. All code that needs a provider adapter
|
||||
* should go through this registry instead of importing individual adapters directly.
|
||||
* Centralizes provider adapter and status checker lookup. All code that needs
|
||||
* a provider adapter or status checker should go through this registry instead
|
||||
* of importing individual modules directly.
|
||||
*
|
||||
* @module providers/registry
|
||||
*/
|
||||
@@ -12,6 +13,11 @@ import { cursorAdapter } from './cursor/adapter.js';
|
||||
import { codexAdapter } from './codex/adapter.js';
|
||||
import { geminiAdapter } from './gemini/adapter.js';
|
||||
|
||||
import * as claudeStatus from './claude/status.js';
|
||||
import * as cursorStatus from './cursor/status.js';
|
||||
import * as codexStatus from './codex/status.js';
|
||||
import * as geminiStatus from './gemini/status.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
|
||||
* @typedef {import('./types.js').SessionProvider} SessionProvider
|
||||
@@ -20,12 +26,20 @@ import { geminiAdapter } from './gemini/adapter.js';
|
||||
/** @type {Map<string, ProviderAdapter>} */
|
||||
const providers = new Map();
|
||||
|
||||
/** @type {Map<string, { checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> }>} */
|
||||
const statusCheckers = new Map();
|
||||
|
||||
// Register built-in providers
|
||||
providers.set('claude', claudeAdapter);
|
||||
providers.set('cursor', cursorAdapter);
|
||||
providers.set('codex', codexAdapter);
|
||||
providers.set('gemini', geminiAdapter);
|
||||
|
||||
statusCheckers.set('claude', claudeStatus);
|
||||
statusCheckers.set('cursor', cursorStatus);
|
||||
statusCheckers.set('codex', codexStatus);
|
||||
statusCheckers.set('gemini', geminiStatus);
|
||||
|
||||
/**
|
||||
* Get a provider adapter by name.
|
||||
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
|
||||
@@ -35,6 +49,15 @@ export function getProvider(name) {
|
||||
return providers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider status checker by name.
|
||||
* @param {string} name - Provider name
|
||||
* @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> } | undefined}
|
||||
*/
|
||||
export function getStatusChecker(name) {
|
||||
return statusCheckers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered provider names.
|
||||
* @returns {string[]}
|
||||
|
||||
@@ -69,6 +69,19 @@
|
||||
* @property {object} [tokenUsage] - Token usage data (provider-specific)
|
||||
*/
|
||||
|
||||
// ─── Provider Status ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of a provider status check (installation + authentication).
|
||||
*
|
||||
* @typedef {Object} ProviderStatus
|
||||
* @property {boolean} installed - Whether the provider's CLI/SDK is available
|
||||
* @property {boolean} authenticated - Whether valid credentials exist
|
||||
* @property {string|null} email - User email or auth method identifier
|
||||
* @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file')
|
||||
* @property {string|null} [error] - Error message if not installed or not authenticated
|
||||
*/
|
||||
|
||||
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,434 +1,27 @@
|
||||
/**
|
||||
* CLI Auth Routes
|
||||
*
|
||||
* Thin router that delegates to per-provider status checkers
|
||||
* registered in the provider registry.
|
||||
*
|
||||
* @module routes/cli-auth
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { getAllProviders, getStatusChecker } from '../providers/registry.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/claude/status', async (req, res) => {
|
||||
try {
|
||||
const credentialsResult = await checkClaudeCredentials();
|
||||
|
||||
if (credentialsResult.authenticated) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: credentialsResult.method // 'api_key' or 'credentials_file'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Claude auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/cursor/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCursorStatus();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Cursor auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/codex/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCodexCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gemini/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkGeminiCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Gemini auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadClaudeSettingsEnv() {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
if (settings?.env && typeof settings.env === 'object') {
|
||||
return settings.env;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore missing or malformed settings and fall back to other auth sources.
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Claude authentication credentials using two methods with priority order:
|
||||
*
|
||||
* Priority 1: ANTHROPIC_API_KEY environment variable
|
||||
* Priority 1b: ~/.claude/settings.json env values
|
||||
* Priority 2: ~/.claude/.credentials.json OAuth tokens
|
||||
*
|
||||
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
|
||||
* This matching behavior ensures consistency with how the SDK authenticates.
|
||||
*
|
||||
* References:
|
||||
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
|
||||
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
|
||||
* - https://platform.claude.com/docs/en/agent-sdk/overview
|
||||
* SDK authentication documentation
|
||||
*
|
||||
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
|
||||
* - authenticated: boolean indicating if valid credentials exist
|
||||
* - email: user email or auth method identifier
|
||||
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
|
||||
*/
|
||||
async function checkClaudeCredentials() {
|
||||
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
|
||||
// The SDK checks this first and uses it if present, even if OAuth tokens exist.
|
||||
// When set, API calls are charged via pay-as-you-go rates instead of subscription.
|
||||
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 1b: Check ~/.claude/settings.json env values.
|
||||
// Claude Code can read proxy/auth values from settings.json even when the
|
||||
// CloudCLI server process itself was not started with those env vars exported.
|
||||
const settingsEnv = await loadClaudeSettingsEnv();
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'Configured via settings.json',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
|
||||
// This is the standard authentication method used by Claude CLI after running
|
||||
// 'claude /login' or 'claude setup-token' commands.
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await fs.readFile(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth && oauth.accessToken) {
|
||||
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
|
||||
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkCursorStatus() {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
if (childProcess) {
|
||||
childProcess.kill();
|
||||
}
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Command timeout'
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
let childProcess;
|
||||
for (const provider of getAllProviders()) {
|
||||
router.get(`/${provider}/status`, async (req, res) => {
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
return;
|
||||
const checker = getStatusChecker(provider);
|
||||
res.json(await checker.checkStatus());
|
||||
} catch (error) {
|
||||
console.error(`Error checking ${provider} status:`, error);
|
||||
res.status(500).json({ authenticated: false, error: error.message });
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (emailMatch) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: emailMatch[1],
|
||||
output: stdout
|
||||
});
|
||||
} else if (stdout.includes('Logged in')) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: 'Logged in',
|
||||
output: stdout
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Not logged in'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: stderr || 'Not logged in'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCodexCredentials() {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await fs.readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content);
|
||||
|
||||
// Tokens are nested under 'tokens' key
|
||||
const tokens = auth.tokens || {};
|
||||
|
||||
// Check for valid tokens (id_token or access_token)
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
// Try to extract email from id_token JWT payload
|
||||
let email = 'Authenticated';
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
// JWT is base64url encoded: header.payload.signature
|
||||
const parts = tokens.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
email = payload.email || payload.user || 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
// If JWT decoding fails, use fallback
|
||||
email = 'Authenticated';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email
|
||||
};
|
||||
}
|
||||
|
||||
// Also check for OPENAI_API_KEY as fallback auth method
|
||||
if (auth.OPENAI_API_KEY) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found'
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Codex not configured'
|
||||
};
|
||||
}
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGeminiCredentials() {
|
||||
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const content = await fs.readFile(credsPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
if (creds.access_token) {
|
||||
let email = 'OAuth Session';
|
||||
|
||||
try {
|
||||
// Validate token against Google API
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
|
||||
if (tokenRes.ok) {
|
||||
const tokenInfo = await tokenRes.json();
|
||||
if (tokenInfo.email) {
|
||||
email = tokenInfo.email;
|
||||
}
|
||||
} else if (!creds.refresh_token) {
|
||||
// Token invalid and no refresh token available
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Access token invalid and no refresh token found'
|
||||
};
|
||||
} else {
|
||||
// Token might be expired but we have a refresh token, so CLI will refresh it
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
if (accounts.active) {
|
||||
email = accounts.active;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error, fallback to checking local accounts file
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
if (accounts.active) {
|
||||
email = accounts.active;
|
||||
}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email: email
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found in oauth_creds'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Gemini CLI not configured'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,8 +2,7 @@ import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import Database from 'better-sqlite3';
|
||||
import crypto from 'crypto';
|
||||
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
|
||||
import { applyCustomSessionNames } from '../database/db.js';
|
||||
@@ -386,16 +385,10 @@ router.get('/sessions', async (req, res) => {
|
||||
} catch (_) {}
|
||||
|
||||
// Open SQLite database
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY
|
||||
});
|
||||
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
// Get metadata from meta table
|
||||
const metaRows = await db.all(`
|
||||
SELECT key, value FROM meta
|
||||
`);
|
||||
const metaRows = db.prepare('SELECT key, value FROM meta').all();
|
||||
|
||||
let sessionData = {
|
||||
id: sessionId,
|
||||
@@ -457,20 +450,11 @@ router.get('/sessions', async (req, res) => {
|
||||
|
||||
// Get message count from JSON blobs only (actual messages, not DAG structure)
|
||||
try {
|
||||
const blobCount = await db.get(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM blobs
|
||||
WHERE substr(data, 1, 1) = X'7B'
|
||||
`);
|
||||
const blobCount = db.prepare(`SELECT COUNT(*) as count FROM blobs WHERE substr(data, 1, 1) = X'7B'`).get();
|
||||
sessionData.messageCount = blobCount.count;
|
||||
|
||||
// Get the most recent JSON blob for preview (actual message, not DAG structure)
|
||||
const lastBlob = await db.get(`
|
||||
SELECT data FROM blobs
|
||||
WHERE substr(data, 1, 1) = X'7B'
|
||||
ORDER BY rowid DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
const lastBlob = db.prepare(`SELECT data FROM blobs WHERE substr(data, 1, 1) = X'7B' ORDER BY rowid DESC LIMIT 1`).get();
|
||||
|
||||
if (lastBlob && lastBlob.data) {
|
||||
try {
|
||||
@@ -525,7 +509,7 @@ router.get('/sessions', async (req, res) => {
|
||||
console.log('Could not read blobs:', e.message);
|
||||
}
|
||||
|
||||
await db.close();
|
||||
db.close();
|
||||
|
||||
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
|
||||
if (!sessionData.createdAt) {
|
||||
|
||||
21
server/utils/colors.js
Normal file
21
server/utils/colors.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
cyan: '\x1b[36m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
dim: '\x1b[2m',
|
||||
};
|
||||
|
||||
const c = {
|
||||
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
||||
ok: (text) => `${colors.green}${text}${colors.reset}`,
|
||||
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
|
||||
tip: (text) => `${colors.blue}${text}${colors.reset}`,
|
||||
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||
};
|
||||
|
||||
export { colors, c };
|
||||
71
server/utils/url-detection.js
Normal file
71
server/utils/url-detection.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
|
||||
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
|
||||
|
||||
function stripAnsiSequences(value = '') {
|
||||
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
|
||||
}
|
||||
|
||||
function normalizeDetectedUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
|
||||
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
|
||||
if (!cleaned) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(cleaned);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlsFromText(value = '') {
|
||||
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
|
||||
|
||||
// Handle wrapped terminal URLs split across lines by terminal width.
|
||||
const wrappedMatches = [];
|
||||
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
|
||||
const lines = value.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
|
||||
if (!startMatch) continue;
|
||||
|
||||
let combined = startMatch[0];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const continuation = lines[j].trim();
|
||||
if (!continuation) break;
|
||||
if (!continuationRegex.test(continuation)) break;
|
||||
combined += continuation;
|
||||
j++;
|
||||
}
|
||||
|
||||
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
|
||||
}
|
||||
|
||||
return Array.from(new Set([...directMatches, ...wrappedMatches]));
|
||||
}
|
||||
|
||||
function shouldAutoOpenUrlFromOutput(value = '') {
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes('browser didn\'t open') ||
|
||||
normalized.includes('open this url') ||
|
||||
normalized.includes('continue in your browser') ||
|
||||
normalized.includes('press enter to open') ||
|
||||
normalized.includes('open_url:')
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ANSI_ESCAPE_SEQUENCE_REGEX,
|
||||
TRAILING_URL_PUNCTUATION_REGEX,
|
||||
stripAnsiSequences,
|
||||
normalizeDetectedUrl,
|
||||
extractUrlsFromText,
|
||||
shouldAutoOpenUrlFromOutput
|
||||
};
|
||||
@@ -160,6 +160,9 @@ export default function ChatComposer({
|
||||
(r) => r.toolName === 'AskUserQuestion'
|
||||
);
|
||||
|
||||
// Hide the thinking/status bar while any permission request is pending
|
||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||
|
||||
// On mobile, when input is focused, float the input box at the bottom
|
||||
const mobileFloatingClass = isInputFocused
|
||||
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
|
||||
@@ -167,7 +170,7 @@ export default function ChatComposer({
|
||||
|
||||
return (
|
||||
<div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>
|
||||
{!hasQuestionPanel && (
|
||||
{!hasPendingPermissions && (
|
||||
<div className="flex-1">
|
||||
<ClaudeStatus
|
||||
status={claudeStatus}
|
||||
|
||||
@@ -452,7 +452,7 @@ export function useSidebarController({
|
||||
[getProjectSessions],
|
||||
);
|
||||
|
||||
const confirmDeleteProject = useCallback(async () => {
|
||||
const confirmDeleteProject = useCallback(async (deleteData = false) => {
|
||||
if (!deleteConfirmation) {
|
||||
return;
|
||||
}
|
||||
@@ -464,7 +464,7 @@ export function useSidebarController({
|
||||
setDeletingProjects((prev) => new Set([...prev, project.name]));
|
||||
|
||||
try {
|
||||
const response = await api.deleteProject(project.name, !isEmpty);
|
||||
const response = await api.deleteProject(project.name, !isEmpty, deleteData);
|
||||
|
||||
if (response.ok) {
|
||||
onProjectDelete?.(project.name);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
import { AlertTriangle, EyeOff, Trash2 } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Button } from '../../../../shared/view/ui';
|
||||
import Settings from '../../../settings/view/Settings';
|
||||
@@ -22,7 +22,7 @@ type SidebarModalsProps = {
|
||||
onProjectCreated: () => void;
|
||||
deleteConfirmation: DeleteProjectConfirmation | null;
|
||||
onCancelDeleteProject: () => void;
|
||||
onConfirmDeleteProject: () => void;
|
||||
onConfirmDeleteProject: (deleteData?: boolean) => void;
|
||||
sessionDeleteConfirmation: SessionDeleteConfirmation | null;
|
||||
onCancelDeleteSession: () => void;
|
||||
onConfirmDeleteSession: () => void;
|
||||
@@ -104,8 +104,8 @@ export default function SidebarModals({
|
||||
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
|
||||
<AlertTriangle className="h-6 w-6 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
@@ -119,32 +119,32 @@ export default function SidebarModals({
|
||||
?
|
||||
</p>
|
||||
{deleteConfirmation.sessionCount > 0 && (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{t('deleteConfirmation.allConversationsDeleted')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{t('deleteConfirmation.cannotUndo')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
|
||||
<Button variant="outline" className="flex-1" onClick={onCancelDeleteProject}>
|
||||
{t('actions.cancel')}
|
||||
<div className="flex flex-col gap-2 border-t border-border bg-muted/30 p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onConfirmDeleteProject(false)}
|
||||
>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
{t('deleteConfirmation.removeFromSidebar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={onConfirmDeleteProject}
|
||||
className="w-full justify-start bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={() => onConfirmDeleteProject(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('actions.delete')}
|
||||
{t('deleteConfirmation.deleteAllData')}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full" onClick={onCancelDeleteProject}>
|
||||
{t('actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"projects": {
|
||||
"title": "Projekte",
|
||||
"newProject": "Neues Projekt",
|
||||
"deleteProject": "Projekt löschen",
|
||||
"deleteProject": "Projekt entfernen",
|
||||
"renameProject": "Projekt umbenennen",
|
||||
"noProjects": "Keine Projekte gefunden",
|
||||
"loadingProjects": "Projekte werden geladen...",
|
||||
@@ -40,7 +40,7 @@
|
||||
"createProject": "Neues Projekt erstellen",
|
||||
"refresh": "Projekte und Sitzungen aktualisieren (Strg+R)",
|
||||
"renameProject": "Projekt umbenennen (F2)",
|
||||
"deleteProject": "Leeres Projekt löschen (Entf)",
|
||||
"deleteProject": "Projekt aus Seitenleiste entfernen (Entf)",
|
||||
"addToFavorites": "Zu Favoriten hinzufügen",
|
||||
"removeFromFavorites": "Aus Favoriten entfernen",
|
||||
"editSessionName": "Sitzungsname manuell bearbeiten",
|
||||
@@ -95,14 +95,14 @@
|
||||
"deleteSuccess": "Erfolgreich gelöscht",
|
||||
"errorOccurred": "Ein Fehler ist aufgetreten",
|
||||
"deleteSessionConfirm": "Möchtest du diese Sitzung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteProjectConfirm": "Möchtest du dieses leere Projekt wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteProjectConfirm": "Projekt aus der Seitenleiste entfernen? Deine Projektdateien, Erinnerungen und Sitzungsdaten werden nicht gelöscht.",
|
||||
"enterProjectPath": "Bitte gib einen Projektpfad ein",
|
||||
"deleteSessionFailed": "Sitzung konnte nicht gelöscht werden. Bitte erneut versuchen.",
|
||||
"deleteSessionError": "Fehler beim Löschen der Sitzung. Bitte erneut versuchen.",
|
||||
"renameSessionFailed": "Sitzung konnte nicht umbenannt werden. Bitte erneut versuchen.",
|
||||
"renameSessionError": "Fehler beim Umbenennen der Sitzung. Bitte erneut versuchen.",
|
||||
"deleteProjectFailed": "Projekt konnte nicht gelöscht werden. Bitte erneut versuchen.",
|
||||
"deleteProjectError": "Fehler beim Löschen des Projekts. Bitte erneut versuchen.",
|
||||
"deleteProjectFailed": "Projekt konnte nicht entfernt werden. Bitte erneut versuchen.",
|
||||
"deleteProjectError": "Fehler beim Entfernen des Projekts. Bitte erneut versuchen.",
|
||||
"createProjectFailed": "Projekt konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"createProjectError": "Fehler beim Erstellen des Projekts. Bitte erneut versuchen."
|
||||
},
|
||||
@@ -122,12 +122,14 @@
|
||||
"projectsScanned_other": "{{count}} Projekte durchsucht"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "Projekt löschen",
|
||||
"deleteProject": "Projekt entfernen",
|
||||
"deleteSession": "Sitzung löschen",
|
||||
"confirmDelete": "Möchtest du wirklich löschen",
|
||||
"confirmDelete": "Was möchtest du mit",
|
||||
"sessionCount_one": "Dieses Projekt enthält {{count}} Unterhaltung.",
|
||||
"sessionCount_other": "Dieses Projekt enthält {{count}} Unterhaltungen.",
|
||||
"allConversationsDeleted": "Alle Unterhaltungen werden dauerhaft gelöscht.",
|
||||
"cannotUndo": "Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
"removeFromSidebar": "Nur aus der Seitenleiste entfernen",
|
||||
"deleteAllData": "Alle Daten dauerhaft löschen",
|
||||
"allConversationsDeleted": "Das Projekt wird aus der Seitenleiste entfernt. Deine Dateien, Erinnerungen und Sitzungsdaten bleiben erhalten.",
|
||||
"cannotUndo": "Du kannst das Projekt später erneut hinzufügen."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"projects": {
|
||||
"title": "Projects",
|
||||
"newProject": "New Project",
|
||||
"deleteProject": "Delete Project",
|
||||
"deleteProject": "Remove Project",
|
||||
"renameProject": "Rename Project",
|
||||
"noProjects": "No projects found",
|
||||
"loadingProjects": "Loading projects...",
|
||||
@@ -40,7 +40,7 @@
|
||||
"createProject": "Create new project",
|
||||
"refresh": "Refresh projects and sessions (Ctrl+R)",
|
||||
"renameProject": "Rename project (F2)",
|
||||
"deleteProject": "Delete empty project (Delete)",
|
||||
"deleteProject": "Remove project from sidebar (Delete)",
|
||||
"addToFavorites": "Add to favorites",
|
||||
"removeFromFavorites": "Remove from favorites",
|
||||
"editSessionName": "Manually edit session name",
|
||||
@@ -95,14 +95,14 @@
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"errorOccurred": "An error occurred",
|
||||
"deleteSessionConfirm": "Are you sure you want to delete this session? This action cannot be undone.",
|
||||
"deleteProjectConfirm": "Are you sure you want to delete this empty project? This action cannot be undone.",
|
||||
"deleteProjectConfirm": "Remove this project from the sidebar? Your project files, memories, and session data will not be deleted.",
|
||||
"enterProjectPath": "Please enter a project path",
|
||||
"deleteSessionFailed": "Failed to delete session. Please try again.",
|
||||
"deleteSessionError": "Error deleting session. Please try again.",
|
||||
"renameSessionFailed": "Failed to rename session. Please try again.",
|
||||
"renameSessionError": "Error renaming session. Please try again.",
|
||||
"deleteProjectFailed": "Failed to delete project. Please try again.",
|
||||
"deleteProjectError": "Error deleting project. Please try again.",
|
||||
"deleteProjectFailed": "Failed to remove project. Please try again.",
|
||||
"deleteProjectError": "Error removing project. Please try again.",
|
||||
"createProjectFailed": "Failed to create project. Please try again.",
|
||||
"createProjectError": "Error creating project. Please try again."
|
||||
},
|
||||
@@ -122,12 +122,14 @@
|
||||
"projectsScanned_other": "{{count}} projects scanned"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "Delete Project",
|
||||
"deleteProject": "Remove Project",
|
||||
"deleteSession": "Delete Session",
|
||||
"confirmDelete": "Are you sure you want to delete",
|
||||
"confirmDelete": "What would you like to do with",
|
||||
"sessionCount_one": "This project contains {{count}} conversation.",
|
||||
"sessionCount_other": "This project contains {{count}} conversations.",
|
||||
"allConversationsDeleted": "All conversations will be permanently deleted.",
|
||||
"cannotUndo": "This action cannot be undone."
|
||||
"removeFromSidebar": "Remove from sidebar only",
|
||||
"deleteAllData": "Delete all data permanently",
|
||||
"allConversationsDeleted": "The project will be removed from the sidebar. Your files, memories, and session data will be preserved.",
|
||||
"cannotUndo": "You can re-add the project later."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"projects": {
|
||||
"title": "プロジェクト",
|
||||
"newProject": "新規プロジェクト",
|
||||
"deleteProject": "プロジェクトを削除",
|
||||
"deleteProject": "プロジェクトを除去",
|
||||
"renameProject": "プロジェクト名を変更",
|
||||
"noProjects": "プロジェクトが見つかりません",
|
||||
"loadingProjects": "プロジェクトを読み込んでいます...",
|
||||
@@ -40,7 +40,7 @@
|
||||
"createProject": "新しいプロジェクトを作成",
|
||||
"refresh": "プロジェクトとセッションを更新 (Ctrl+R)",
|
||||
"renameProject": "プロジェクト名を変更 (F2)",
|
||||
"deleteProject": "空のプロジェクトを削除 (Delete)",
|
||||
"deleteProject": "サイドバーからプロジェクトを除去 (Delete)",
|
||||
"addToFavorites": "お気に入りに追加",
|
||||
"removeFromFavorites": "お気に入りから削除",
|
||||
"editSessionName": "セッション名を手動で編集",
|
||||
@@ -94,14 +94,14 @@
|
||||
"deleteSuccess": "削除しました",
|
||||
"errorOccurred": "エラーが発生しました",
|
||||
"deleteSessionConfirm": "このセッションを削除してもよろしいですか?この操作は取り消せません。",
|
||||
"deleteProjectConfirm": "この空のプロジェクトを削除してもよろしいですか?この操作は取り消せません。",
|
||||
"deleteProjectConfirm": "サイドバーからこのプロジェクトを除去しますか?プロジェクトファイル、メモリ、セッションデータは削除されません。",
|
||||
"enterProjectPath": "プロジェクトのパスを入力してください",
|
||||
"deleteSessionFailed": "セッションの削除に失敗しました。もう一度お試しください。",
|
||||
"deleteSessionError": "セッションの削除でエラーが発生しました。もう一度お試しください。",
|
||||
"renameSessionFailed": "セッション名の変更に失敗しました。もう一度お試しください。",
|
||||
"renameSessionError": "セッション名の変更でエラーが発生しました。もう一度お試しください。",
|
||||
"deleteProjectFailed": "プロジェクトの削除に失敗しました。もう一度お試しください。",
|
||||
"deleteProjectError": "プロジェクトの削除でエラーが発生しました。もう一度お試しください。",
|
||||
"deleteProjectFailed": "プロジェクトの除去に失敗しました。もう一度お試しください。",
|
||||
"deleteProjectError": "プロジェクトの除去でエラーが発生しました。もう一度お試しください。",
|
||||
"createProjectFailed": "プロジェクトの作成に失敗しました。もう一度お試しください。",
|
||||
"createProjectError": "プロジェクトの作成でエラーが発生しました。もう一度お試しください。"
|
||||
},
|
||||
@@ -109,11 +109,13 @@
|
||||
"updateAvailable": "アップデートあり"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "プロジェクトを削除",
|
||||
"deleteProject": "プロジェクトを除去",
|
||||
"deleteSession": "セッションを削除",
|
||||
"confirmDelete": "本当に削除しますか?",
|
||||
"confirmDelete": "このプロジェクトをどうしますか:",
|
||||
"sessionCount": "このプロジェクトには{{count}}件の会話があります。",
|
||||
"allConversationsDeleted": "すべての会話が完全に削除されます。",
|
||||
"cannotUndo": "この操作は取り消せません。"
|
||||
"removeFromSidebar": "サイドバーからのみ除去",
|
||||
"deleteAllData": "すべてのデータを完全に削除",
|
||||
"allConversationsDeleted": "プロジェクトはサイドバーから除去されます。ファイル、メモリ、セッションデータは保持されます。",
|
||||
"cannotUndo": "後からプロジェクトを再追加できます。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"projects": {
|
||||
"title": "프로젝트",
|
||||
"newProject": "새 프로젝트",
|
||||
"deleteProject": "프로젝트 삭제",
|
||||
"deleteProject": "프로젝트 제거",
|
||||
"renameProject": "프로젝트 이름 변경",
|
||||
"noProjects": "프로젝트가 없습니다",
|
||||
"loadingProjects": "프로젝트 로딩 중...",
|
||||
@@ -40,7 +40,7 @@
|
||||
"createProject": "새 프로젝트 생성",
|
||||
"refresh": "프로젝트 및 세션 새로고침 (Ctrl+R)",
|
||||
"renameProject": "프로젝트 이름 변경 (F2)",
|
||||
"deleteProject": "빈 프로젝트 삭제 (Delete)",
|
||||
"deleteProject": "사이드바에서 프로젝트 제거 (Delete)",
|
||||
"addToFavorites": "즐겨찾기에 추가",
|
||||
"removeFromFavorites": "즐겨찾기에서 제거",
|
||||
"editSessionName": "세션 이름 직접 편집",
|
||||
@@ -94,14 +94,14 @@
|
||||
"deleteSuccess": "삭제되었습니다",
|
||||
"errorOccurred": "오류가 발생했습니다",
|
||||
"deleteSessionConfirm": "이 세션을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"deleteProjectConfirm": "이 빈 프로젝트를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"deleteProjectConfirm": "사이드바에서 이 프로젝트를 제거하시겠습니까? 프로젝트 파일, 메모리 및 세션 데이터는 삭제되지 않습니다.",
|
||||
"enterProjectPath": "프로젝트 경로를 입력해주세요",
|
||||
"deleteSessionFailed": "세션 삭제 실패. 다시 시도해주세요.",
|
||||
"deleteSessionError": "세션 삭제 오류. 다시 시도해주세요.",
|
||||
"renameSessionFailed": "세션 이름 변경 실패. 다시 시도해주세요.",
|
||||
"renameSessionError": "세션 이름 변경 오류. 다시 시도해주세요.",
|
||||
"deleteProjectFailed": "프로젝트 삭제 실패. 다시 시도해주세요.",
|
||||
"deleteProjectError": "프로젝트 삭제 오류. 다시 시도해주세요.",
|
||||
"deleteProjectFailed": "프로젝트 제거 실패. 다시 시도해주세요.",
|
||||
"deleteProjectError": "프로젝트 제거 오류. 다시 시도해주세요.",
|
||||
"createProjectFailed": "프로젝트 생성 실패. 다시 시도해주세요.",
|
||||
"createProjectError": "프로젝트 생성 오류. 다시 시도해주세요."
|
||||
},
|
||||
@@ -109,12 +109,14 @@
|
||||
"updateAvailable": "업데이트 가능"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "프로젝트 삭제",
|
||||
"deleteProject": "프로젝트 제거",
|
||||
"deleteSession": "세션 삭제",
|
||||
"confirmDelete": "정말 삭제하시겠습니까",
|
||||
"confirmDelete": "이 프로젝트를 어떻게 하시겠습니까:",
|
||||
"sessionCount_one": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
|
||||
"sessionCount_other": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
|
||||
"allConversationsDeleted": "모든 대화가 영구적으로 삭제됩니다.",
|
||||
"cannotUndo": "이 작업은 취소할 수 없습니다."
|
||||
"removeFromSidebar": "사이드바에서만 제거",
|
||||
"deleteAllData": "모든 데이터 영구 삭제",
|
||||
"allConversationsDeleted": "프로젝트가 사이드바에서 제거됩니다. 파일, 메모리 및 세션 데이터는 보존됩니다.",
|
||||
"cannotUndo": "나중에 프로젝트를 다시 추가할 수 있습니다."
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"projects": {
|
||||
"title": "Проекты",
|
||||
"newProject": "Новый проект",
|
||||
"deleteProject": "Удалить проект",
|
||||
"deleteProject": "Убрать проект",
|
||||
"renameProject": "Переименовать проект",
|
||||
"noProjects": "Проекты не найдены",
|
||||
"loadingProjects": "Загрузка проектов...",
|
||||
@@ -40,7 +40,7 @@
|
||||
"createProject": "Создать новый проект",
|
||||
"refresh": "Обновить проекты и сеансы (Ctrl+R)",
|
||||
"renameProject": "Переименовать проект (F2)",
|
||||
"deleteProject": "Удалить пустой проект (Delete)",
|
||||
"deleteProject": "Убрать проект из боковой панели (Delete)",
|
||||
"addToFavorites": "Добавить в избранное",
|
||||
"removeFromFavorites": "Удалить из избранного",
|
||||
"editSessionName": "Вручную редактировать имя сеанса",
|
||||
@@ -95,14 +95,14 @@
|
||||
"deleteSuccess": "Успешно удалено",
|
||||
"errorOccurred": "Произошла ошибка",
|
||||
"deleteSessionConfirm": "Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.",
|
||||
"deleteProjectConfirm": "Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.",
|
||||
"deleteProjectConfirm": "Убрать этот проект из боковой панели? Файлы проекта, воспоминания и данные сеансов не будут удалены.",
|
||||
"enterProjectPath": "Пожалуйста, введите путь к проекту",
|
||||
"deleteSessionFailed": "Не удалось удалить сеанс. Попробуйте снова.",
|
||||
"deleteSessionError": "Ошибка при удалении сеанса. Попробуйте снова.",
|
||||
"renameSessionFailed": "Не удалось переименовать сеанс. Попробуйте снова.",
|
||||
"renameSessionError": "Ошибка при переименовании сеанса. Попробуйте снова.",
|
||||
"deleteProjectFailed": "Не удалось удалить проект. Попробуйте снова.",
|
||||
"deleteProjectError": "Ошибка при удалении проекта. Попробуйте снова.",
|
||||
"deleteProjectFailed": "Не удалось убрать проект. Попробуйте снова.",
|
||||
"deleteProjectError": "Ошибка при удалении проекта из списка. Попробуйте снова.",
|
||||
"createProjectFailed": "Не удалось создать проект. Попробуйте снова.",
|
||||
"createProjectError": "Ошибка при создании проекта. Попробуйте снова."
|
||||
},
|
||||
@@ -126,14 +126,16 @@
|
||||
"projectsScanned_other": "{{count}} проектов просканировано"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "Удалить проект",
|
||||
"deleteProject": "Убрать проект",
|
||||
"deleteSession": "Удалить сеанс",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить",
|
||||
"confirmDelete": "Что вы хотите сделать с",
|
||||
"sessionCount_one": "Этот проект содержит {{count}} разговор.",
|
||||
"sessionCount_few": "Этот проект содержит {{count}} разговора.",
|
||||
"sessionCount_many": "Этот проект содержит {{count}} разговоров.",
|
||||
"sessionCount_other": "Этот проект содержит {{count}} разговоров.",
|
||||
"allConversationsDeleted": "Все разговоры будут удалены навсегда.",
|
||||
"cannotUndo": "Это действие нельзя отменить."
|
||||
"removeFromSidebar": "Убрать только из боковой панели",
|
||||
"deleteAllData": "Удалить все данные навсегда",
|
||||
"allConversationsDeleted": "Проект будет убран из боковой панели. Ваши файлы, воспоминания и данные сеансов сохранятся.",
|
||||
"cannotUndo": "Вы сможете добавить проект позже."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"projects": {
|
||||
"title": "项目",
|
||||
"newProject": "新建项目",
|
||||
"deleteProject": "删除项目",
|
||||
"deleteProject": "移除项目",
|
||||
"renameProject": "重命名项目",
|
||||
"noProjects": "未找到项目",
|
||||
"loadingProjects": "加载项目中...",
|
||||
@@ -40,7 +40,7 @@
|
||||
"createProject": "创建新项目",
|
||||
"refresh": "刷新项目和会话 (Ctrl+R)",
|
||||
"renameProject": "重命名项目 (F2)",
|
||||
"deleteProject": "删除空项目 (Delete)",
|
||||
"deleteProject": "从侧边栏移除项目 (Delete)",
|
||||
"addToFavorites": "添加到收藏",
|
||||
"removeFromFavorites": "从收藏移除",
|
||||
"editSessionName": "手动编辑会话名称",
|
||||
@@ -95,14 +95,14 @@
|
||||
"deleteSuccess": "删除成功",
|
||||
"errorOccurred": "发生错误",
|
||||
"deleteSessionConfirm": "确定要删除此会话吗?此操作无法撤销。",
|
||||
"deleteProjectConfirm": "确定要删除此空项目吗?此操作无法撤销。",
|
||||
"deleteProjectConfirm": "从侧边栏移除此项目?您的项目文件、记忆和会话数据不会被删除。",
|
||||
"enterProjectPath": "请输入项目路径",
|
||||
"deleteSessionFailed": "删除会话失败,请重试。",
|
||||
"deleteSessionError": "删除会话时出错,请重试。",
|
||||
"renameSessionFailed": "重命名会话失败,请重试。",
|
||||
"renameSessionError": "重命名会话时出错,请重试。",
|
||||
"deleteProjectFailed": "删除项目失败,请重试。",
|
||||
"deleteProjectError": "删除项目时出错,请重试。",
|
||||
"deleteProjectFailed": "移除项目失败,请重试。",
|
||||
"deleteProjectError": "移除项目时出错,请重试。",
|
||||
"createProjectFailed": "创建项目失败,请重试。",
|
||||
"createProjectError": "创建项目时出错,请重试。"
|
||||
},
|
||||
@@ -122,12 +122,14 @@
|
||||
"projectsScanned_other": "{{count}} 个项目已扫描"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "删除项目",
|
||||
"deleteProject": "移除项目",
|
||||
"deleteSession": "删除会话",
|
||||
"confirmDelete": "您确定要删除",
|
||||
"confirmDelete": "您想如何处理",
|
||||
"sessionCount_one": "此项目包含 {{count}} 个对话。",
|
||||
"sessionCount_other": "此项目包含 {{count}} 个对话。",
|
||||
"allConversationsDeleted": "所有对话将被永久删除。",
|
||||
"cannotUndo": "此操作无法撤销。"
|
||||
"removeFromSidebar": "仅从侧边栏移除",
|
||||
"deleteAllData": "永久删除所有数据",
|
||||
"allConversationsDeleted": "项目将从侧边栏中移除。您的文件、记忆和会话数据将会保留。",
|
||||
"cannotUndo": "您可以稍后重新添加此项目。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,15 @@ export const api = {
|
||||
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteProject: (projectName, force = false) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
||||
deleteProject: (projectName, force = false, deleteData = false) => {
|
||||
const params = new URLSearchParams();
|
||||
if (force) params.set('force', 'true');
|
||||
if (deleteData) params.set('deleteData', 'true');
|
||||
const qs = params.toString();
|
||||
return authenticatedFetch(`/api/projects/${projectName}${qs ? `?${qs}` : ''}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
});
|
||||
},
|
||||
searchConversationsUrl: (query, limit = 50) => {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||
|
||||
Reference in New Issue
Block a user