mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-06 13:15:38 +08:00
Compare commits
2 Commits
fix/tool-r
...
fix/restar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a14aa08d8 | ||
|
|
7704fde228 |
@@ -1,218 +0,0 @@
|
|||||||
# CloudCLI UI Nginx subpath deployment template.
|
|
||||||
#
|
|
||||||
# Purpose:
|
|
||||||
# Serve CloudCLI UI from a path prefix such as:
|
|
||||||
# http://localhost/ai/
|
|
||||||
# https://example.com/ai/
|
|
||||||
#
|
|
||||||
# CloudCLI itself still runs at the root of its own HTTP server, for example:
|
|
||||||
# http://127.0.0.1:3001/
|
|
||||||
#
|
|
||||||
# Nginx receives public requests under /ai, strips that prefix, and forwards the
|
|
||||||
# remaining path to CloudCLI. For example:
|
|
||||||
# /ai/ -> /
|
|
||||||
# /ai/session/abc -> /session/abc
|
|
||||||
# /ai/assets/index.js -> /assets/index.js
|
|
||||||
#
|
|
||||||
# Important Nginx limitation:
|
|
||||||
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
|
|
||||||
# The configurable variables below are still useful for proxy/filter values,
|
|
||||||
# but if you change /ai to a different subpath, also update every line marked:
|
|
||||||
# [SUBPATH LITERAL]
|
|
||||||
#
|
|
||||||
# To use a different subpath, replace these literal matchers:
|
|
||||||
# location = /ai
|
|
||||||
# location ^~ /ai/
|
|
||||||
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
|
|
||||||
#
|
|
||||||
# Recommended deployment shape:
|
|
||||||
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
|
|
||||||
# are also proxied because the current frontend still calls those endpoints
|
|
||||||
# with root-relative URLs.
|
|
||||||
|
|
||||||
worker_processes 1;
|
|
||||||
|
|
||||||
events {
|
|
||||||
# Maximum simultaneous connections handled by each worker process.
|
|
||||||
# The default is enough for local testing and small self-hosted deployments.
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
|
|
||||||
# This map gives us the right Connection header for both cases:
|
|
||||||
# Upgrade present -> "upgrade"
|
|
||||||
# Upgrade absent -> "close"
|
|
||||||
map $http_upgrade $connection_upgrade {
|
|
||||||
default upgrade;
|
|
||||||
'' close;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
|
|
||||||
# add ssl_certificate / ssl_certificate_key lines.
|
|
||||||
listen 80 default_server;
|
|
||||||
|
|
||||||
# Use your real hostname in production, for example:
|
|
||||||
# server_name cloudcli.example.com;
|
|
||||||
server_name localhost 127.0.0.1;
|
|
||||||
|
|
||||||
# ---- User settings -------------------------------------------------
|
|
||||||
#
|
|
||||||
# Public path prefix where users access CloudCLI.
|
|
||||||
# Do not add a trailing slash.
|
|
||||||
#
|
|
||||||
# This variable can be used in redirects and response rewrites. It
|
|
||||||
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
|
|
||||||
# lines too if you change it.
|
|
||||||
set $cloudcli_subpath /ai;
|
|
||||||
|
|
||||||
# Private upstream URL where the CloudCLI server is listening.
|
|
||||||
# For a default local server this is usually http://127.0.0.1:3001.
|
|
||||||
set $cloudcli_upstream http://127.0.0.1:3001;
|
|
||||||
|
|
||||||
# Allow larger file uploads through the code editor/project file APIs.
|
|
||||||
client_max_body_size 100m;
|
|
||||||
|
|
||||||
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
|
||||||
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
|
||||||
location = /ai {
|
|
||||||
return 301 $cloudcli_subpath/;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main prefixed CloudCLI UI route.
|
|
||||||
#
|
|
||||||
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
|
|
||||||
# $cloudcli_subpath.
|
|
||||||
location ^~ /ai/ {
|
|
||||||
# Strip the public subpath before proxying. CloudCLI expects to see
|
|
||||||
# root paths such as /, /session/:id, /assets/..., /manifest.json.
|
|
||||||
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
|
|
||||||
|
|
||||||
# Forward the rewritten request to the private CloudCLI server.
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
|
|
||||||
# browser reaches a socket endpoint under the subpath.
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
# Preserve useful request metadata for logs and future app support.
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
|
|
||||||
# WebSocket upgrade headers. Harmless for normal HTTP requests.
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
|
|
||||||
# Long-running agent and terminal sessions can stay open for a long
|
|
||||||
# time, so avoid closing idle proxied connections too aggressively.
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
|
|
||||||
# Disable gzip from the upstream response so sub_filter can inspect
|
|
||||||
# and rewrite HTML/JSON/JS response bodies.
|
|
||||||
proxy_set_header Accept-Encoding "";
|
|
||||||
|
|
||||||
# Rewrite browser-visible root-relative URLs so the runtime can
|
|
||||||
# discover that the app is mounted under the subpath.
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# href="/manifest.json" -> href="/ai/manifest.json"
|
|
||||||
# src="/assets/app.js" -> src="/ai/assets/app.js"
|
|
||||||
#
|
|
||||||
# These rewrites are important for React Router basename detection.
|
|
||||||
sub_filter_once off;
|
|
||||||
sub_filter_types
|
|
||||||
application/json
|
|
||||||
application/manifest+json
|
|
||||||
application/javascript
|
|
||||||
text/javascript;
|
|
||||||
|
|
||||||
sub_filter 'href="/' 'href="$cloudcli_subpath/';
|
|
||||||
sub_filter 'src="/' 'src="$cloudcli_subpath/';
|
|
||||||
|
|
||||||
# The production HTML and JS register the service worker at /sw.js.
|
|
||||||
# Rewrite that registration so the worker is served from /ai/sw.js.
|
|
||||||
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
|
|
||||||
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
|
|
||||||
|
|
||||||
# The manifest and service worker contain root-relative paths too.
|
|
||||||
# Rewriting them keeps PWA metadata and cached manifest requests
|
|
||||||
# under the same public subpath.
|
|
||||||
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
|
|
||||||
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
|
|
||||||
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
|
|
||||||
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
|
|
||||||
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
|
|
||||||
}
|
|
||||||
|
|
||||||
# Root API proxy.
|
|
||||||
#
|
|
||||||
# The current CloudCLI frontend calls APIs with root-relative URLs such
|
|
||||||
# as /api/auth/login. Keep this location unless the frontend becomes
|
|
||||||
# fully prefix-aware for API requests.
|
|
||||||
location ^~ /api/ {
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main app WebSocket proxy.
|
|
||||||
#
|
|
||||||
# The frontend opens /ws for realtime chat/session/task updates.
|
|
||||||
location /ws {
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Shell WebSocket proxy.
|
|
||||||
#
|
|
||||||
# The browser terminal uses /shell. It requires the same WebSocket
|
|
||||||
# upgrade handling as /ws.
|
|
||||||
location /shell {
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Optional health endpoint proxy used by the frontend version checker.
|
|
||||||
location = /health {
|
|
||||||
proxy_pass $cloudcli_upstream;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
173
server/index.js
173
server/index.js
@@ -1483,133 +1483,74 @@ function permToRwx(perm) {
|
|||||||
return r + w + x;
|
return r + w + x;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directories that are almost never interesting for a project tree but can
|
|
||||||
// contain tens of thousands of files. Skipping them before recursion keeps
|
|
||||||
// traversal time bounded on large monorepos and high-latency filesystems
|
|
||||||
// (NFS / SMB).
|
|
||||||
const IGNORED_DIRS = new Set([
|
|
||||||
// JS / TS toolchains
|
|
||||||
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
|
|
||||||
// VCS
|
|
||||||
'.git', '.svn', '.hg',
|
|
||||||
// Python
|
|
||||||
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
|
|
||||||
// Rust / Go / Java / Ruby
|
|
||||||
'target', 'vendor',
|
|
||||||
// Build output / IDE
|
|
||||||
'.gradle', '.idea', 'coverage', '.nyc_output'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const DEFAULT_FS_CONCURRENCY = 64;
|
|
||||||
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
|
|
||||||
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
|
|
||||||
? parsedFsConcurrency
|
|
||||||
: DEFAULT_FS_CONCURRENCY;
|
|
||||||
let activeFsOperations = 0;
|
|
||||||
const pendingFsOperations = [];
|
|
||||||
|
|
||||||
async function acquire() {
|
|
||||||
if (activeFsOperations < FS_CONCURRENCY) {
|
|
||||||
activeFsOperations += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
pendingFsOperations.push(resolve);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function release() {
|
|
||||||
const next = pendingFsOperations.shift();
|
|
||||||
if (next) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeFsOperations = Math.max(0, activeFsOperations - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||||
// Using fsPromises from import
|
// Using fsPromises from import
|
||||||
let entries;
|
const items = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await acquire();
|
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||||
try {
|
|
||||||
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
for (const entry of entries) {
|
||||||
} finally {
|
// Debug: log all entries including hidden files
|
||||||
release();
|
|
||||||
|
|
||||||
|
// Skip heavy build directories and VCS directories
|
||||||
|
if (entry.name === 'node_modules' ||
|
||||||
|
entry.name === 'dist' ||
|
||||||
|
entry.name === 'build' ||
|
||||||
|
entry.name === '.git' ||
|
||||||
|
entry.name === '.svn' ||
|
||||||
|
entry.name === '.hg') continue;
|
||||||
|
|
||||||
|
const itemPath = path.join(dirPath, entry.name);
|
||||||
|
const item = {
|
||||||
|
name: entry.name,
|
||||||
|
path: itemPath,
|
||||||
|
type: entry.isDirectory() ? 'directory' : 'file'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get file stats for additional metadata
|
||||||
|
try {
|
||||||
|
const stats = await fsPromises.stat(itemPath);
|
||||||
|
item.size = stats.size;
|
||||||
|
item.modified = stats.mtime.toISOString();
|
||||||
|
|
||||||
|
// Convert permissions to rwx format
|
||||||
|
const mode = stats.mode;
|
||||||
|
const ownerPerm = (mode >> 6) & 7;
|
||||||
|
const groupPerm = (mode >> 3) & 7;
|
||||||
|
const otherPerm = mode & 7;
|
||||||
|
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
||||||
|
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
||||||
|
} catch (statError) {
|
||||||
|
// If stat fails, provide default values
|
||||||
|
item.size = 0;
|
||||||
|
item.modified = null;
|
||||||
|
item.permissions = '000';
|
||||||
|
item.permissionsRwx = '---------';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||||
|
// Recursively get subdirectories but limit depth
|
||||||
|
try {
|
||||||
|
// Check if we can access the directory before trying to read it
|
||||||
|
await fsPromises.access(item.path, fs.constants.R_OK);
|
||||||
|
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
||||||
|
} catch (e) {
|
||||||
|
// Silently skip directories we can't access (permission denied, etc.)
|
||||||
|
item.children = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Only log non-permission errors to avoid spam
|
// Only log non-permission errors to avoid spam
|
||||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||||
console.error('Error reading directory:', error);
|
console.error('Error reading directory:', error);
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
|
|
||||||
|
|
||||||
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
|
|
||||||
// serial stat() was the real bottleneck — issuing them concurrently lets
|
|
||||||
// the kernel pipeline the round-trips and the recursive calls overlap too.
|
|
||||||
const items = await Promise.all(filteredEntries.map(async (entry) => {
|
|
||||||
const itemPath = path.join(dirPath, entry.name);
|
|
||||||
const item = {
|
|
||||||
name: entry.name,
|
|
||||||
path: itemPath,
|
|
||||||
type: entry.isDirectory() ? 'directory' : 'file'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get file stats for additional metadata
|
|
||||||
try {
|
|
||||||
await acquire();
|
|
||||||
try {
|
|
||||||
const stats = await fsPromises.lstat(itemPath);
|
|
||||||
item.size = stats.size;
|
|
||||||
item.modified = stats.mtime.toISOString();
|
|
||||||
|
|
||||||
// Mark symlinks so UI can distinguish them
|
|
||||||
if (stats.isSymbolicLink()) {
|
|
||||||
item.isSymlink = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert permissions to rwx format
|
|
||||||
const mode = stats.mode;
|
|
||||||
const ownerPerm = (mode >> 6) & 7;
|
|
||||||
const groupPerm = (mode >> 3) & 7;
|
|
||||||
const otherPerm = mode & 7;
|
|
||||||
item.permissions =
|
|
||||||
((mode >> 6) & 7).toString() +
|
|
||||||
((mode >> 3) & 7).toString() +
|
|
||||||
(mode & 7).toString();
|
|
||||||
item.permissionsRwx =
|
|
||||||
permToRwx(ownerPerm) +
|
|
||||||
permToRwx(groupPerm) +
|
|
||||||
permToRwx(otherPerm);
|
|
||||||
} finally {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
} catch (statError) {
|
|
||||||
// If stat fails, provide default values
|
|
||||||
item.size = 0;
|
|
||||||
item.modified = null;
|
|
||||||
item.permissions = '000';
|
|
||||||
item.permissionsRwx = '---------';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
|
||||||
// Recurse. Let readdir's own EACCES bubble up through the catch in
|
|
||||||
// the recursive call rather than doing a separate access() probe
|
|
||||||
// (which doubled the round-trip count on SMB without adding info).
|
|
||||||
// The recursive call starts with a bounded readdir; holding a permit
|
|
||||||
// for the whole subtree can deadlock when sibling directories are
|
|
||||||
// waiting on their own children.
|
|
||||||
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) {
|
||||||
return a.type === 'directory' ? -1 : 1;
|
return a.type === 'directory' ? -1 : 1;
|
||||||
|
|||||||
@@ -20,13 +20,7 @@ export function verifyWebSocketClient(
|
|||||||
dependencies: WebSocketAuthDependencies
|
dependencies: WebSocketAuthDependencies
|
||||||
): boolean {
|
): boolean {
|
||||||
const request = info.req as AuthenticatedWebSocketRequest;
|
const request = info.req as AuthenticatedWebSocketRequest;
|
||||||
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
console.log('WebSocket connection attempt to:', request.url);
|
||||||
const loggedUrl = new URL(upgradeUrl);
|
|
||||||
if (loggedUrl.searchParams.has('token')) {
|
|
||||||
loggedUrl.searchParams.set('token', 'REDACTED');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
|
|
||||||
|
|
||||||
// Platform mode: use the first DB user and skip token checks.
|
// Platform mode: use the first DB user and skip token checks.
|
||||||
if (dependencies.isPlatform) {
|
if (dependencies.isPlatform) {
|
||||||
@@ -42,6 +36,7 @@ export function verifyWebSocketClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OSS mode: read JWT from query string first, then Authorization header.
|
// OSS mode: read JWT from query string first, then Authorization header.
|
||||||
|
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||||
const token =
|
const token =
|
||||||
upgradeUrl.searchParams.get('token') ??
|
upgradeUrl.searchParams.get('token') ??
|
||||||
request.headers.authorization?.split(' ')[1] ??
|
request.headers.authorization?.split(' ')[1] ??
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
|
|||||||
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
||||||
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
||||||
|
|
||||||
function formatToolResultContent(content: unknown): string {
|
|
||||||
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
|
||||||
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
|
|
||||||
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
||||||
* that the existing UI components expect.
|
* that the existing UI components expect.
|
||||||
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
|
|
||||||
// First pass: collect tool results for attachment
|
// First pass: collect tool results for attachment
|
||||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||||
const toolUseIds = new Set<string>();
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.kind === 'tool_use' && msg.toolId) {
|
|
||||||
toolUseIds.add(msg.toolId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||||
toolResultMap.set(msg.toolId, msg);
|
toolResultMap.set(msg.toolId, msg);
|
||||||
}
|
}
|
||||||
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
|
|
||||||
const toolResult = tr
|
const toolResult = tr
|
||||||
? {
|
? {
|
||||||
content: formatToolResultContent(tr.content),
|
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||||
isError: Boolean(tr.isError),
|
isError: Boolean(tr.isError),
|
||||||
toolUseResult: (tr as any).toolUseResult,
|
toolUseResult: (tr as any).toolUseResult,
|
||||||
}
|
}
|
||||||
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
// tool_result is handled via attachment to tool_use above
|
// tool_result is handled via attachment to tool_use above
|
||||||
case 'tool_result': {
|
case 'tool_result':
|
||||||
if (msg.toolId && toolUseIds.has(msg.toolId)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = formatToolResultContent(msg.content || '');
|
|
||||||
if (!content.trim()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
converted.push({
|
|
||||||
type: msg.isError ? 'error' : 'assistant',
|
|
||||||
content,
|
|
||||||
timestamp: msg.timestamp,
|
|
||||||
toolId: msg.toolId,
|
|
||||||
...sharedMetadata,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -564,15 +564,11 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
|
|||||||
|
|
||||||
if (!config.result) return false;
|
if (!config.result) return false;
|
||||||
|
|
||||||
// Hidden/success-only configs suppress noisy successful output, but errors
|
|
||||||
// still need to be visible so failed tool calls are diagnosable.
|
|
||||||
if (toolResult?.isError) return false;
|
|
||||||
|
|
||||||
// Always hidden
|
// Always hidden
|
||||||
if (config.result.hidden) return true;
|
if (config.result.hidden) return true;
|
||||||
|
|
||||||
// Hide on success only
|
// Hide on success only
|
||||||
if (config.result.hideOnSuccess && toolResult) {
|
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -9,10 +8,10 @@ import type {
|
|||||||
Provider,
|
Provider,
|
||||||
} from '../../types/types';
|
} from '../../types/types';
|
||||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||||
|
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||||
|
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
import MessageCopyControl from './MessageCopyControl';
|
import MessageCopyControl from './MessageCopyControl';
|
||||||
|
|
||||||
@@ -42,9 +41,10 @@ type InteractiveOption = {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||||
|
|
||||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
@@ -53,6 +53,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||||
|
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||||
const userCopyContent = String(message.content || '');
|
const userCopyContent = String(message.content || '');
|
||||||
const formattedMessageContent = useMemo(
|
const formattedMessageContent = useMemo(
|
||||||
() => formatUsageLimitText(String(message.content || '')),
|
() => formatUsageLimitText(String(message.content || '')),
|
||||||
@@ -71,6 +73,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
!message.isThinking;
|
!message.isThinking;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPermissionGrantState('idle');
|
||||||
|
}, [permissionSuggestion?.entry, message.toolId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = messageRef.current;
|
const node = messageRef.current;
|
||||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||||
@@ -235,6 +241,55 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||||
{String(message.toolResult.content || '')}
|
{String(message.toolResult.content || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
|
{permissionSuggestion && (
|
||||||
|
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!onGrantToolPermission) return;
|
||||||
|
const result = onGrantToolPermission(permissionSuggestion);
|
||||||
|
if (result?.success) {
|
||||||
|
setPermissionGrantState('granted');
|
||||||
|
} else {
|
||||||
|
setPermissionGrantState('error');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||||
|
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
|
||||||
|
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||||
|
? t('permissions.added')
|
||||||
|
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
|
||||||
|
</button>
|
||||||
|
{onShowSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
||||||
|
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
|
||||||
|
>
|
||||||
|
{t('permissions.openSettings')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
||||||
|
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
|
||||||
|
</div>
|
||||||
|
{permissionGrantState === 'error' && (
|
||||||
|
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||||
|
{t('permissions.error')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
||||||
|
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
||||||
|
{t('permissions.retry')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user