322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
"use strict";
|
||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||
};
|
||
Object.defineProperty(exports, "__esModule", { value: true });
|
||
exports.ThreadManager = void 0;
|
||
const database_1 = require("../database");
|
||
const git_integration_1 = require("../git/git-integration");
|
||
const embedding_service_1 = require("./embedding-service");
|
||
const uuid_1 = require("uuid");
|
||
const date_fns_1 = require("date-fns");
|
||
const locale_1 = require("date-fns/locale");
|
||
const path_1 = __importDefault(require("path"));
|
||
const fs_extra_1 = __importDefault(require("fs-extra"));
|
||
class ThreadManager {
|
||
dbManager;
|
||
threadsDAO;
|
||
messagesDAO;
|
||
fileChangesDAO;
|
||
gitIntegration;
|
||
embeddingService;
|
||
claudeContextPath = path_1.default.join(process.cwd(), '.claude', '.threads', 'current-context.md');
|
||
constructor(dbManager) {
|
||
this.dbManager = dbManager;
|
||
this.embeddingService = new embedding_service_1.XenovaEmbeddingService();
|
||
this.threadsDAO = new database_1.ThreadsDAO(dbManager);
|
||
this.messagesDAO = new database_1.MessagesDAO(dbManager, this.embeddingService);
|
||
this.fileChangesDAO = new database_1.FileChangesDAO(dbManager);
|
||
this.gitIntegration = new git_integration_1.GitIntegration();
|
||
fs_extra_1.default.ensureDirSync(path_1.default.dirname(this.claudeContextPath)); // Ensure directory exists on startup
|
||
}
|
||
get messagesDAOInstance() {
|
||
return this.messagesDAO;
|
||
}
|
||
async updateClaudeMd(thread, messages) {
|
||
// 格式化历史消息
|
||
const historyText = messages.map(msg => {
|
||
const time = new Date(msg.timestamp).toLocaleString('zh-CN');
|
||
const roleText = msg.role === 'user' ? '用户' : '助手';
|
||
return `### [${time}] ${roleText}\n\n${msg.content}\n`;
|
||
}).join('\n---\n\n');
|
||
const content = `# 📋 当前线程上下文\n\n**⚠️ 重要:上下文隔离规则**\n\n您当前在独立的对话线程中工作。请**严格遵守**以下规则:\n\n1. **只参考本文档中的历史对话**\n2. **忽略本线程之外的所有内容**\n3. **不要引用或提及其他线程的信息**\n\n---\n\n## 线程信息\n\n- 📋 标题:${thread.title}\n- 📝 描述:${thread.description || '无'}\n- 🆔 ID:${thread.id}\n- 🌿 Git 分支:${thread.gitBranch || '无'}\n- 🏷️ 标签:${thread.metadata.tags?.join(', ') || '无'}\n- 📊 消息数:${thread.messageCount}\n\n---\n\n## 历史对话\n\n${historyText || '暂无历史对话'}\n\n---\n\n**再次强调:请只参考上述对话内容进行回复,忽略本线程之外的所有历史记录。**\n`;
|
||
// 写入文件
|
||
await fs_extra_1.default.writeFile(this.claudeContextPath, content, 'utf-8');
|
||
}
|
||
async createThread(input) {
|
||
const { title, description, tags, switchTo = true } = input;
|
||
// 1. 生成 UUID
|
||
const threadId = (0, uuid_1.v4)();
|
||
// 2. 准备 Git 分支名(但不立即创建,延迟到切换时)
|
||
let gitBranch;
|
||
if (await this.gitIntegration.isGitRepo()) {
|
||
gitBranch = `thread/${threadId.substring(0, 8)}`;
|
||
// 注意:分支将在首次切换到此 thread 时创建,避免阻塞
|
||
}
|
||
// 3. 创建 Thread 记录
|
||
// 先创建为非激活状态,如果是 switchTo=true,后面会统一调用 setActive 处理互斥
|
||
let newThread = this.threadsDAO.create({
|
||
id: threadId,
|
||
sessionId: threadId, // 相同
|
||
title,
|
||
description,
|
||
gitBranch,
|
||
isActive: false,
|
||
metadata: {
|
||
filesChanged: 0,
|
||
linesAdded: 0,
|
||
linesDeleted: 0,
|
||
tags: tags || []
|
||
}
|
||
});
|
||
// 4. 处理切换逻辑 (默认为 true)
|
||
if (switchTo) {
|
||
this.threadsDAO.setActive(threadId);
|
||
newThread.isActive = true; // 更新内存中的对象状态
|
||
// 更新 CLAUDE.md 上下文
|
||
await this.updateClaudeMd(newThread, []);
|
||
}
|
||
// 5. 生成启动命令
|
||
const launchCommand = `claude --session-id ${threadId}`;
|
||
// 6. 返回结果(快速返回,不阻塞)
|
||
return {
|
||
thread: newThread,
|
||
message: this.formatCreateMessage(newThread),
|
||
launchCommand
|
||
};
|
||
}
|
||
formatCreateMessage(thread) {
|
||
const shortId = thread.id.substring(0, 8);
|
||
const gitBranchInfo = thread.gitBranch ? `- **Git 分支**: \`${thread.gitBranch}\`` : '';
|
||
return `### ✨ 新线程已创建
|
||
|
||
- **标题**: ${thread.title}
|
||
- **ID**: \`${shortId}\`
|
||
${gitBranchInfo}
|
||
|
||
**🚀 启动独立会话**:
|
||
\`claude --session-id ${thread.id}\`
|
||
或: \`clt ${shortId}\`
|
||
`;
|
||
}
|
||
formatCombinedSwitchMessage(thread, messages) {
|
||
const shortId = thread.id.substring(0, 8);
|
||
// Filter out commands and format context messages
|
||
const contextMessages = messages.filter(msg => !msg.content.trim().startsWith('/'));
|
||
const displayLimit = 5; // Reduced limit
|
||
const recentMessages = contextMessages
|
||
.slice(0, displayLimit)
|
||
.reverse()
|
||
.map(msg => {
|
||
const time = (0, date_fns_1.formatDistanceToNow)(msg.timestamp, { locale: locale_1.zhCN });
|
||
const preview = msg.content.substring(0, 60).replace(/\n/g, ' ');
|
||
const icon = msg.role === 'user' ? '👤' : '🤖';
|
||
return `- ${time} ${icon} ${preview}${msg.content.length > 60 ? '...' : ''}`;
|
||
})
|
||
.join('\n');
|
||
const contextSection = contextMessages.length > 0
|
||
? `**💬 最近消息**:\n${recentMessages}\n`
|
||
: '';
|
||
const gitBranchInfo = thread.gitBranch ? `- **Git**: \`${thread.gitBranch}\`` : '';
|
||
return `### 🔄 已切换到线程
|
||
|
||
- **标题**: ${thread.title}
|
||
- **ID**: \`${shortId}\`
|
||
${gitBranchInfo}
|
||
- **统计**: ${messages.length} 消息 | ${thread.metadata.filesChanged} 文件变更
|
||
|
||
${contextSection}
|
||
**⚠️ 完全隔离上下文**:
|
||
推荐重启: \`exit\` 后运行 \`claude --session-id ${thread.id}\`
|
||
`;
|
||
}
|
||
async getThread(id, includeMessages = false, includeFileChanges = false, messageLimit = 50) {
|
||
const thread = this.threadsDAO.findById(id);
|
||
if (!thread) {
|
||
return { thread: null };
|
||
}
|
||
let messages;
|
||
if (includeMessages) {
|
||
messages = this.messagesDAO.findByThreadId(id, { limit: messageLimit });
|
||
}
|
||
let fileChanges;
|
||
if (includeFileChanges) {
|
||
fileChanges = this.fileChangesDAO.findByThreadId(id);
|
||
}
|
||
return { thread, messages, fileChanges };
|
||
}
|
||
async listThreads(input) {
|
||
const { threads, total } = this.threadsDAO.findAll(input);
|
||
const currentActive = this.threadsDAO.getActive();
|
||
// Format the message for display
|
||
const message = this.formatListThreadsMessage(threads, total, currentActive?.id);
|
||
return {
|
||
threads,
|
||
total,
|
||
currentThreadId: currentActive?.id,
|
||
message
|
||
};
|
||
}
|
||
formatListThreadsMessage(threads, total, currentThreadId) {
|
||
if (threads.length === 0) {
|
||
return `📋 **线程列表**: 暂无线程。使用 \`/thread create <title>\` 创建。`;
|
||
}
|
||
const rows = threads.map(thread => {
|
||
const isActive = thread.id === currentThreadId;
|
||
const status = isActive ? '✅' : ' ';
|
||
const shortId = thread.id.substring(0, 8);
|
||
const title = thread.title.length > 40 ? thread.title.substring(0, 37) + '...' : thread.title;
|
||
const timeAgo = (0, date_fns_1.formatDistanceToNow)(thread.updatedAt, { locale: locale_1.zhCN, addSuffix: true });
|
||
// Compact format: Status | ID | Title (Msgs, Files) | Time
|
||
return `\`${status} ${shortId}\` **${title}** (${thread.messageCount} msg, ${thread.metadata.filesChanged} files) - ${timeAgo}`;
|
||
}).join('\n');
|
||
return `### 📋 线程列表 (总计: ${total})
|
||
|
||
${rows}
|
||
`;
|
||
}
|
||
findThreadsByPrefix(prefix) {
|
||
return this.threadsDAO.findByPrefix(prefix);
|
||
}
|
||
async updateThread(id, updates) {
|
||
const existingThread = this.threadsDAO.findById(id);
|
||
if (!existingThread) {
|
||
return { success: false, message: `Thread with ID ${id} not found.` };
|
||
}
|
||
// Map UpdateThreadInput to Partial<Thread>
|
||
const threadUpdates = {
|
||
title: updates.title,
|
||
description: updates.description,
|
||
gitBranch: updates.gitBranch,
|
||
};
|
||
if (updates.tags) {
|
||
threadUpdates.metadata = {
|
||
...existingThread.metadata,
|
||
tags: updates.tags
|
||
};
|
||
}
|
||
const updatedThread = this.threadsDAO.update(id, threadUpdates);
|
||
if (!updatedThread) {
|
||
return { success: false, message: `Failed to update thread with ID ${id}.` };
|
||
}
|
||
return { success: true, thread: updatedThread, message: `Thread "${updatedThread.title}" (${updatedThread.id}) updated successfully.` };
|
||
}
|
||
async deleteThread(id) {
|
||
// Check if it's the active thread
|
||
const currentActive = this.threadsDAO.getActive();
|
||
if (currentActive?.id === id) {
|
||
return { success: false, message: "Cannot delete the currently active thread. Please switch to another thread first." };
|
||
}
|
||
const deleted = this.threadsDAO.delete(id);
|
||
if (!deleted) {
|
||
return { success: false, message: `Thread with ID ${id} not found or failed to delete.` };
|
||
}
|
||
// Due to ON DELETE CASCADE in SQLite schema, messages and file_changes are automatically deleted.
|
||
return { success: true, message: `Thread with ID ${id} deleted successfully.` };
|
||
}
|
||
async switchThread(id, options) {
|
||
// 1. 查找目标 thread
|
||
const targetThread = this.threadsDAO.findById(id);
|
||
if (!targetThread) {
|
||
return {
|
||
success: false,
|
||
message: `Thread with ID ${id} not found.`
|
||
};
|
||
}
|
||
// 2. 切换 Git 分支(如果需要,先创建)
|
||
if (targetThread.gitBranch) {
|
||
// 检查分支是否存在
|
||
const branchExists = await this.gitIntegration.branchExists(targetThread.gitBranch);
|
||
if (!branchExists) {
|
||
// 分支不存在,创建它(延迟创建策略)
|
||
const created = await this.gitIntegration.createAndCheckoutBranch(targetThread.gitBranch);
|
||
if (!created) {
|
||
return {
|
||
success: false,
|
||
message: `Failed to create Git branch ${targetThread.gitBranch}`
|
||
};
|
||
}
|
||
}
|
||
else {
|
||
// 分支存在,直接切换
|
||
const switched = await this.gitIntegration.checkoutBranch(targetThread.gitBranch);
|
||
if (!switched) {
|
||
return {
|
||
success: false,
|
||
message: `Failed to switch to Git branch ${targetThread.gitBranch}`
|
||
};
|
||
}
|
||
}
|
||
}
|
||
// Set target thread as active
|
||
this.threadsDAO.setActive(id);
|
||
targetThread.isActive = true; // Update in-memory object
|
||
// 3. 加载历史消息 (for CLAUDE.md and display)
|
||
const messages = this.messagesDAO.findByThreadId(id, { limit: 50 });
|
||
// 4. 更新 CLAUDE.md(注入上下文提示)
|
||
await this.updateClaudeMd(targetThread, messages);
|
||
// 5. 生成切换命令
|
||
const launchCommand = `claude --session-id ${targetThread.id}`;
|
||
// 6. 生成组合消息
|
||
const combinedMessage = this.formatCombinedSwitchMessage(targetThread, messages);
|
||
return {
|
||
success: true,
|
||
thread: targetThread,
|
||
messages, // 返回原始消息数组,供 Claude CLI 可能的进一步处理
|
||
message: combinedMessage,
|
||
launchCommand
|
||
};
|
||
}
|
||
async getCurrentThread(includeMessages = false, includeFileChanges = false, messageLimit = 50) {
|
||
const activeThread = this.threadsDAO.getActive();
|
||
if (!activeThread) {
|
||
return { message: "No active thread found." };
|
||
}
|
||
const { thread, messages, fileChanges } = await this.getThread(activeThread.id, includeMessages, includeFileChanges, messageLimit);
|
||
if (!thread) {
|
||
return { message: "Active thread not found in database (unexpected)." }; // Should not happen if getActive returns one
|
||
}
|
||
return { thread, messages, fileChanges };
|
||
}
|
||
async addMessageToThread(threadId, role, content, metadata) {
|
||
// Ensure thread exists and is active (or specified threadId is the active one)
|
||
const activeThread = this.threadsDAO.getActive();
|
||
if (!activeThread || activeThread.id !== threadId) {
|
||
throw new Error(`Thread with ID ${threadId} is not the active thread or does not exist. Cannot add message.`);
|
||
}
|
||
const message = await this.messagesDAO.create({ threadId, role, content, metadata });
|
||
return message;
|
||
}
|
||
async trackFileChange(threadId, filePath, changeType, linesAdded, linesDeleted, gitCommit) {
|
||
// Ensure thread exists and is active
|
||
const activeThread = this.threadsDAO.getActive();
|
||
if (!activeThread || activeThread.id !== threadId) {
|
||
throw new Error(`Thread with ID ${threadId} is not the active thread or does not exist. Cannot track file change.`);
|
||
}
|
||
// Auto-detect stats if not provided
|
||
if (linesAdded === undefined || linesDeleted === undefined || changeType === undefined) {
|
||
const stats = await this.gitIntegration.getFileStats(filePath);
|
||
if (linesAdded === undefined)
|
||
linesAdded = stats.added;
|
||
if (linesDeleted === undefined)
|
||
linesDeleted = stats.deleted;
|
||
if (changeType === undefined)
|
||
changeType = stats.changeType;
|
||
}
|
||
// Auto-detect commit hash if not provided
|
||
if (!gitCommit) {
|
||
gitCommit = await this.gitIntegration.getCurrentCommit();
|
||
}
|
||
const fileChange = this.fileChangesDAO.create({
|
||
threadId,
|
||
filePath,
|
||
changeType: changeType,
|
||
linesAdded,
|
||
linesDeleted,
|
||
gitCommit
|
||
});
|
||
return fileChange;
|
||
}
|
||
}
|
||
exports.ThreadManager = ThreadManager;
|