feat: v1.2.5 — UI improvements and session management fixes
- Fix session delete to scan all claude project dirs (not just first match) - Batch async rendering for message history with stale render guard - Add custom draggable scrollbar for chat area - Fix AskUserQuestion card rendered at bottom instead of top - Fix bubble split (msg-text + msg-tools) to prevent tool UI overwrite - Add delete confirmation dialog with warm theme styling - Support multiline display in user messages - Apply model config to settings.json immediately on save
This commit is contained in:
198
public/app.js
198
public/app.js
@@ -47,6 +47,7 @@
|
|||||||
let currentMode = localStorage.getItem('cc-web-mode') || 'yolo';
|
let currentMode = localStorage.getItem('cc-web-mode') || 'yolo';
|
||||||
let currentModel = 'opus';
|
let currentModel = 'opus';
|
||||||
let loginPasswordValue = ''; // store login password for force-change flow
|
let loginPasswordValue = ''; // store login password for force-change flow
|
||||||
|
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||||||
|
|
||||||
// --- DOM ---
|
// --- DOM ---
|
||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel) => document.querySelector(sel);
|
||||||
@@ -324,6 +325,16 @@
|
|||||||
|
|
||||||
const msgEl = createMsgElement('assistant', '');
|
const msgEl = createMsgElement('assistant', '');
|
||||||
msgEl.id = 'streaming-msg';
|
msgEl.id = 'streaming-msg';
|
||||||
|
// 流式消息 bubble 拆为 .msg-text 和 .msg-tools 两个子容器
|
||||||
|
const bubble = msgEl.querySelector('.msg-bubble');
|
||||||
|
bubble.innerHTML = '';
|
||||||
|
const textDiv = document.createElement('div');
|
||||||
|
textDiv.className = 'msg-text';
|
||||||
|
textDiv.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
||||||
|
const toolsDiv = document.createElement('div');
|
||||||
|
toolsDiv.className = 'msg-tools';
|
||||||
|
bubble.appendChild(textDiv);
|
||||||
|
bubble.appendChild(toolsDiv);
|
||||||
messagesDiv.appendChild(msgEl);
|
messagesDiv.appendChild(msgEl);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
@@ -361,7 +372,9 @@
|
|||||||
if (!streamEl) return;
|
if (!streamEl) return;
|
||||||
const bubble = streamEl.querySelector('.msg-bubble');
|
const bubble = streamEl.querySelector('.msg-bubble');
|
||||||
if (!bubble) return;
|
if (!bubble) return;
|
||||||
bubble.innerHTML = renderMarkdown(pendingText);
|
let textDiv = bubble.querySelector('.msg-text');
|
||||||
|
if (!textDiv) { textDiv = bubble; }
|
||||||
|
textDiv.innerHTML = renderMarkdown(pendingText);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +404,7 @@
|
|||||||
bubble.className = 'msg-bubble';
|
bubble.className = 'msg-bubble';
|
||||||
|
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
|
bubble.style.whiteSpace = 'pre-wrap';
|
||||||
bubble.textContent = content;
|
bubble.textContent = content;
|
||||||
} else {
|
} else {
|
||||||
bubble.innerHTML = content ? renderMarkdown(content) : '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
bubble.innerHTML = content ? renderMarkdown(content) : '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
||||||
@@ -401,35 +415,69 @@
|
|||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let renderEpoch = 0;
|
||||||
|
|
||||||
|
function buildMsgElement(m) {
|
||||||
|
const el = createMsgElement(m.role, m.content);
|
||||||
|
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
|
||||||
|
const bubble = el.querySelector('.msg-bubble');
|
||||||
|
for (const tc of m.toolCalls) {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = 'tool-call';
|
||||||
|
details.dataset.toolName = tc.name || '';
|
||||||
|
if (tc.name === 'AskUserQuestion') details.open = true;
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.innerHTML = `<span class="tool-call-icon done"></span> ${escapeHtml(tc.name)}`;
|
||||||
|
details.appendChild(summary);
|
||||||
|
const displayInput = tc.name === 'AskUserQuestion' ? tc.input : (tc.result || tc.input);
|
||||||
|
details.appendChild(buildToolContentElement(tc.name, displayInput));
|
||||||
|
bubble.appendChild(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMessages(messages) {
|
function renderMessages(messages) {
|
||||||
|
renderEpoch++;
|
||||||
|
const epoch = renderEpoch;
|
||||||
messagesDiv.innerHTML = '';
|
messagesDiv.innerHTML = '';
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
messagesDiv.innerHTML = '<div class="welcome-msg"><div class="welcome-icon">✿</div><h3>欢迎使用 CC-Web</h3><p>开始与 Claude Code 对话</p></div>';
|
messagesDiv.innerHTML = '<div class="welcome-msg"><div class="welcome-icon">✿</div><h3>欢迎使用 CC-Web</h3><p>开始与 Claude Code 对话</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const m of messages) {
|
// Batch render: last 10 first, then next 20, then the rest
|
||||||
const el = createMsgElement(m.role, m.content);
|
const batches = [];
|
||||||
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
|
const len = messages.length;
|
||||||
const bubble = el.querySelector('.msg-bubble');
|
if (len <= 10) {
|
||||||
for (const tc of m.toolCalls) {
|
batches.push([0, len]);
|
||||||
const details = document.createElement('details');
|
} else if (len <= 30) {
|
||||||
details.className = 'tool-call';
|
batches.push([len - 10, len]);
|
||||||
details.dataset.toolName = tc.name || '';
|
batches.push([0, len - 10]);
|
||||||
if (tc.name === 'AskUserQuestion') details.open = true;
|
} else {
|
||||||
|
batches.push([len - 10, len]);
|
||||||
const summary = document.createElement('summary');
|
batches.push([len - 30, len - 10]);
|
||||||
summary.innerHTML = `<span class="tool-call-icon done"></span> ${escapeHtml(tc.name)}`;
|
batches.push([0, len - 30]);
|
||||||
details.appendChild(summary);
|
|
||||||
|
|
||||||
const displayInput = tc.name === 'AskUserQuestion' ? tc.input : (tc.result || tc.input);
|
|
||||||
details.appendChild(buildToolContentElement(tc.name, displayInput));
|
|
||||||
|
|
||||||
bubble.insertBefore(details, bubble.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messagesDiv.appendChild(el);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render first batch immediately
|
||||||
|
const frag0 = document.createDocumentFragment();
|
||||||
|
for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i]));
|
||||||
|
messagesDiv.appendChild(frag0);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|
||||||
|
// Render remaining batches asynchronously, prepending each
|
||||||
|
let delay = 0;
|
||||||
|
for (let b = 1; b < batches.length; b++) {
|
||||||
|
const [start, end] = batches[b];
|
||||||
|
delay += 16;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (renderEpoch !== epoch) return; // session switched, abort stale render
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i]));
|
||||||
|
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
|
||||||
|
updateScrollbar();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAskUserInput(input) {
|
function normalizeAskUserInput(input) {
|
||||||
@@ -530,6 +578,8 @@
|
|||||||
if (!streamEl) return;
|
if (!streamEl) return;
|
||||||
const bubble = streamEl.querySelector('.msg-bubble');
|
const bubble = streamEl.querySelector('.msg-bubble');
|
||||||
if (!bubble) return;
|
if (!bubble) return;
|
||||||
|
let toolsDiv = bubble.querySelector('.msg-tools');
|
||||||
|
if (!toolsDiv) { toolsDiv = bubble; }
|
||||||
|
|
||||||
const details = document.createElement('details');
|
const details = document.createElement('details');
|
||||||
details.className = 'tool-call';
|
details.className = 'tool-call';
|
||||||
@@ -542,7 +592,7 @@
|
|||||||
details.appendChild(summary);
|
details.appendChild(summary);
|
||||||
details.appendChild(buildToolContentElement(name, input));
|
details.appendChild(buildToolContentElement(name, input));
|
||||||
|
|
||||||
bubble.appendChild(details);
|
toolsDiv.appendChild(details);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +610,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showDeleteConfirm(onConfirm) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'settings-overlay';
|
||||||
|
overlay.style.zIndex = '10002';
|
||||||
|
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'settings-panel';
|
||||||
|
box.innerHTML = `
|
||||||
|
<div style="font-size:0.9em;color:var(--text-primary);margin-bottom:20px;line-height:1.7">删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
<button id="del-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:#fff;font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">确认删除</button>
|
||||||
|
<button id="del-confirm-skip" style="width:100%;padding:9px;border:1px solid var(--border-color);border-radius:10px;background:var(--bg-tertiary);color:var(--text-secondary);font-size:0.85em;cursor:pointer;font-family:inherit">确认且不再提示</button>
|
||||||
|
<button id="del-confirm-cancel" style="width:100%;padding:9px;border:none;border-radius:10px;background:transparent;color:var(--text-muted);font-size:0.85em;cursor:pointer;font-family:inherit">取消</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const close = () => document.body.removeChild(overlay);
|
||||||
|
box.querySelector('#del-confirm-ok').addEventListener('click', () => { close(); onConfirm(); });
|
||||||
|
box.querySelector('#del-confirm-skip').addEventListener('click', () => {
|
||||||
|
skipDeleteConfirm = true;
|
||||||
|
localStorage.setItem('cc-web-skip-delete-confirm', '1');
|
||||||
|
close();
|
||||||
|
onConfirm();
|
||||||
|
});
|
||||||
|
box.querySelector('#del-confirm-cancel').addEventListener('click', close);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
}
|
||||||
|
|
||||||
function appendSystemMessage(message) {
|
function appendSystemMessage(message) {
|
||||||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||||||
if (welcome) welcome.remove();
|
if (welcome) welcome.remove();
|
||||||
@@ -578,10 +658,73 @@
|
|||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
updateScrollbar();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Session List ---
|
// --- Custom Scrollbar ---
|
||||||
|
const scrollbarEl = document.getElementById('custom-scrollbar');
|
||||||
|
const thumbEl = document.getElementById('custom-scrollbar-thumb');
|
||||||
|
|
||||||
|
function updateScrollbar() {
|
||||||
|
if (!scrollbarEl || !thumbEl) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = messagesDiv;
|
||||||
|
if (scrollHeight <= clientHeight) {
|
||||||
|
thumbEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thumbEl.style.display = '';
|
||||||
|
const trackH = scrollbarEl.clientHeight;
|
||||||
|
const thumbH = Math.max(30, trackH * clientHeight / scrollHeight);
|
||||||
|
const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackH - thumbH);
|
||||||
|
thumbEl.style.height = thumbH + 'px';
|
||||||
|
thumbEl.style.top = thumbTop + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesDiv.addEventListener('scroll', () => updateScrollbar(), { passive: true });
|
||||||
|
new ResizeObserver(updateScrollbar).observe(messagesDiv);
|
||||||
|
|
||||||
|
// Drag logic
|
||||||
|
let dragStartY = 0, dragStartScrollTop = 0, isDragging = false;
|
||||||
|
|
||||||
|
function onDragStart(e) {
|
||||||
|
isDragging = true;
|
||||||
|
dragStartY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
|
||||||
|
dragStartScrollTop = messagesDiv.scrollTop;
|
||||||
|
thumbEl.classList.add('dragging');
|
||||||
|
scrollbarEl.classList.add('active');
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
|
||||||
|
const dy = clientY - dragStartY;
|
||||||
|
const { scrollHeight, clientHeight } = messagesDiv;
|
||||||
|
const trackH = scrollbarEl.clientHeight;
|
||||||
|
const thumbH = Math.max(30, trackH * clientHeight / scrollHeight);
|
||||||
|
const ratio = (scrollHeight - clientHeight) / (trackH - thumbH);
|
||||||
|
messagesDiv.scrollTop = dragStartScrollTop + dy * ratio;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
if (!isDragging) return;
|
||||||
|
isDragging = false;
|
||||||
|
thumbEl.classList.remove('dragging');
|
||||||
|
scrollbarEl.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbEl.addEventListener('mousedown', onDragStart);
|
||||||
|
thumbEl.addEventListener('touchstart', onDragStart, { passive: false });
|
||||||
|
document.addEventListener('mousemove', onDragMove);
|
||||||
|
document.addEventListener('touchmove', onDragMove, { passive: false });
|
||||||
|
document.addEventListener('mouseup', onDragEnd);
|
||||||
|
document.addEventListener('touchend', onDragEnd);
|
||||||
|
|
||||||
|
updateScrollbar();
|
||||||
|
|
||||||
|
|
||||||
function renderSessionList() {
|
function renderSessionList() {
|
||||||
sessionList.innerHTML = '';
|
sessionList.innerHTML = '';
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
@@ -602,7 +745,7 @@
|
|||||||
const target = e.target;
|
const target = e.target;
|
||||||
if (target.classList.contains('delete')) {
|
if (target.classList.contains('delete')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm('删除此会话?')) {
|
const doDelete = () => {
|
||||||
send({ type: 'delete_session', sessionId: s.id });
|
send({ type: 'delete_session', sessionId: s.id });
|
||||||
if (s.id === currentSessionId) {
|
if (s.id === currentSessionId) {
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
@@ -610,6 +753,11 @@
|
|||||||
chatTitle.textContent = '新会话';
|
chatTitle.textContent = '新会话';
|
||||||
costDisplay.textContent = '';
|
costDisplay.textContent = '';
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if (skipDeleteConfirm) {
|
||||||
|
doDelete();
|
||||||
|
} else {
|
||||||
|
showDeleteConfirm(doDelete);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,16 @@
|
|||||||
<span id="cost-display" class="cost-display"></span>
|
<span id="cost-display" class="cost-display"></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="messages" class="messages">
|
<div class="messages-wrap">
|
||||||
<div class="welcome-msg">
|
<div id="messages" class="messages">
|
||||||
<div class="welcome-icon">✿</div>
|
<div class="welcome-msg">
|
||||||
<h3>欢迎使用 CC-Web</h3>
|
<div class="welcome-icon">✿</div>
|
||||||
<p>开始与 Claude Code 对话</p>
|
<h3>欢迎使用 CC-Web</h3>
|
||||||
|
<p>开始与 Claude Code 对话</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="custom-scrollbar" id="custom-scrollbar">
|
||||||
|
<div class="custom-scrollbar-thumb" id="custom-scrollbar-thumb"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -334,17 +334,60 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* === Messages === */
|
/* === Messages === */
|
||||||
.messages {
|
.messages-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.messages {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-right: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
.messages::-webkit-scrollbar { display: none; }
|
||||||
|
/* Custom scrollbar */
|
||||||
|
.custom-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.messages-wrap:hover .custom-scrollbar,
|
||||||
|
.custom-scrollbar.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.custom-scrollbar-thumb {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
width: 6px;
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
cursor: grab;
|
||||||
|
transition: width 0.15s, right 0.15s, background 0.15s;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
.custom-scrollbar-thumb:hover,
|
||||||
|
.custom-scrollbar-thumb.dragging {
|
||||||
|
width: 12px;
|
||||||
|
right: -3px;
|
||||||
|
background: #b0a090;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.custom-scrollbar-thumb.dragging { cursor: grabbing; }
|
||||||
.welcome-msg {
|
.welcome-msg {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
@@ -519,33 +562,6 @@ body {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
/* HTML preview */
|
|
||||||
.code-html-preview {
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
.code-html-preview summary {
|
|
||||||
padding: 6px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
user-select: none;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.code-html-preview summary::-webkit-details-marker { display: none; }
|
|
||||||
.code-html-preview summary::before {
|
|
||||||
content: '▸';
|
|
||||||
font-size: 11px;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
.code-html-preview[open] summary::before { transform: rotate(90deg); }
|
|
||||||
.code-html-preview iframe {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 180px;
|
|
||||||
border: 0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tool calls */
|
/* Tool calls */
|
||||||
.tool-call {
|
.tool-call {
|
||||||
|
|||||||
65
server.js
65
server.js
@@ -309,6 +309,27 @@ function loadClaudeJsonModelMap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply model config to runtime MODEL_MAP only (env vars are injected per-spawn, not here)
|
// Apply model config to runtime MODEL_MAP only (env vars are injected per-spawn, not here)
|
||||||
|
const CLAUDE_SETTINGS_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'settings.json');
|
||||||
|
const SETTINGS_API_KEYS = ['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_BASE_URL','ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL','ANTHROPIC_DEFAULT_HAIKU_MODEL'];
|
||||||
|
|
||||||
|
function applyCustomTemplateToSettings(tpl) {
|
||||||
|
let settings = {};
|
||||||
|
try { settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8')); } catch {}
|
||||||
|
const cleanedEnv = {};
|
||||||
|
for (const [k, v] of Object.entries(settings.env || {})) {
|
||||||
|
if (!SETTINGS_API_KEYS.includes(k)) cleanedEnv[k] = v;
|
||||||
|
}
|
||||||
|
if (tpl.apiKey) { cleanedEnv.ANTHROPIC_AUTH_TOKEN = tpl.apiKey; cleanedEnv.ANTHROPIC_API_KEY = tpl.apiKey; }
|
||||||
|
if (tpl.apiBase) cleanedEnv.ANTHROPIC_BASE_URL = tpl.apiBase;
|
||||||
|
if (tpl.defaultModel) cleanedEnv.ANTHROPIC_MODEL = tpl.defaultModel;
|
||||||
|
if (tpl.opusModel) cleanedEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = tpl.opusModel;
|
||||||
|
if (tpl.sonnetModel) cleanedEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = tpl.sonnetModel;
|
||||||
|
if (tpl.haikuModel) cleanedEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = tpl.haikuModel;
|
||||||
|
settings.env = cleanedEnv;
|
||||||
|
try { fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function applyModelConfig() {
|
function applyModelConfig() {
|
||||||
const config = loadModelConfig();
|
const config = loadModelConfig();
|
||||||
if (config.mode === 'custom' && config.activeTemplate) {
|
if (config.mode === 'custom' && config.activeTemplate) {
|
||||||
@@ -934,6 +955,11 @@ function handleSaveModelConfig(ws, newConfig) {
|
|||||||
// Re-apply at runtime
|
// Re-apply at runtime
|
||||||
MODEL_MAP = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
|
MODEL_MAP = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
|
||||||
applyModelConfig();
|
applyModelConfig();
|
||||||
|
// custom mode: write to ~/.claude/settings.json immediately on save
|
||||||
|
if (merged.mode === 'custom' && merged.activeTemplate) {
|
||||||
|
const tpl = merged.templates.find(t => t.name === merged.activeTemplate);
|
||||||
|
if (tpl) applyCustomTemplateToSettings(tpl);
|
||||||
|
}
|
||||||
plog('INFO', 'model_config_saved', { mode: merged.mode, activeTemplate: merged.activeTemplate });
|
plog('INFO', 'model_config_saved', { mode: merged.mode, activeTemplate: merged.activeTemplate });
|
||||||
wsSend(ws, { type: 'model_config', config: getModelConfigMasked() });
|
wsSend(ws, { type: 'model_config', config: getModelConfigMasked() });
|
||||||
wsSend(ws, { type: 'system_message', message: '模型配置已保存' });
|
wsSend(ws, { type: 'system_message', message: '模型配置已保存' });
|
||||||
@@ -1138,7 +1164,23 @@ function handleDeleteSession(ws, sessionId) {
|
|||||||
cleanRunDir(sessionId);
|
cleanRunDir(sessionId);
|
||||||
try {
|
try {
|
||||||
const p = sessionPath(sessionId);
|
const p = sessionPath(sessionId);
|
||||||
|
// Read claudeSessionId before deleting the file
|
||||||
|
let claudeSessionId = null;
|
||||||
|
try {
|
||||||
|
const session = loadSession(sessionId);
|
||||||
|
claudeSessionId = session?.claudeSessionId || null;
|
||||||
|
} catch {}
|
||||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||||
|
// Sync-delete the corresponding Claude native session .jsonl
|
||||||
|
if (claudeSessionId) {
|
||||||
|
const projectsDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
|
||||||
|
try {
|
||||||
|
for (const proj of fs.readdirSync(projectsDir)) {
|
||||||
|
const target = path.join(projectsDir, proj, `${claudeSessionId}.jsonl`);
|
||||||
|
if (fs.existsSync(target)) fs.unlinkSync(target);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
sendSessionList(ws);
|
sendSessionList(ws);
|
||||||
} catch {
|
} catch {
|
||||||
wsSend(ws, { type: 'error', message: 'Failed to delete session' });
|
wsSend(ws, { type: 'error', message: 'Failed to delete session' });
|
||||||
@@ -1295,28 +1337,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
const modelCfg = loadModelConfig();
|
const modelCfg = loadModelConfig();
|
||||||
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
|
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
|
||||||
const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate);
|
const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate);
|
||||||
if (tpl) {
|
if (tpl) applyCustomTemplateToSettings(tpl);
|
||||||
const CLAUDE_SETTINGS_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'settings.json');
|
|
||||||
let settings = {};
|
|
||||||
try { settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8')); } catch {}
|
|
||||||
const API_KEYS = ['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_BASE_URL','ANTHROPIC_MODEL',
|
|
||||||
'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL','ANTHROPIC_DEFAULT_HAIKU_MODEL'];
|
|
||||||
const existingEnv = settings.env || {};
|
|
||||||
// Remove old API-related keys, keep non-API keys
|
|
||||||
const cleanedEnv = {};
|
|
||||||
for (const [k, v] of Object.entries(existingEnv)) {
|
|
||||||
if (!API_KEYS.includes(k)) cleanedEnv[k] = v;
|
|
||||||
}
|
|
||||||
// Inject template values
|
|
||||||
if (tpl.apiKey) { cleanedEnv.ANTHROPIC_AUTH_TOKEN = tpl.apiKey; cleanedEnv.ANTHROPIC_API_KEY = tpl.apiKey; }
|
|
||||||
if (tpl.apiBase) cleanedEnv.ANTHROPIC_BASE_URL = tpl.apiBase;
|
|
||||||
if (tpl.defaultModel) cleanedEnv.ANTHROPIC_MODEL = tpl.defaultModel;
|
|
||||||
if (tpl.opusModel) cleanedEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = tpl.opusModel;
|
|
||||||
if (tpl.sonnetModel) cleanedEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = tpl.sonnetModel;
|
|
||||||
if (tpl.haikuModel) cleanedEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = tpl.haikuModel;
|
|
||||||
settings.env = cleanedEnv;
|
|
||||||
try { fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2)); } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user