feat: v1.2.7 - 导入本地CLI会话、新建会话指定工作目录、检查更新功能
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 更新记录
|
||||
|
||||
- **v1.2.7**
|
||||
- 新增导入本地 CLI 会话:扫描 `~/.claude/projects/` 下的 `.jsonl`,解析后导入到 cc-web,可续接历史对话。
|
||||
- 新增新建会话指定工作目录:创建会话时弹窗设置 cwd,spawn 子进程时使用该目录,header 显示当前工作路径。
|
||||
- 新增检查更新功能:设置面板底部「检查更新」按钮,对比本地 CHANGELOG 与 GitHub 最新版本。
|
||||
- 修复导入历史会话中工具调用显示动态动画的问题:已完成的工具调用统一显示为静态状态。
|
||||
|
||||
- **v1.2.6**
|
||||
- 工具调用折叠显示:长任务中散落的工具调用块达到5个时自动折叠入唯一父节点,之后每满5个再次移入同一父节点(两级结构),输出结束后剩余散落项也一并收入;总数不超过5个则完整显示不折叠。
|
||||
- 新增编辑模板弹窗「获取上游模型列表」:通过 `/v1/models` 端点拉取可用模型,填充到四个模型输入框的下拉建议列表,支持自定义端点地址。
|
||||
@@ -8,9 +14,6 @@
|
||||
- 新增 AskUserQuestion 选项预览区:左侧选项列表,右侧实时显示选项说明;桌面端 hover 切换,移动端 tap 选中后点确认按钮发送。
|
||||
- 修复 `~/.claude/settings.json` 写入竞争问题:改为原子写入(先写临时文件再 rename),避免 Claude 子进程读到写了一半的文件导致随机 401 认证失败。
|
||||
- 修复 `ANTHROPIC_REASONING_MODEL` 被误删问题:补充到 settings.json 白名单,保留该字段不被覆盖。
|
||||
- 移动端自定义滚动条优化:加宽滑块热区(18px),滚动时自动显示滑块,停止后 1.2 秒淡出,修复 hover 粘滞导致半透明滑块残留问题。
|
||||
- 修复历史消息分批渲染时 prepend 导致的视口跳动问题:通过补偿 scrollTop 保持可见区域稳定。
|
||||
- 新增 HTML/SVG 代码块实时预览:代码块右上角新增 Preview 按钮,点击在 iframe 中渲染效果,可切换回 Source 查看代码。
|
||||
|
||||
- 修复删除会话时同步删除 `~/.claude/projects/` 下对应的原生会话历史,遍历所有项目目录确保完整清除。
|
||||
- 新增删除确认弹窗,支持「确认且不再提示」选项,风格与主界面一致。
|
||||
|
||||
231
public/app.js
231
public/app.js
@@ -49,6 +49,7 @@
|
||||
let currentMode = localStorage.getItem('cc-web-mode') || 'yolo';
|
||||
let currentModel = 'opus';
|
||||
let loginPasswordValue = ''; // store login password for force-change flow
|
||||
let currentCwd = null;
|
||||
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||||
|
||||
// --- DOM ---
|
||||
@@ -63,8 +64,12 @@
|
||||
const sidebarOverlay = $('#sidebar-overlay');
|
||||
const menuBtn = $('#menu-btn');
|
||||
const newChatBtn = $('#new-chat-btn');
|
||||
const newChatArrow = $('#new-chat-arrow');
|
||||
const newChatDropdown = $('#new-chat-dropdown');
|
||||
const importSessionBtn = $('#import-session-btn');
|
||||
const sessionList = $('#session-list');
|
||||
const chatTitle = $('#chat-title');
|
||||
const chatCwd = $('#chat-cwd');
|
||||
const costDisplay = $('#cost-display');
|
||||
const messagesDiv = $('#messages');
|
||||
const msgInput = $('#msg-input');
|
||||
@@ -225,6 +230,18 @@
|
||||
currentSessionId = msg.sessionId;
|
||||
localStorage.setItem('cc-web-session', currentSessionId);
|
||||
chatTitle.textContent = msg.title || '新会话';
|
||||
// 显示 cwd
|
||||
currentCwd = msg.cwd || null;
|
||||
if (currentCwd) {
|
||||
const parts = currentCwd.replace(/\/+$/, '').split('/');
|
||||
const short = parts.slice(-2).join('/') || currentCwd;
|
||||
chatCwd.textContent = '~/' + short;
|
||||
chatCwd.title = currentCwd;
|
||||
chatCwd.hidden = false;
|
||||
} else {
|
||||
chatCwd.hidden = true;
|
||||
chatCwd.textContent = '';
|
||||
}
|
||||
// 同步 session 的 mode(如有)
|
||||
if (msg.mode && MODE_LABELS[msg.mode]) {
|
||||
currentMode = msg.mode;
|
||||
@@ -347,6 +364,18 @@
|
||||
case 'password_changed':
|
||||
handlePasswordChanged(msg);
|
||||
break;
|
||||
|
||||
case 'native_sessions':
|
||||
if (typeof _onNativeSessions === 'function') _onNativeSessions(msg.groups || []);
|
||||
break;
|
||||
|
||||
case 'cwd_suggestions':
|
||||
if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg.paths || []);
|
||||
break;
|
||||
|
||||
case 'update_info':
|
||||
if (typeof window._ccOnUpdateInfo === 'function') window._ccOnUpdateInfo(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +505,7 @@
|
||||
bubble.style.whiteSpace = 'pre-wrap';
|
||||
bubble.textContent = content;
|
||||
} else {
|
||||
bubble.innerHTML = content ? renderMarkdown(content) : '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
||||
bubble.innerHTML = content ? renderMarkdown(content) : '';
|
||||
}
|
||||
|
||||
div.appendChild(avatar);
|
||||
@@ -1308,7 +1337,24 @@
|
||||
});
|
||||
|
||||
sidebarOverlay.addEventListener('click', closeSidebar);
|
||||
newChatBtn.addEventListener('click', () => send({ type: 'new_session' }));
|
||||
|
||||
// Split new-chat button
|
||||
newChatBtn.addEventListener('click', () => showNewSessionModal());
|
||||
newChatArrow.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
newChatDropdown.hidden = !newChatDropdown.hidden;
|
||||
});
|
||||
importSessionBtn.addEventListener('click', () => {
|
||||
newChatDropdown.hidden = true;
|
||||
showImportSessionModal();
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!newChatDropdown.hidden &&
|
||||
!newChatDropdown.contains(e.target) &&
|
||||
e.target !== newChatArrow) {
|
||||
newChatDropdown.hidden = true;
|
||||
}
|
||||
});
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
abortBtn.addEventListener('click', () => send({ type: 'abort' }));
|
||||
|
||||
@@ -1474,10 +1520,11 @@
|
||||
|
||||
<div class="settings-divider"></div>
|
||||
|
||||
<div class="settings-section-title">修改密码</div>
|
||||
<div class="settings-actions" style="margin-top:0">
|
||||
<div class="settings-actions" style="margin-top:0;flex-wrap:wrap;gap:10px">
|
||||
<button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
|
||||
<button class="btn-test" id="check-update-btn" style="padding:6px 16px">检查更新</button>
|
||||
</div>
|
||||
<div class="settings-status" id="update-status" style="margin-top:8px"></div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(panel);
|
||||
@@ -1852,6 +1899,35 @@
|
||||
const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn');
|
||||
pwOpenModalBtn.addEventListener('click', openPasswordModal);
|
||||
|
||||
// Check update button
|
||||
const checkUpdateBtn = panel.querySelector('#check-update-btn');
|
||||
const updateStatusEl = panel.querySelector('#update-status');
|
||||
let _onUpdateInfo = null;
|
||||
checkUpdateBtn.addEventListener('click', () => {
|
||||
updateStatusEl.textContent = '正在检查...';
|
||||
updateStatusEl.className = 'settings-status';
|
||||
_onUpdateInfo = (info) => {
|
||||
_onUpdateInfo = null;
|
||||
if (info.error) {
|
||||
updateStatusEl.textContent = '检查失败: ' + info.error;
|
||||
updateStatusEl.className = 'settings-status error';
|
||||
return;
|
||||
}
|
||||
if (info.hasUpdate) {
|
||||
updateStatusEl.innerHTML = `有新版本 <strong>v${escapeHtml(info.latestVersion)}</strong>(当前 v${escapeHtml(info.localVersion)}) <a href="${escapeHtml(info.releaseUrl)}" target="_blank" style="color:var(--accent)">查看更新</a>`;
|
||||
updateStatusEl.className = 'settings-status success';
|
||||
} else {
|
||||
updateStatusEl.textContent = `已是最新版本 v${info.localVersion}`;
|
||||
updateStatusEl.className = 'settings-status success';
|
||||
}
|
||||
};
|
||||
send({ type: 'check_update' });
|
||||
});
|
||||
|
||||
// Wire _onUpdateInfo into WS handler via closure
|
||||
const _origOnUpdateInfo = window._ccOnUpdateInfo;
|
||||
window._ccOnUpdateInfo = (info) => { if (_onUpdateInfo) _onUpdateInfo(info); };
|
||||
|
||||
function openPasswordModal() {
|
||||
const pwOverlay = document.createElement('div');
|
||||
pwOverlay.className = 'settings-overlay';
|
||||
@@ -1961,6 +2037,7 @@
|
||||
_onNotifyTestResult = null;
|
||||
_onModelConfig = null;
|
||||
_onFetchModelsResult = null;
|
||||
window._ccOnUpdateInfo = null;
|
||||
document.removeEventListener('keydown', _settingsEscape);
|
||||
}
|
||||
|
||||
@@ -2112,6 +2189,152 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- New Session Modal ---
|
||||
let _onCwdSuggestions = null;
|
||||
|
||||
function showNewSessionModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'new-session-overlay';
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-panel">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">新建会话</span>
|
||||
<button class="modal-close-btn" id="ns-close-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="modal-field-label">工作目录</label>
|
||||
<div class="modal-field-row">
|
||||
<input type="text" id="ns-cwd-input" class="modal-text-input" placeholder="例如 /home/user/project" list="ns-cwd-list" autocomplete="off">
|
||||
<datalist id="ns-cwd-list"></datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn-secondary" id="ns-cancel-btn">取消</button>
|
||||
<button class="modal-btn-primary" id="ns-create-btn">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const cwdInput = overlay.querySelector('#ns-cwd-input');
|
||||
const cwdList = overlay.querySelector('#ns-cwd-list');
|
||||
|
||||
// Fetch suggestions on focus
|
||||
cwdInput.addEventListener('focus', () => {
|
||||
_onCwdSuggestions = (paths) => {
|
||||
cwdList.innerHTML = paths.map(p => `<option value="${escapeHtml(p)}"></option>`).join('');
|
||||
};
|
||||
send({ type: 'list_cwd_suggestions' });
|
||||
});
|
||||
|
||||
function close() {
|
||||
overlay.remove();
|
||||
_onCwdSuggestions = null;
|
||||
}
|
||||
|
||||
overlay.querySelector('#ns-close-btn').addEventListener('click', close);
|
||||
overlay.querySelector('#ns-cancel-btn').addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
overlay.querySelector('#ns-create-btn').addEventListener('click', () => {
|
||||
const cwd = cwdInput.value.trim() || null;
|
||||
close();
|
||||
send({ type: 'new_session', cwd });
|
||||
});
|
||||
|
||||
cwdInput.focus();
|
||||
}
|
||||
|
||||
// --- Import Native Session Modal ---
|
||||
let _onNativeSessions = null;
|
||||
|
||||
function showImportSessionModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'import-session-overlay';
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-panel modal-panel-wide">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">导入本地 CLI 会话</span>
|
||||
<button class="modal-close-btn" id="is-close-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="is-body">
|
||||
<div class="modal-loading">正在加载…</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function close() {
|
||||
overlay.remove();
|
||||
_onNativeSessions = null;
|
||||
}
|
||||
|
||||
overlay.querySelector('#is-close-btn').addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
_onNativeSessions = (groups) => {
|
||||
const body = overlay.querySelector('#is-body');
|
||||
if (!body) return;
|
||||
if (!groups || groups.length === 0) {
|
||||
body.innerHTML = '<div class="modal-empty">未找到本地 CLI 会话</div>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = '';
|
||||
for (const group of groups) {
|
||||
const groupEl = document.createElement('div');
|
||||
groupEl.className = 'import-group';
|
||||
// Convert slug dir to readable path
|
||||
let readablePath = group.dir.replace(/-/g, '/');
|
||||
if (!readablePath.startsWith('/')) readablePath = '/' + readablePath;
|
||||
readablePath = readablePath.replace(/\/+/g, '/');
|
||||
const groupTitle = document.createElement('div');
|
||||
groupTitle.className = 'import-group-title';
|
||||
groupTitle.textContent = readablePath;
|
||||
groupEl.appendChild(groupTitle);
|
||||
for (const sess of group.sessions) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'import-item';
|
||||
const info = document.createElement('div');
|
||||
info.className = 'import-item-info';
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'import-item-title';
|
||||
titleEl.textContent = sess.title;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'import-item-meta';
|
||||
const cwdText = sess.cwd ? sess.cwd : '';
|
||||
const timeText = sess.updatedAt ? timeAgo(sess.updatedAt) : '';
|
||||
meta.textContent = [cwdText, timeText].filter(Boolean).join(' · ');
|
||||
info.appendChild(titleEl);
|
||||
info.appendChild(meta);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'import-item-btn';
|
||||
btn.textContent = sess.alreadyImported ? '重新导入' : '导入';
|
||||
btn.addEventListener('click', () => {
|
||||
if (sess.alreadyImported) {
|
||||
if (!confirm('已导入过此会话,重新导入将覆盖已有内容。确认继续?')) return;
|
||||
} else {
|
||||
if (!confirm('由于 cc-web 与本地 CLI 的逻辑不同,导入会话需要解析后方可展示,导入后将覆盖已有内容。确认继续?')) return;
|
||||
}
|
||||
close();
|
||||
send({ type: 'import_native_session', sessionId: sess.sessionId, projectDir: group.dir });
|
||||
});
|
||||
item.appendChild(info);
|
||||
item.appendChild(btn);
|
||||
groupEl.appendChild(item);
|
||||
}
|
||||
body.appendChild(groupEl);
|
||||
}
|
||||
};
|
||||
|
||||
send({ type: 'list_native_sessions' });
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
|
||||
@@ -32,7 +32,13 @@
|
||||
<!-- Sidebar -->
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="new-chat-split">
|
||||
<button id="new-chat-btn" class="new-chat-btn">+ 新会话</button>
|
||||
<button id="new-chat-arrow" class="new-chat-arrow" title="更多">▾</button>
|
||||
</div>
|
||||
<div id="new-chat-dropdown" class="new-chat-dropdown" hidden>
|
||||
<button id="import-session-btn">导入本地 CLI 会话</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="session-list" class="session-list"></div>
|
||||
<div class="sidebar-footer">
|
||||
@@ -48,6 +54,7 @@
|
||||
<header class="chat-header">
|
||||
<button id="menu-btn" class="menu-btn" title="菜单">☰</button>
|
||||
<span id="chat-title" class="chat-title">新会话</span>
|
||||
<span id="chat-cwd" class="chat-cwd" hidden></span>
|
||||
<select id="mode-select" class="mode-select" title="权限模式">
|
||||
<option value="yolo">YOLO</option>
|
||||
<option value="default">默认</option>
|
||||
|
||||
234
public/style.css
234
public/style.css
@@ -1309,3 +1309,237 @@ body {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* === New Chat Split Button === */
|
||||
.new-chat-split {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.new-chat-split .new-chat-btn {
|
||||
flex: 1;
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
.new-chat-arrow {
|
||||
padding: 0 10px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 0 10px 10px 0;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.new-chat-arrow:hover { background: var(--accent-hover); }
|
||||
.new-chat-dropdown {
|
||||
position: absolute;
|
||||
top: 54px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(45,31,20,0.12);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
.new-chat-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.new-chat-dropdown button:hover { background: var(--accent-light); }
|
||||
.sidebar-header { position: relative; }
|
||||
|
||||
/* === Chat CWD label === */
|
||||
.chat-cwd {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* === Modal Overlay === */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(45,31,20,0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 32px rgba(45,31,20,0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
.modal-panel-wide {
|
||||
max-width: 600px;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.modal-text-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.modal-text-input:focus { border-color: var(--accent); }
|
||||
.modal-btn-primary {
|
||||
padding: 9px 20px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.modal-btn-primary:hover { background: var(--accent-hover); }
|
||||
.modal-btn-secondary {
|
||||
padding: 9px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.modal-btn-secondary:hover { background: var(--bg-secondary); }
|
||||
.modal-loading, .modal-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 32px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* === Import Session List === */
|
||||
.import-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.import-group:last-child { margin-bottom: 0; }
|
||||
.import-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.import-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
.import-item:last-child { border-bottom: none; }
|
||||
.import-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.import-item-title {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.import-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.import-item-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.import-item-btn:hover { background: var(--accent-hover); }
|
||||
|
||||
267
server.js
267
server.js
@@ -814,7 +814,7 @@ wss.on('connection', (ws) => {
|
||||
handleAbort(ws);
|
||||
break;
|
||||
case 'new_session':
|
||||
handleNewSession(ws);
|
||||
handleNewSession(ws, msg);
|
||||
break;
|
||||
case 'load_session':
|
||||
handleLoadSession(ws, msg.sessionId);
|
||||
@@ -852,6 +852,18 @@ wss.on('connection', (ws) => {
|
||||
case 'fetch_models':
|
||||
handleFetchModels(ws, msg);
|
||||
break;
|
||||
case 'check_update':
|
||||
handleCheckUpdate(ws);
|
||||
break;
|
||||
case 'list_native_sessions':
|
||||
handleListNativeSessions(ws);
|
||||
break;
|
||||
case 'import_native_session':
|
||||
handleImportNativeSession(ws, msg);
|
||||
break;
|
||||
case 'list_cwd_suggestions':
|
||||
handleListCwdSuggestions(ws);
|
||||
break;
|
||||
default:
|
||||
wsSend(ws, { type: 'error', message: `Unknown type: ${msg.type}` });
|
||||
}
|
||||
@@ -1157,7 +1169,8 @@ function handleSlashCommand(ws, text, sessionId) {
|
||||
}
|
||||
|
||||
// === Session Handlers ===
|
||||
function handleNewSession(ws) {
|
||||
function handleNewSession(ws, msg) {
|
||||
const cwd = (msg && msg.cwd) ? String(msg.cwd) : null;
|
||||
const id = crypto.randomUUID();
|
||||
const session = {
|
||||
id,
|
||||
@@ -1169,10 +1182,11 @@ function handleNewSession(ws) {
|
||||
permissionMode: 'yolo',
|
||||
totalCost: 0,
|
||||
messages: [],
|
||||
cwd: cwd,
|
||||
};
|
||||
saveSession(session);
|
||||
wsSessionMap.set(ws, id);
|
||||
wsSend(ws, { type: 'session_info', sessionId: id, messages: [], title: session.title, mode: session.permissionMode, model: null });
|
||||
wsSend(ws, { type: 'session_info', sessionId: id, messages: [], title: session.title, mode: session.permissionMode, model: null, cwd: session.cwd });
|
||||
sendSessionList(ws);
|
||||
}
|
||||
|
||||
@@ -1204,6 +1218,7 @@ function handleLoadSession(ws, sessionId) {
|
||||
mode: session.permissionMode || 'yolo',
|
||||
model: modelShortName(session.model),
|
||||
hasUnread: hadUnread,
|
||||
cwd: session.cwd || null,
|
||||
});
|
||||
|
||||
// Resume streaming if process is still active
|
||||
@@ -1433,7 +1448,7 @@ function handleMessage(ws, msg, options = {}) {
|
||||
try {
|
||||
proc = spawn(CLAUDE_PATH, args, {
|
||||
env,
|
||||
cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(),
|
||||
cwd: session.cwd || process.env.HOME || process.env.USERPROFILE || process.cwd(),
|
||||
stdio: [inputFd, outputFd, errorFd],
|
||||
detached: !IS_WIN,
|
||||
windowsHide: true,
|
||||
@@ -1583,6 +1598,250 @@ function sanitizeToolInput(toolName, input) {
|
||||
return truncateObj(parsed, 500);
|
||||
}
|
||||
|
||||
// === Check Update ===
|
||||
function handleCheckUpdate(ws) {
|
||||
const localVersion = (() => {
|
||||
try {
|
||||
const cl = fs.readFileSync(path.join(__dirname, 'CHANGELOG.md'), 'utf8');
|
||||
const m = cl.match(/\*\*v([\d.]+)\*\*/);
|
||||
if (m) return m[1];
|
||||
} catch {}
|
||||
try { return JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version || 'unknown'; } catch {}
|
||||
return 'unknown';
|
||||
})();
|
||||
|
||||
const https = require('https');
|
||||
const options = {
|
||||
hostname: 'raw.githubusercontent.com',
|
||||
path: '/ZgDaniel/cc-web/main/CHANGELOG.md',
|
||||
headers: { 'User-Agent': 'cc-web-update-check' },
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', c => body += c);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return wsSend(ws, { type: 'update_info', localVersion, error: `HTTP ${res.statusCode}` });
|
||||
}
|
||||
const m = body.match(/\*\*v([\d.]+)\*\*/);
|
||||
const latest = m ? m[1] : null;
|
||||
if (!latest) {
|
||||
return wsSend(ws, { type: 'update_info', localVersion, error: '无法解析远端版本号' });
|
||||
}
|
||||
const hasUpdate = latest !== localVersion;
|
||||
wsSend(ws, {
|
||||
type: 'update_info',
|
||||
localVersion,
|
||||
latestVersion: latest,
|
||||
hasUpdate,
|
||||
releaseUrl: 'https://github.com/ZgDaniel/cc-web',
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => {
|
||||
wsSend(ws, { type: 'update_info', localVersion, error: '网络请求失败: ' + e.message });
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
wsSend(ws, { type: 'update_info', localVersion, error: '请求超时' });
|
||||
});
|
||||
req.end();
|
||||
}
|
||||
|
||||
// === Native Session Import ===
|
||||
|
||||
const CLAUDE_PROJECTS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
|
||||
|
||||
function parseJsonlToMessages(lines) {
|
||||
const messages = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let entry;
|
||||
try { entry = JSON.parse(trimmed); } catch { continue; }
|
||||
if (entry.type === 'user') {
|
||||
const raw = entry.message?.content;
|
||||
let content = '';
|
||||
if (typeof raw === 'string') {
|
||||
content = raw;
|
||||
} else if (Array.isArray(raw)) {
|
||||
// skip tool_result blocks, only take text blocks
|
||||
content = raw
|
||||
.filter(b => b.type === 'text')
|
||||
.map(b => b.text || '')
|
||||
.join('');
|
||||
}
|
||||
if (content.trim()) {
|
||||
messages.push({ role: 'user', content, timestamp: entry.timestamp || null });
|
||||
}
|
||||
} else if (entry.type === 'assistant') {
|
||||
const blocks = entry.message?.content;
|
||||
if (!Array.isArray(blocks)) continue;
|
||||
let content = '';
|
||||
const toolCalls = [];
|
||||
for (const b of blocks) {
|
||||
if (b.type === 'text' && b.text) {
|
||||
content += b.text;
|
||||
} else if (b.type === 'tool_use') {
|
||||
toolCalls.push({ name: b.name, id: b.id, input: b.input, done: true });
|
||||
}
|
||||
// skip thinking blocks
|
||||
}
|
||||
if (content.trim() || toolCalls.length > 0) {
|
||||
messages.push({ role: 'assistant', content, toolCalls, timestamp: entry.timestamp || null });
|
||||
}
|
||||
}
|
||||
// skip other types
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function getImportedSessionIds() {
|
||||
const imported = new Set();
|
||||
try {
|
||||
for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
|
||||
if (s.claudeSessionId) imported.add(s.claudeSessionId);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return imported;
|
||||
}
|
||||
|
||||
function handleListNativeSessions(ws) {
|
||||
const groups = [];
|
||||
try {
|
||||
const imported = getImportedSessionIds();
|
||||
const dirs = fs.readdirSync(CLAUDE_PROJECTS_DIR).filter(d => {
|
||||
try { return fs.statSync(path.join(CLAUDE_PROJECTS_DIR, d)).isDirectory(); } catch { return false; }
|
||||
});
|
||||
for (const dir of dirs) {
|
||||
const dirPath = path.join(CLAUDE_PROJECTS_DIR, dir);
|
||||
const sessionItems = [];
|
||||
try {
|
||||
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
|
||||
for (const f of files) {
|
||||
const sessionId = f.replace('.jsonl', '');
|
||||
const filePath = path.join(dirPath, f);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
// Find first user message for title
|
||||
let title = sessionId.slice(0, 20);
|
||||
let cwd = null;
|
||||
let updatedAt = null;
|
||||
let lastTs = null;
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try {
|
||||
const e = JSON.parse(t);
|
||||
if (e.timestamp) lastTs = e.timestamp;
|
||||
if (e.type === 'user' && !cwd) {
|
||||
cwd = e.cwd || null;
|
||||
const raw = e.message?.content;
|
||||
let text = '';
|
||||
if (typeof raw === 'string') text = raw;
|
||||
else if (Array.isArray(raw)) text = raw.filter(b => b.type === 'text').map(b => b.text || '').join('');
|
||||
if (text.trim()) title = text.trim().slice(0, 80).replace(/\n/g, ' ');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
updatedAt = lastTs;
|
||||
sessionItems.push({ sessionId, title, cwd, updatedAt, alreadyImported: imported.has(sessionId) });
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
if (sessionItems.length > 0) {
|
||||
sessionItems.sort((a, b) => {
|
||||
if (!a.updatedAt) return 1;
|
||||
if (!b.updatedAt) return -1;
|
||||
return new Date(b.updatedAt) - new Date(a.updatedAt);
|
||||
});
|
||||
groups.push({ dir, sessions: sessionItems });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
wsSend(ws, { type: 'native_sessions', groups });
|
||||
}
|
||||
|
||||
function handleImportNativeSession(ws, msg) {
|
||||
const { sessionId, projectDir } = msg;
|
||||
if (!sessionId || !projectDir) {
|
||||
return wsSend(ws, { type: 'error', message: '缺少 sessionId 或 projectDir' });
|
||||
}
|
||||
const filePath = path.join(CLAUDE_PROJECTS_DIR, String(projectDir), `${sanitizeId(sessionId)}.jsonl`);
|
||||
if (!filePath.startsWith(CLAUDE_PROJECTS_DIR)) {
|
||||
return wsSend(ws, { type: 'error', message: '非法路径' });
|
||||
}
|
||||
let content;
|
||||
try { content = fs.readFileSync(filePath, 'utf8'); } catch {
|
||||
return wsSend(ws, { type: 'error', message: '无法读取会话文件' });
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const messages = parseJsonlToMessages(lines);
|
||||
|
||||
// Find or create cc-web session with this claudeSessionId
|
||||
let existingSession = null;
|
||||
try {
|
||||
for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
|
||||
if (s.claudeSessionId === sessionId) { existingSession = s; break; }
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Determine title and cwd from messages/raw
|
||||
let title = sessionId.slice(0, 20);
|
||||
let cwd = null;
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try {
|
||||
const e = JSON.parse(t);
|
||||
if (e.type === 'user') {
|
||||
if (!cwd) cwd = e.cwd || null;
|
||||
const raw = e.message?.content;
|
||||
let text = '';
|
||||
if (typeof raw === 'string') text = raw;
|
||||
else if (Array.isArray(raw)) text = raw.filter(b => b.type === 'text').map(b => b.text || '').join('');
|
||||
if (text.trim()) { title = text.trim().slice(0, 60).replace(/\n/g, ' '); break; }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const id = existingSession ? existingSession.id : crypto.randomUUID();
|
||||
const session = {
|
||||
id,
|
||||
title,
|
||||
created: existingSession?.created || new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
claudeSessionId: sessionId,
|
||||
importedFrom: projectDir,
|
||||
model: existingSession?.model || null,
|
||||
permissionMode: existingSession?.permissionMode || 'yolo',
|
||||
totalCost: existingSession?.totalCost || 0,
|
||||
messages,
|
||||
cwd: cwd || existingSession?.cwd || null,
|
||||
};
|
||||
saveSession(session);
|
||||
wsSessionMap.set(ws, id);
|
||||
wsSend(ws, { type: 'session_info', sessionId: id, messages: session.messages, title: session.title, mode: session.permissionMode, model: modelShortName(session.model), cwd: session.cwd });
|
||||
sendSessionList(ws);
|
||||
}
|
||||
|
||||
function handleListCwdSuggestions(ws) {
|
||||
const paths = new Set();
|
||||
// Always include HOME
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
if (home) paths.add(home);
|
||||
wsSend(ws, { type: 'cwd_suggestions', paths: Array.from(paths).sort() });
|
||||
}
|
||||
|
||||
// === Startup ===
|
||||
recoverProcesses();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user