mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-06 04:55:45 +08:00
Compare commits
8 Commits
v1.33.0
...
fix/claude
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9cdf0114 | ||
|
|
c667b6a179 | ||
|
|
fa9eaf5573 | ||
|
|
2edfef2e3f | ||
|
|
96b16b42e4 | ||
|
|
f082cdc63b | ||
|
|
d9e9df183f | ||
|
|
43c33d5cb1 |
17
package-lock.json
generated
17
package-lock.json
generated
@@ -39,6 +39,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"dompurify": "^3.4.7",
|
||||
"express": "^4.18.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -4580,6 +4581,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -7485,6 +7493,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"dompurify": "^3.4.7",
|
||||
"express": "^4.18.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
|
||||
@@ -11,7 +11,7 @@ export const CLAUDE_MODELS = {
|
||||
{
|
||||
value: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
|
||||
description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
|
||||
@@ -304,7 +304,11 @@ function extractTokenBudget(sdkMessage) {
|
||||
|
||||
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||
if (messageUsage && typeof messageUsage === 'object') {
|
||||
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
|
||||
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
|
||||
const cacheTokens = cacheCreationTokens + cacheReadTokens;
|
||||
const inputTokens = directInputTokens + cacheTokens;
|
||||
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||
@@ -314,6 +318,9 @@ function extractTokenBudget(sdkMessage) {
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
cacheTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
|
||||
@@ -87,6 +87,11 @@ const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||
|
||||
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
||||
|
||||
function readUsageNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
@@ -1386,6 +1391,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
|
||||
// Find the latest assistant message with usage data (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
@@ -1397,8 +1404,11 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const usage = entry.message.usage;
|
||||
|
||||
// Use token counts from latest assistant message only
|
||||
inputTokens = usage.input_tokens || 0;
|
||||
outputTokens = usage.output_tokens || 0;
|
||||
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
|
||||
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
|
||||
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens);
|
||||
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
|
||||
|
||||
break; // Stop after finding the latest assistant message
|
||||
}
|
||||
@@ -1409,12 +1419,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
}
|
||||
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const cacheTokens = cacheReadTokens + cacheCreationTokens;
|
||||
|
||||
res.json({
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
cacheTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
|
||||
@@ -83,6 +83,10 @@ export class ClaudeProviderAuth implements IProviderAuth {
|
||||
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
||||
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
|
||||
|
||||
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
|
||||
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
|
||||
}
|
||||
|
||||
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
@@ -31,6 +31,24 @@ export function createWebSocketServer(
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
|
||||
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
|
||||
// are silently torn down even when the UI is active, causing repeated
|
||||
// reconnect cycles. ws library heartbeat is opt-in.
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
try {
|
||||
ws.ping();
|
||||
} catch {
|
||||
// socket may have been closed concurrently — interval will be cleared below
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
const stopHeartbeat = () => clearInterval(heartbeat);
|
||||
ws.on('close', stopHeartbeat);
|
||||
ws.on('error', stopHeartbeat);
|
||||
|
||||
const incomingRequest = request as AuthenticatedWebSocketRequest;
|
||||
const url = incomingRequest.url ?? '/';
|
||||
const pathname = new URL(url, 'http://localhost').pathname;
|
||||
|
||||
@@ -592,12 +592,14 @@ class ResponseCollector {
|
||||
}
|
||||
}
|
||||
|
||||
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
|
||||
|
||||
return {
|
||||
inputTokens: totalInput,
|
||||
inputTokens,
|
||||
outputTokens: totalOutput,
|
||||
cacheReadTokens: totalCacheRead,
|
||||
cacheCreationTokens: totalCacheCreation,
|
||||
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
|
||||
totalTokens: inputTokens + totalOutput
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,16 +268,35 @@ Custom commands can be created in:
|
||||
tokenUsage.contextWindow ??
|
||||
0,
|
||||
) || 0;
|
||||
const inputTokensRaw =
|
||||
const normalizedInputValue =
|
||||
tokenUsage.inputTokens ??
|
||||
tokenUsage.input ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.breakdown?.input ??
|
||||
tokenUsage.promptTokens;
|
||||
const directInputTokens =
|
||||
Number(
|
||||
tokenUsage.inputTokens ??
|
||||
tokenUsage.input ??
|
||||
normalizedInputValue ??
|
||||
tokenUsage.input_tokens ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.breakdown?.input ??
|
||||
tokenUsage.promptTokens ??
|
||||
0
|
||||
) || 0;
|
||||
const cacheReadTokens =
|
||||
Number(
|
||||
tokenUsage.cacheReadTokens ??
|
||||
tokenUsage.cache_read_input_tokens ??
|
||||
tokenUsage.cacheReadInputTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const cacheCreationTokens =
|
||||
Number(
|
||||
tokenUsage.cacheCreationTokens ??
|
||||
tokenUsage.cache_creation_input_tokens ??
|
||||
tokenUsage.cacheCreationInputTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const inputTokens = normalizedInputValue == null
|
||||
? directInputTokens + cacheReadTokens + cacheCreationTokens
|
||||
: directInputTokens;
|
||||
const outputTokens =
|
||||
Number(
|
||||
tokenUsage.outputTokens ??
|
||||
@@ -288,8 +307,9 @@ Custom commands can be created in:
|
||||
tokenUsage.completionTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
|
||||
const used = reportedUsed || inputTokensRaw + outputTokens;
|
||||
const computedUsed = inputTokens + outputTokens;
|
||||
const hasTokenBreakdown = computedUsed > 0;
|
||||
const used = Math.max(reportedUsed, computedUsed);
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
@@ -302,7 +322,7 @@ Custom commands can be created in:
|
||||
...(hasTokenBreakdown
|
||||
? {
|
||||
tokenBreakdown: {
|
||||
input: inputTokensRaw,
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ export default function ChatComposer({
|
||||
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
value={input}
|
||||
onChange={onInputChange}
|
||||
onClick={onTextareaClick}
|
||||
|
||||
@@ -120,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
/* User message bubble on the right */
|
||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||
<div className="whitespace-pre-wrap break-words text-sm">
|
||||
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
@@ -405,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Reasoning accordion */}
|
||||
{showThinking && message.reasoning && (
|
||||
<Reasoning className="mb-3" defaultOpen={false}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
type Props = {
|
||||
@@ -10,6 +12,48 @@ type Props = {
|
||||
// Module-level cache so repeated renders don't re-fetch
|
||||
const svgCache = new Map<string, string>();
|
||||
|
||||
const FORBIDDEN_SVG_TAGS = [
|
||||
'script',
|
||||
'foreignObject',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'link',
|
||||
'meta',
|
||||
'style',
|
||||
'animate',
|
||||
'set',
|
||||
'animateTransform',
|
||||
'animateMotion',
|
||||
];
|
||||
|
||||
const FORBIDDEN_SVG_ATTRS = [
|
||||
'href',
|
||||
'xlink:href',
|
||||
'src',
|
||||
'style',
|
||||
];
|
||||
|
||||
function sanitizeSvg(svgText: string): string | null {
|
||||
const sanitized = DOMPurify.sanitize(svgText, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
FORBID_TAGS: FORBIDDEN_SVG_TAGS,
|
||||
FORBID_ATTR: FORBIDDEN_SVG_ATTRS,
|
||||
});
|
||||
|
||||
if (!sanitized) return null;
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(sanitized, 'image/svg+xml');
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.nodeName.toLowerCase() !== 'svg') return null;
|
||||
if (doc.querySelector('parsererror')) return null;
|
||||
return sanitized;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||
const url = iconFile
|
||||
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
||||
@@ -24,9 +68,11 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||
return r.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (text && text.trimStart().startsWith('<svg')) {
|
||||
svgCache.set(url, text);
|
||||
setSvg(text);
|
||||
if (!text) return;
|
||||
const sanitized = sanitizeSvg(text);
|
||||
if (sanitized) {
|
||||
svgCache.set(url, sanitized);
|
||||
setSvg(sanitized);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -35,10 +81,6 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||
if (!svg) return <span className={className} />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
<span className={className} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,12 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
||||
const { token } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// The cleanup below sets unmountedRef = true. Without this reset, every
|
||||
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
|
||||
// at its unmounted guard and leave the socket permanently disconnected.
|
||||
unmountedRef.current = false;
|
||||
connect();
|
||||
|
||||
|
||||
return () => {
|
||||
unmountedRef.current = true;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
|
||||
@@ -37,6 +37,10 @@ export default defineConfig(({ mode }) => {
|
||||
'/shell': {
|
||||
target: `ws://${proxyHost}:${serverPort}`,
|
||||
ws: true
|
||||
},
|
||||
'/plugin-ws': {
|
||||
target: `ws://${proxyHost}:${serverPort}`,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user