diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b1dff0dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..10b731c5 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..1bec35e5 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..79ee123c --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 00000000..05eca020 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..5cd9a108 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..ddb56f49 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# IDEA版 Claude Code GUI 插件 + +本项目主要解决在IDEA中使用Claude Code 没有 GUI操作窗口的场景 + +目前在实验阶段,成品尚未完成,代码会按天更新进度 + +### 目前进度 + +2025年11月19日 实现历史记录读取功能 + +安装包:[idea-claude-code-gui-0.0.1.zip](https://claudecodecn-1253302184.cos.ap-beijing.myqcloud.com/idea/v0.0.1/idea-claude-code-gui-0.0.1.zip) + +Image + +2025年11月20日 攻关JAVA 与 @anthropic-ai/claude-agent-sdk 交互问题 + + + +### 构建插件 + +```sh + ./gradlew build + +# 生成的插件包会在 build/distributions/ 目录下 +``` + +### 开发环境 + +``` +IntelliJ IDEA 2025.2.4 (Ultimate Edition) +Build #IU-252.27397.103, built on October 23, 2025 +Source revision: 9b31ba2c05b47 +Runtime version: 21.0.8+9-b1038.73 aarch64 (JCEF 122.1.9) +VM: OpenJDK 64-Bit Server VM by JetBrains s.r.o. +Toolkit: sun.lwawt.macosx.LWCToolkit +macOS 15.3.1 +GC: G1 Young Generation, G1 Concurrent GC, G1 Old Generation +Memory: 2048M +Cores: 12 +Metal Rendering is ON +Registry: + ide.experimental.ui=true + llm.selector.config.refresh.interval=10 + llm.rules.refresh.interval=10 +Non-Bundled Plugins: + com.luomacode.ChatMoss (7.1.2) + com.anthropic.code.plugin (0.1.12-beta) + com.intellij.ml.llm (252.27397.144) + com.example.claudeagent (1.0-SNAPSHOT) +Kotlin: 252.27397.103-IJ +``` diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..65d2e169 --- /dev/null +++ b/build.gradle @@ -0,0 +1,58 @@ +plugins { + id 'java' + id 'org.jetbrains.intellij' version '1.17.0' +} + +group 'com.github.idea-claude-code-gui' +version '0.0.1' + +sourceCompatibility = 17 +targetCompatibility = 17 + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.jetbrains:annotations:24.0.0' + implementation 'com.google.code.gson:gson:2.10.1' +} + +// 配置 IntelliJ Platform 插件 +intellij { + version = '2023.3.2' // 目标 IDEA 版本 + type = 'IC' // IC = IntelliJ IDEA Community Edition + + // 下载源码和 JavaDoc + downloadSources = true + + // 插件依赖(如果需要) + plugins = [] +} + +tasks.withType(JavaCompile) { + options.encoding = 'UTF-8' +} + +patchPluginXml { + sinceBuild = '233' + untilBuild = '252.*' // 更新为支持2024.2版本 + + changeNotes = """ + + """ +} + +runIde { + // 启用 JCEF 支持 + jvmArgs '-Djcef.sandbox.enable=false' +} + +buildSearchableOptions { + enabled = false +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..756cb646 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") version "2.2.20" +} + +group = "org.example" +version = "0.0.1" + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(21) +} \ No newline at end of file diff --git a/docs/CLAUDE_HISTORY_READER_PRINCIPLE.md b/docs/CLAUDE_HISTORY_READER_PRINCIPLE.md new file mode 100644 index 00000000..1a1add6f --- /dev/null +++ b/docs/CLAUDE_HISTORY_READER_PRINCIPLE.md @@ -0,0 +1,533 @@ +# Claude Code 本地历史消息记录读取原理 + +--- + +## 概述 + +Claude Code 是 Cursor 编辑器内置的 AI 助手,它会将所有的对话历史记录以 **JSONL 格式**(JSON Lines)存储在本地文件系统中。通过直接读取这些文件,我们可以实现历史记录的查看、搜索和统计功能。 + +### 核心特点 + +- **本地存储**:所有数据存储在用户主目录的 `.claude` 文件夹 +- **JSONL 格式**:每行一个 JSON 对象,易于追加和解析 +- **项目隔离**:每个项目的会话独立存储 +- **无需 API**:直接读取文件系统,无需网络请求 + +--- + +## 数据存储位置 + +### 1. 主目录结构 + +``` +~/.claude/ +├── history.jsonl # 全局历史记录索引(已废弃,主要用旧版) +└── projects/ # 项目会话目录 + ├── {sanitized-path-1}/ # 项目1的目录(路径被转义) + │ ├── {session-id-1}.jsonl # 会话1 + │ ├── {session-id-2}.jsonl # 会话2 + │ └── ... + ├── {sanitized-path-2}/ # 项目2的目录 + │ └── ... + └── ... +``` + +### 2. 路径转义规则 + +项目路径会被转义为文件系统安全的名称: + +```java +// 将所有非字母数字字符替换为 - +String sanitizedPath = projectPath.replaceAll("[^a-zA-Z0-9]", "-"); +``` + +**示例:** +``` +原始路径: /Users/john/Desktop/my-project +转义后: -Users-john-Desktop-my-project +``` + +--- + +## 数据结构分析 + +### 1. history.jsonl(历史索引文件) + +每行是一个 JSON 对象,记录了单条历史消息: + +```json +{ + "display": "用户的消息内容", + "pastedContents": {}, + "timestamp": 1700000000000, + "project": "/path/to/project", + "sessionId": "session-uuid-xxxx" +} +``` + +**字段说明:** +- `display`: 显示的消息内容 +- `pastedContents`: 粘贴的内容(如代码片段) +- `timestamp`: Unix 时间戳(毫秒) +- `project`: 项目路径 +- `sessionId`: 会话ID + +### 2. 会话文件 (.jsonl) + +每个会话文件包含该会话的所有消息,每行一个消息对象: + +```json +{ + "uuid": "msg-uuid-xxxx", + "sessionId": "session-uuid-xxxx", + "parentUuid": "parent-msg-uuid", + "timestamp": "2025-11-18T20:16:42.310Z", + "type": "user", + "message": { + "role": "user", + "content": "这是用户的消息" + }, + "isMeta": false, + "isSidechain": false, + "cwd": "/path/to/project" +} +``` + +**字段说明:** +- `uuid`: 消息唯一标识 +- `sessionId`: 所属会话ID +- `parentUuid`: 父消息ID(用于构建对话树) +- `timestamp`: ISO 8601 格式时间戳 +- `type`: 消息类型(`user`、`assistant`) +- `message.content`: 消息内容(可能是字符串或数组) +- `isMeta`: 是否为元消息(系统消息) +- `isSidechain`: 是否为侧链消息 + +--- + +## 读取原理 + +### 核心流程图 + +``` +┌─────────────────┐ +│ 获取项目路径 │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 路径转义处理 │ projectPath.replaceAll("[^a-zA-Z0-9]", "-") +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 定位项目目录 │ ~/.claude/projects/{sanitized-path}/ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 遍历.jsonl文件 │ 读取目录下所有 *.jsonl 文件 +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 解析JSONL格式 │ 逐行读取,每行解析为JSON对象 +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 提取会话信息 │ 生成会话摘要、统计消息数 +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 过滤无效会话 │ 排除 Warmup、agent-xxx 等 +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 按时间排序 │ 最新的会话在前 +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 返回结果数据 │ JSON格式返回给前端 +└─────────────────┘ +``` + +--- + +## 技术实现细节 + +### 1. JSONL 文件读取 + +```java +// 使用 BufferedReader 逐行读取 +try (BufferedReader reader = Files.newBufferedReader(path)) { + String line; + while ((line = reader.readLine()) != null) { + if (line.trim().isEmpty()) continue; + + try { + // 使用 Gson 解析每一行 + ConversationMessage msg = gson.fromJson(line, ConversationMessage.class); + if (msg != null) { + messages.add(msg); + } + } catch (Exception e) { + // 跳过解析失败的行 + } + } +} +``` + +**关键点:** +- JSONL 格式每行是独立的 JSON,便于流式处理 +- 使用 Gson 库进行 JSON 解析 +- 错误容忍:单行解析失败不影响整体 + +### 2. 会话摘要生成 + +从会话消息中提取第一条用户消息作为摘要: + +```java +private String generateSummary(List messages) { + for (ConversationMessage msg : messages) { + if ("user".equals(msg.type) && + (msg.isMeta == null || !msg.isMeta) && + msg.message != null && + msg.message.content != null) { + + String text = extractTextFromContent(msg.message.content); + if (text != null && !text.isEmpty()) { + // 去除换行符并截断 + text = text.replace("\n", " ").trim(); + if (text.length() > 45) { + text = text.substring(0, 45) + "..."; + } + return text; + } + } + } + return null; +} +``` + +**策略:** +- 查找第一条非 meta 的用户消息 +- 提取文本内容(content 可能是字符串或数组) +- 截断到 45 字符,添加省略号 + +### 3. 时间戳处理 + +支持多种时间戳格式: + +```java +private long parseTimestamp(String timestamp) { + try { + // ISO 8601 格式: "2025-11-18T20:16:42.310Z" + java.time.Instant instant = java.time.Instant.parse(timestamp); + return instant.toEpochMilli(); + } catch (Exception e) { + return 0; + } +} +``` + +--- + +## 会话过滤机制 + +为了提供更好的用户体验,需要过滤掉无效会话: + +### 过滤规则 + +```java +private boolean isValidSession(String sessionId, String summary, int messageCount) { + // 1. 过滤 agent-xxx 格式的会话(都是 Warmup) + if (sessionId != null && sessionId.startsWith("agent-")) { + return false; + } + + // 2. 过滤摘要为空的会话 + if (summary == null || summary.isEmpty()) { + return false; + } + + // 3. 过滤 "Warmup" 或 "No prompt" 会话 + String lowerSummary = summary.toLowerCase(); + if (lowerSummary.equals("warmup") || + lowerSummary.equals("no prompt") || + lowerSummary.startsWith("warmup") || + lowerSummary.startsWith("no prompt")) { + return false; + } + + // 4. 过滤消息数太少的会话(少于2条) + if (messageCount < 2) { + return false; + } + + return true; +} +``` + +### 过滤原因 + +| 类型 | 原因 | 示例 | +|------|------|------| +| `agent-xxx` | 系统内部会话 | `agent-warmup-12345` | +| 空摘要 | 无实际内容 | 只有系统消息 | +| "Warmup" | 预热会话 | 系统启动时的测试 | +| "No prompt" | 空提示 | 用户未输入内容 | +| 消息数 < 2 | 不完整对话 | 只有一条消息 | + +--- + +## 实际应用场景 + +### 1. IntelliJ IDEA 插件集成 + +**实现类:** `VueHelloToolWindowFactorySimple.java` + +```java +public VueHelloToolWindow(String projectPath) { + this.projectPath = projectPath; + this.historyReader = new ClaudeHistoryReader(); + + // 获取当前项目的历史数据 + String jsonData = historyReader.getProjectDataAsJson(projectPath); + + // 使用 JCEF 浏览器组件渲染 HTML + JBCefBrowser browser = new JBCefBrowser(); + String htmlContent = generateHtmlWithData(jsonData); + browser.loadHTML(htmlContent); +} +``` + +**功能:** +- 在 IDE 侧边栏显示当前项目的 Claude 历史 +- 实时加载,无需刷新 +- Vue.js 渲染,交互流畅 + +### 2. Web 端历史查看器 + +**文件:** `claude-real-history.html` + +**特点:** +- 完整的 Web UI,使用 Vue 3 +- 支持搜索、过滤、导出 +- 需要后端 API(Node.js 服务) + +### 3. 命令行工具 + +```java +public static void main(String[] args) { + ClaudeHistoryReader reader = new ClaudeHistoryReader(); + + // 读取历史 + List history = reader.readHistory(); + System.out.println("历史记录条数: " + history.size()); + + // 获取项目列表 + List projects = reader.getProjects(history); + System.out.println("项目数: " + projects.size()); + + // 输出 JSON + System.out.println(reader.getAllDataAsJson()); +} +``` + +--- + +## 注意事项 + +### ⚠️ 安全性 + +1. **隐私保护**:历史记录可能包含敏感信息(代码、密钥等) +2. **权限控制**:确保只有授权用户能访问 +3. **数据加密**:考虑对敏感数据进行加密存储 + +### ⚠️ 兼容性 + +1. **路径差异**: + - macOS/Linux: `~/.claude/` + - Windows: `%USERPROFILE%\.claude\` + +2. **格式变化**:Claude Code 可能更新数据格式 + - 使用错误容忍的解析方式 + - 版本检测机制 + +3. **文件锁定**: + - Claude Code 可能正在写入文件 + - 使用只读模式打开 + - 实现重试机制 + +### ⚠️ 性能优化 + +1. **大文件处理**: + ```java + // 限制返回的消息数量 + history.size() > 200 ? history.subList(0, 200) : history + ``` + +2. **异步读取**: + ```java + // 使用 Java 8 Stream API 并行处理 + Files.list(projectDir) + .parallel() + .filter(path -> path.toString().endsWith(".jsonl")) + .forEach(path -> processFile(path)); + ``` + +3. **缓存机制**: + - 缓存已读取的数据 + - 监听文件变化,增量更新 + +--- + +## 数据流示意图 + +``` +┌──────────────────────────────────────────────────────────┐ +│ Cursor 编辑器 │ +│ │ +│ ┌─────────────┐ │ +│ │ Claude AI │ 写入历史记录 │ +│ │ Assistant │────────┐ │ +│ └─────────────┘ │ │ +└─────────────────────────┼─────────────────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ ~/.claude/projects/ │ + │ {project}/ │ + │ ├── session1.jsonl │ + │ ├── session2.jsonl │ + │ └── ... │ + └───────────────────────┘ + │ + │ 读取 + │ + ▼ + ┌───────────────────────┐ + │ ClaudeHistoryReader │ + │ Java 读取器 │ + │ - 解析 JSONL │ + │ - 过滤会话 │ + │ - 生成摘要 │ + └───────────────────────┘ + │ + │ JSON API + │ + ┌───────────┴───────────┐ + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ + │ IDEA 插件 │ │ Web 界面 │ + │ (JCEF Browser) │ │ (Vue.js) │ + │ - 嵌入式展示 │ │ - 全功能查看器 │ + └──────────────────┘ └──────────────────┘ +``` + +--- + +## 示例代码 + +### 完整读取流程 + +```java +// 1. 创建读取器 +ClaudeHistoryReader reader = new ClaudeHistoryReader(); + +// 2. 读取指定项目的会话列表 +String projectPath = "/Users/john/Desktop/my-project"; +List sessions = reader.readProjectSessions(projectPath); + +// 3. 输出会话信息 +for (SessionInfo session : sessions) { + System.out.println("会话ID: " + session.sessionId); + System.out.println("标题: " + session.title); + System.out.println("消息数: " + session.messageCount); + System.out.println("时间: " + new Date(session.lastTimestamp)); + System.out.println("---"); +} + +// 4. 获取 JSON 格式数据(用于前端显示) +String jsonData = reader.getProjectDataAsJson(projectPath); +System.out.println(jsonData); +``` + +### 输出示例 + +```json +{ + "success": true, + "sessions": [ + { + "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "实现用户登录功能", + "messageCount": 15, + "lastTimestamp": 1700000000000, + "firstTimestamp": 1699999000000 + }, + { + "sessionId": "b2c3d4e5-f6g7-8901-bcde-f12345678901", + "title": "修复数据库连接问题", + "messageCount": 8, + "lastTimestamp": 1699998000000, + "firstTimestamp": 1699997000000 + } + ], + "currentProject": "/Users/john/Desktop/my-project", + "total": 23, + "sessionCount": 2 +} +``` + +--- + +## 技术栈总结 + +| 组件 | 技术 | 用途 | +|------|------|------| +| 数据读取 | Java NIO | 文件系统操作 | +| JSON 解析 | Gson | JSON 序列化/反序列化 | +| UI 渲染 | JCEF (Chromium) | 嵌入式浏览器 | +| 前端框架 | Vue.js 3 | 响应式 UI | +| HTTP 客户端 | Axios | API 请求(Web版) | +| 数据格式 | JSONL | 行式 JSON 存储 | + +--- + +## 扩展功能建议 + +### 🚀 可实现的功能 + +1. **全文搜索**:基于 Apache Lucene 实现 +2. **数据统计**:消息数量、使用频率、时间分布 +3. **导出功能**:导出为 Markdown、PDF +4. **会话恢复**:点击历史会话,在 Cursor 中恢复 +5. **智能分类**:基于内容自动分类(Bug修复、功能开发等) +6. **数据同步**:跨设备同步历史记录 + +--- + +## 参考资料 + +- [JSONL 格式规范](http://jsonlines.org/) +- [Gson 用户指南](https://github.com/google/gson/blob/master/UserGuide.md) +- [Java NIO 文件操作](https://docs.oracle.com/javase/tutorial/essential/io/fileio.html) +- [IntelliJ Platform SDK](https://plugins.jetbrains.com/docs/intellij/welcome.html) + +--- + +## 版本历史 + +| 版本 | 日期 | 变更说明 | +|------|------|----------| +| 1.0.0 | 2025-11-19 | 初始版本 | + +--- + +**最后更新:** 2025年11月19日 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..7fc6f1ff --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..249e5832 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..8c25685d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Nov 19 01:08:29 CST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..2721d753 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'idea-claude-code-gui' \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..6394719c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} +rootProject.name = "idea-claude-code-gui" \ No newline at end of file diff --git a/src/main/java/com/github/claudecodegui/CCGuiToolWindowFactory.java b/src/main/java/com/github/claudecodegui/CCGuiToolWindowFactory.java new file mode 100644 index 00000000..a8098a4e --- /dev/null +++ b/src/main/java/com/github/claudecodegui/CCGuiToolWindowFactory.java @@ -0,0 +1,259 @@ +package com.github.claudecodegui; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import com.intellij.ui.jcef.JBCefBrowser; +import com.intellij.ui.jcef.JBCefJSQuery; +import org.cef.browser.CefBrowser; +import org.cef.browser.CefFrame; +import org.cef.handler.CefLoadHandlerAdapter; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.awt.*; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * CC-GUI 工具窗口工厂类(完整版,包含 JavaScript 桥接) + */ +public class CCGuiToolWindowFactory implements ToolWindowFactory { + + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + // 创建工具窗口内容 + CCGuiToolWindow ccGuiToolWindow = new CCGuiToolWindow(); + ContentFactory contentFactory = ContentFactory.getInstance(); + Content content = contentFactory.createContent( + ccGuiToolWindow.getContent(), + "CC-GUI", + false + ); + toolWindow.getContentManager().addContent(content); + } + + /** + * CC-GUI 工具窗口内容类 + */ + private static class CCGuiToolWindow { + private JPanel mainPanel; + + public CCGuiToolWindow() { + createUIComponents(); + } + + private void createUIComponents() { + mainPanel = new JPanel(new BorderLayout()); + + try { + // 使用 JCEF (Java Chromium Embedded Framework) 显示 HTML 内容 + JBCefBrowser browser = new JBCefBrowser(); + + // 读取 HTML 文件内容 + String htmlContent = loadHtmlContent(); + + // 加载 HTML 内容 + browser.loadHTML(htmlContent); + + // 添加浏览器组件到面板 + mainPanel.add(browser.getComponent(), BorderLayout.CENTER); + + // 可选:添加 Java-JavaScript 交互桥接 + setupJavaScriptBridge(browser); + + } catch (Exception e) { + // 如果 JCEF 不可用,显示备用内容 + JLabel label = new JLabel("CC-GUI - Claude Code GUI", SwingConstants.CENTER); + label.setFont(new Font("Microsoft YaHei", Font.BOLD, 24)); + mainPanel.add(label, BorderLayout.CENTER); + + // 显示错误信息 + JTextArea errorArea = new JTextArea("注意:JCEF 组件未能加载\n" + e.getMessage()); + errorArea.setEditable(false); + JScrollPane scrollPane = new JScrollPane(errorArea); + scrollPane.setPreferredSize(new Dimension(400, 100)); + mainPanel.add(scrollPane, BorderLayout.SOUTH); + } + } + + /** + * 加载 HTML 内容 + */ + private String loadHtmlContent() { + try { + // 从资源文件中读取 HTML + InputStream inputStream = getClass().getClassLoader() + .getResourceAsStream("html/index.html"); + + if (inputStream != null) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + + // 如果无法加载文件,返回默认 HTML + return getDefaultHtml(); + } + + /** + * 获取默认的 HTML 内容 + */ + private String getDefaultHtml() { + return """ + + + + + + + + +
+

{{ message }}

+ +
+ + + + """; + } + + /** + * 设置 JavaScript 桥接 + * 允许 JavaScript 与 Java 代码交互 + */ + private void setupJavaScriptBridge(JBCefBrowser browser) { + // 创建 Claude 历史记录读取器 + ClaudeHistoryReader historyReader = new ClaudeHistoryReader(); + + // 创建 JS 查询对象 + JBCefJSQuery jsQuery = JBCefJSQuery.create(browser); + + // 注册回调处理器 + jsQuery.addHandler((request) -> { + try { + // 解析请求 + String[] parts = request.split("\\|", 2); + String endpoint = parts[0]; + Map params = new HashMap<>(); + + if (parts.length > 1) { + // 解析参数 + String[] paramPairs = parts[1].split("&"); + for (String pair : paramPairs) { + String[] keyValue = pair.split("=", 2); + if (keyValue.length == 2) { + params.put(keyValue[0], keyValue[1]); + } + } + } + + // 调用历史记录读取器处理请求 + String response = historyReader.handleApiRequest(endpoint, params); + return new JBCefJSQuery.Response(response); + } catch (Exception e) { + return new JBCefJSQuery.Response(null, 0, e.getMessage()); + } + }); + + // 页面加载完成后注入JavaScript代码 + browser.getJBCefClient().addLoadHandler(new CefLoadHandlerAdapter() { + @Override + public void onLoadEnd(CefBrowser cefBrowser, CefFrame frame, int httpStatusCode) { + // 注入全局 API 对象 + String jsCode = jsQuery.inject("request", + "function(response) {" + + " return response;" + + "}", + "function(error_code, error_msg) {" + + " console.error('Error:', error_code, error_msg);" + + " return null;" + + "}" + ); + + String injection = + "window.ClaudeAPI = {" + + " fetchHistory: function(callback) {" + + " " + jsCode + "('/history', function(response) {" + + " try { callback(JSON.parse(response)); } catch(e) { console.error(e); }" + + " });" + + " }," + + " fetchStats: function(callback) {" + + " " + jsCode + "('/stats', function(response) {" + + " try { callback(JSON.parse(response)); } catch(e) { console.error(e); }" + + " });" + + " }," + + " search: function(query, callback) {" + + " " + jsCode + "('/search|q=' + encodeURIComponent(query), function(response) {" + + " try { callback(JSON.parse(response)); } catch(e) { console.error(e); }" + + " });" + + " }," + + " fetchProject: function(path, callback) {" + + " " + jsCode + "('/project|path=' + encodeURIComponent(path), function(response) {" + + " try { callback(JSON.parse(response)); } catch(e) { console.error(e); }" + + " });" + + " }" + + "};" + + "console.log('ClaudeAPI injected successfully');"; + + cefBrowser.executeJavaScript(injection, "", 0); + } + }, browser.getCefBrowser()); + } + + public JPanel getContent() { + return mainPanel; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/github/claudecodegui/CCGuiToolWindowFactorySimple.java b/src/main/java/com/github/claudecodegui/CCGuiToolWindowFactorySimple.java new file mode 100644 index 00000000..7af9499d --- /dev/null +++ b/src/main/java/com/github/claudecodegui/CCGuiToolWindowFactorySimple.java @@ -0,0 +1,323 @@ +package com.github.claudecodegui; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import com.intellij.ui.jcef.JBCefBrowser; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.awt.*; + +/** + * CC-GUI 工具窗口工厂类(简化版) + */ +public class CCGuiToolWindowFactorySimple implements ToolWindowFactory { + + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + String projectPath = project.getBasePath(); + CCGuiToolWindow ccGuiToolWindow = new CCGuiToolWindow(projectPath); + ContentFactory contentFactory = ContentFactory.getInstance(); + Content content = contentFactory.createContent( + ccGuiToolWindow.getContent(), + "Claude History", + false + ); + toolWindow.getContentManager().addContent(content); + } + + private static class CCGuiToolWindow { + private JPanel mainPanel; + private ClaudeHistoryReader historyReader; + private String projectPath; + + public CCGuiToolWindow(String projectPath) { + this.projectPath = projectPath; + this.historyReader = new ClaudeHistoryReader(); + createUIComponents(); + } + + private void createUIComponents() { + mainPanel = new JPanel(new BorderLayout()); + + try { + JBCefBrowser browser = new JBCefBrowser(); + + // 获取当前项目的数据 + String jsonData = historyReader.getProjectDataAsJson(projectPath); + + // 生成HTML + String htmlContent = generateHtmlWithData(jsonData); + + // 加载HTML + browser.loadHTML(htmlContent); + + mainPanel.add(browser.getComponent(), BorderLayout.CENTER); + + } catch (Exception e) { + // 备用显示 + JTextArea textArea = new JTextArea(); + textArea.setEditable(false); + textArea.setFont(new Font("Monospaced", Font.PLAIN, 12)); + + try { + String jsonData = historyReader.getProjectDataAsJson(projectPath); + textArea.setText("Claude历史数据 (JSON格式):\n\n" + jsonData); + } catch (Exception ex) { + textArea.setText("无法加载数据: " + ex.getMessage()); + } + + mainPanel.add(new JScrollPane(textArea), BorderLayout.CENTER); + } + } + + private String generateHtmlWithData(String jsonData) { + // 正确的转义顺序很重要! + String escapedJson = jsonData + .replace("\\", "\\\\") // 先转义反斜杠 + .replace("\"", "\\\"") // 再转义双引号 + .replace("'", "\\'") // 转义单引号 + .replace("\n", "\\n") // 转义换行 + .replace("\r", "\\r"); // 转义回车 + + StringBuilder html = new StringBuilder(); + html.append("\n"); + html.append("\n"); + html.append("\n"); + html.append("\n"); + html.append("\n"); + html.append("\n"); + html.append("\n"); + html.append("\n"); + html.append("
\n"); + + // 头部区域 + html.append("
\n"); + html.append("

🤖 Claude 项目历史

\n"); + html.append("
\n"); + html.append(" {{ data.currentProject }}\n"); + html.append("
\n"); + html.append("
\n"); + html.append(" 📝 {{ data.sessions ? data.sessions.length : 0 }} 个会话\n"); + html.append(" 💬 {{ data.total || 0 }} 条消息\n"); + html.append("
\n"); + html.append("
\n"); + + // 内容区域 + html.append("
0\">\n"); + html.append("
\n"); + html.append("
\n"); + html.append("
{{ session.title }}
\n"); + html.append("
{{ timeAgo(session.lastTimestamp) }}
\n"); + html.append("
\n"); + html.append("
\n"); + html.append(" {{ session.messageCount }} 条消息\n"); + html.append(" {{ session.sessionId.substring(0, 8) }}\n"); + html.append("
\n"); + html.append("
\n"); + html.append("
\n"); + + // 空状态 + html.append("
\n"); + html.append("

暂无历史会话

\n"); + html.append("

当前项目下没有找到 Claude 会话记录

\n"); + html.append("
\n"); + + // 错误状态 + html.append("
\n"); + html.append("

⚠️ 加载失败

\n"); + html.append("

{{ error || (data && data.error) || '未知错误' }}

\n"); + html.append("
\n"); + + html.append("
\n"); + + html.append("\n"); + html.append("\n"); + html.append(""); + + return html.toString(); + } + + public JPanel getContent() { + return mainPanel; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/github/claudecodegui/ClaudeHistoryReader.java b/src/main/java/com/github/claudecodegui/ClaudeHistoryReader.java new file mode 100644 index 00000000..89a8ada2 --- /dev/null +++ b/src/main/java/com/github/claudecodegui/ClaudeHistoryReader.java @@ -0,0 +1,603 @@ +package com.github.claudecodegui; + +import com.google.gson.Gson; +import com.google.gson.JsonParser; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Claude本地历史记录读取器 + * 直接从本地文件系统读取Claude的历史数据 + */ +public class ClaudeHistoryReader { + + private static final String HOME_DIR = System.getProperty("user.home"); + private static final Path CLAUDE_DIR = Paths.get(HOME_DIR, ".claude"); + private static final Path HISTORY_FILE = CLAUDE_DIR.resolve("history.jsonl"); + private static final Path PROJECTS_DIR = CLAUDE_DIR.resolve("projects"); + + private final Gson gson = new Gson(); + + /** + * 历史记录条目 + */ + public static class HistoryEntry { + public String display; + public Map pastedContents; + public long timestamp; + public String project; + public String sessionId; + + public HistoryEntry() { + this.pastedContents = new HashMap<>(); + } + } + + /** + * 项目信息 + */ + public static class ProjectInfo { + public String path; + public String name; + public int count; + public long lastAccess; + public List messages; + + public ProjectInfo(String path) { + this.path = path; + this.name = path != null ? Paths.get(path).getFileName().toString() : "Root"; + if (this.name.isEmpty()) { + this.name = "Root"; + } + this.count = 0; + this.lastAccess = 0; + this.messages = new ArrayList<>(); + } + } + + /** + * 会话消息(从 projects 目录读取) + */ + public static class ConversationMessage { + public String uuid; + public String sessionId; + public String parentUuid; + public String timestamp; + public String type; + public Message message; + public Boolean isMeta; + public Boolean isSidechain; + public String cwd; + + public static class Message { + public String role; + public Object content; // 可能是 String 或 Array + } + } + + /** + * 从 projects 目录读取项目的所有会话 + */ + public List readProjectSessions(String projectPath) throws IOException { + List sessions = new ArrayList<>(); + + if (projectPath == null || projectPath.isEmpty()) { + return sessions; + } + + // 转换项目路径为安全的目录名(与 VSCode 扩展逻辑一致) + String sanitizedPath = projectPath.replaceAll("[^a-zA-Z0-9]", "-"); + Path projectDir = PROJECTS_DIR.resolve(sanitizedPath); + + if (!Files.exists(projectDir) || !Files.isDirectory(projectDir)) { + return sessions; + } + + // 读取项目目录下所有 .jsonl 文件 + Map> sessionMessagesMap = new HashMap<>(); + + Files.list(projectDir) + .filter(path -> path.toString().endsWith(".jsonl")) + .filter(path -> { + try { + return Files.size(path) > 0; + } catch (IOException e) { + return false; + } + }) + .forEach(path -> { + try (BufferedReader reader = Files.newBufferedReader(path)) { + // 从文件名提取 sessionId + String fileName = path.getFileName().toString(); + String sessionId = fileName.substring(0, fileName.lastIndexOf(".jsonl")); + + List messages = new ArrayList<>(); + String line; + + while ((line = reader.readLine()) != null) { + if (line.trim().isEmpty()) continue; + + try { + ConversationMessage msg = gson.fromJson(line, ConversationMessage.class); + if (msg != null) { + messages.add(msg); + } + } catch (Exception e) { + // 跳过解析失败的行 + } + } + + if (!messages.isEmpty()) { + sessionMessagesMap.put(sessionId, messages); + } + + } catch (Exception e) { + System.err.println("读取对话文件失败: " + path + " - " + e.getMessage()); + } + }); + + // 为每个会话生成 SessionInfo + for (Map.Entry> entry : sessionMessagesMap.entrySet()) { + String sessionId = entry.getKey(); + List messages = entry.getValue(); + + if (messages.isEmpty()) continue; + + // 生成摘要:找到第一条非 meta 的用户消息 + String summary = generateSummary(messages); + + // 获取最后一条消息的时间戳 + long lastTimestamp = 0; + for (ConversationMessage msg : messages) { + if (msg.timestamp != null) { + try { + long ts = parseTimestamp(msg.timestamp); + if (ts > lastTimestamp) { + lastTimestamp = ts; + } + } catch (Exception e) { + // 忽略无效的时间戳 + } + } + } + + // 过滤无效会话 + if (!isValidSession(sessionId, summary, messages.size())) { + continue; + } + + SessionInfo session = new SessionInfo(); + session.sessionId = sessionId; + session.title = summary; + session.messageCount = messages.size(); + session.lastTimestamp = lastTimestamp; + session.firstTimestamp = lastTimestamp; // 简化处理 + + sessions.add(session); + } + + // 按最后更新时间倒序排序 + sessions.sort((a, b) -> Long.compare(b.lastTimestamp, a.lastTimestamp)); + + return sessions; + } + + /** + * 生成会话摘要 + */ + private String generateSummary(List messages) { + for (ConversationMessage msg : messages) { + if ("user".equals(msg.type) && + (msg.isMeta == null || !msg.isMeta) && + msg.message != null && + msg.message.content != null) { + + String text = extractTextFromContent(msg.message.content); + if (text != null && !text.isEmpty()) { + // 去除换行符并截断 + text = text.replace("\n", " ").trim(); + if (text.length() > 45) { + text = text.substring(0, 45) + "..."; + } + return text; + } + } + } + return null; // 返回 null 表示没有有效内容 + } + + /** + * 判断会话是否有效(过滤掉 Warmup、No prompt 等无效会话) + */ + private boolean isValidSession(String sessionId, String summary, int messageCount) { + // 过滤 agent-xxx 格式的会话(都是 Warmup) + if (sessionId != null && sessionId.startsWith("agent-")) { + return false; + } + + // 过滤摘要为空或无效的会话 + if (summary == null || summary.isEmpty()) { + return false; + } + + // 过滤只有 "Warmup" 或 "No prompt" 的会话 + String lowerSummary = summary.toLowerCase(); + if (lowerSummary.equals("warmup") || + lowerSummary.equals("no prompt") || + lowerSummary.startsWith("warmup") || + lowerSummary.startsWith("no prompt")) { + return false; + } + + // 过滤消息数太少的会话(少于2条消息通常没什么内容) + if (messageCount < 2) { + return false; + } + + return true; + } + + /** + * 从 content 提取文本 + */ + private String extractTextFromContent(Object content) { + if (content instanceof String) { + return (String) content; + } else if (content instanceof List) { + @SuppressWarnings("unchecked") + List> contentList = (List>) content; + // 从后向前查找最后一个 text 类型的项 + for (int i = contentList.size() - 1; i >= 0; i--) { + Map item = contentList.get(i); + if ("text".equals(item.get("type"))) { + Object text = item.get("text"); + if (text instanceof String) { + return (String) text; + } + } + } + } + return null; + } + + /** + * 解析时间戳(支持 ISO 8601 格式) + */ + private long parseTimestamp(String timestamp) { + try { + // ISO 8601 格式如 "2025-11-18T20:16:42.310Z" + java.time.Instant instant = java.time.Instant.parse(timestamp); + return instant.toEpochMilli(); + } catch (Exception e) { + return 0; + } + } + + /** + * 会话信息 + */ + public static class SessionInfo { + public String sessionId; + public String title; + public int messageCount; + public long lastTimestamp; + public long firstTimestamp; + } + + /** + * 统计信息 + */ + public static class Statistics { + public int totalMessages; + public int totalProjects; + public HistoryEntry firstMessage; + public HistoryEntry lastMessage; + public Map messagesByDay; + + public Statistics() { + this.messagesByDay = new HashMap<>(); + } + } + + /** + * API响应 + */ + public static class ApiResponse { + public boolean success; + public String error; + public Object data; + + public static ApiResponse success(Object data) { + ApiResponse response = new ApiResponse(); + response.success = true; + response.data = data; + return response; + } + + public static ApiResponse error(String message) { + ApiResponse response = new ApiResponse(); + response.success = false; + response.error = message; + return response; + } + } + + /** + * 读取所有历史记录 + */ + public List readHistory() throws IOException { + List history = new ArrayList<>(); + + if (!Files.exists(HISTORY_FILE)) { + return history; + } + + try (BufferedReader reader = Files.newBufferedReader(HISTORY_FILE)) { + String line; + while ((line = reader.readLine()) != null) { + if (!line.trim().isEmpty()) { + try { + HistoryEntry entry = gson.fromJson(line, HistoryEntry.class); + if (entry != null) { + history.add(entry); + } + } catch (Exception e) { + System.err.println("解析行失败: " + e.getMessage()); + } + } + } + } + + // 按时间戳排序(最新的在前) + history.sort((a, b) -> Long.compare(b.timestamp, a.timestamp)); + + return history; + } + + /** + * 获取项目列表 + */ + public List getProjects(List history) { + Map projectsMap = new HashMap<>(); + + for (HistoryEntry entry : history) { + if (entry.project != null) { + ProjectInfo project = projectsMap.computeIfAbsent( + entry.project, + ProjectInfo::new + ); + project.count++; + project.messages.add(entry); + if (entry.timestamp > project.lastAccess) { + project.lastAccess = entry.timestamp; + } + } + } + + return projectsMap.values().stream() + .sorted((a, b) -> Long.compare(b.lastAccess, a.lastAccess)) + .collect(Collectors.toList()); + } + + /** + * 获取统计信息 + */ + public Statistics getStatistics(List history) { + Statistics stats = new Statistics(); + stats.totalMessages = history.size(); + + if (!history.isEmpty()) { + // 获取第一条和最后一条消息 + List sorted = new ArrayList<>(history); + sorted.sort(Comparator.comparingLong(e -> e.timestamp)); + stats.firstMessage = sorted.get(0); + stats.lastMessage = sorted.get(sorted.size() - 1); + + // 统计项目数 + Set projects = history.stream() + .map(e -> e.project) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + stats.totalProjects = projects.size(); + + // 按天统计消息 + for (HistoryEntry entry : history) { + if (entry.timestamp > 0) { + Date date = new Date(entry.timestamp); + String dateStr = String.format("%tF", date); + stats.messagesByDay.merge(dateStr, 1, Integer::sum); + } + } + } + + return stats; + } + + /** + * 搜索历史记录 + */ + public List searchHistory(List history, String query) { + if (query == null || query.trim().isEmpty()) { + return history; + } + + String lowerQuery = query.toLowerCase(); + return history.stream() + .filter(entry -> { + String display = entry.display != null ? entry.display.toLowerCase() : ""; + return display.contains(lowerQuery); + }) + .limit(100) + .collect(Collectors.toList()); + } + + /** + * 读取项目详情 + */ + public Map getProjectDetails(String projectPath) { + Map details = new HashMap<>(); + details.put("path", projectPath); + details.put("exists", false); + details.put("conversations", new ArrayList<>()); + + if (projectPath == null || projectPath.isEmpty()) { + return details; + } + + // 将路径转换为文件系统安全的名称 + String sanitizedPath = projectPath.replace("/", "-"); + Path projectDir = PROJECTS_DIR.resolve(sanitizedPath); + + if (Files.exists(projectDir) && Files.isDirectory(projectDir)) { + details.put("exists", true); + + try { + List> conversations = new ArrayList<>(); + + // 读取项目目录中的对话文件 + Files.list(projectDir) + .filter(Files::isDirectory) + .forEach(subDir -> { + Path convFile = subDir.resolve("conversation.json"); + if (Files.exists(convFile)) { + try { + String content = new String(Files.readAllBytes(convFile)); + Map convData = new HashMap<>(); + convData.put("id", subDir.getFileName().toString()); + convData.put("data", JsonParser.parseString(content)); + convData.put("timestamp", Files.getLastModifiedTime(convFile).toMillis()); + conversations.add(convData); + } catch (Exception e) { + System.err.println("读取对话文件失败: " + e.getMessage()); + } + } + }); + + details.put("conversations", conversations); + } catch (IOException e) { + System.err.println("读取项目详情失败: " + e.getMessage()); + } + } + + return details; + } + + /** + * 获取指定项目的历史记录JSON字符串 + */ + public String getProjectDataAsJson(String projectPath) { + try { + // 从 projects 目录读取会话列表 + List sessions = readProjectSessions(projectPath); + + // 计算总消息数 + int totalMessages = sessions.stream() + .mapToInt(s -> s.messageCount) + .sum(); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("sessions", sessions); + result.put("currentProject", projectPath); + result.put("total", totalMessages); + result.put("sessionCount", sessions.size()); + + return gson.toJson(result); + } catch (Exception e) { + return gson.toJson(ApiResponse.error("读取项目数据失败: " + e.getMessage())); + } + } + + /** + * 获取所有数据的JSON字符串 + */ + public String getAllDataAsJson() { + try { + List history = readHistory(); + List projects = getProjects(history); + Statistics stats = getStatistics(history); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("history", history.size() > 200 ? history.subList(0, 200) : history); + result.put("projects", projects); + result.put("stats", stats); + result.put("total", history.size()); + + return gson.toJson(result); + } catch (Exception e) { + return gson.toJson(ApiResponse.error("读取数据失败: " + e.getMessage())); + } + } + + /** + * 处理API请求 + */ + public String handleApiRequest(String endpoint, Map params) { + try { + switch (endpoint) { + case "/history": + return getAllDataAsJson(); + + case "/stats": + List historyForStats = readHistory(); + Statistics stats = getStatistics(historyForStats); + return gson.toJson(ApiResponse.success(stats)); + + case "/search": + String query = params.get("q"); + List historyForSearch = readHistory(); + List searchResults = searchHistory(historyForSearch, query); + Map searchResponse = new HashMap<>(); + searchResponse.put("query", query); + searchResponse.put("count", searchResults.size()); + searchResponse.put("results", searchResults); + return gson.toJson(ApiResponse.success(searchResponse)); + + case "/project": + String projectPath = params.get("path"); + Map projectDetails = getProjectDetails(projectPath); + return gson.toJson(ApiResponse.success(projectDetails)); + + default: + return gson.toJson(ApiResponse.error("Unknown endpoint: " + endpoint)); + } + } catch (Exception e) { + return gson.toJson(ApiResponse.error("处理请求失败: " + e.getMessage())); + } + } + + /** + * 主方法用于测试 + */ + public static void main(String[] args) { + ClaudeHistoryReader reader = new ClaudeHistoryReader(); + + try { + // 测试读取历史 + List history = reader.readHistory(); + System.out.println("历史记录条数: " + history.size()); + + // 测试获取项目 + List projects = reader.getProjects(history); + System.out.println("项目数: " + projects.size()); + + // 测试获取统计 + Statistics stats = reader.getStatistics(history); + System.out.println("总消息数: " + stats.totalMessages); + System.out.println("总项目数: " + stats.totalProjects); + + // 输出JSON + System.out.println("\nJSON输出:"); + System.out.println(reader.getAllDataAsJson()); + + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt new file mode 100644 index 00000000..71fc4dfd --- /dev/null +++ b/src/main/kotlin/Main.kt @@ -0,0 +1,16 @@ +package org.example + +//TIP 要运行代码,请按 或 +// 点击装订区域中的 图标。 +fun main() { + val name = "Kotlin" + //TIP 当文本光标位于高亮显示的文本处时按 + // 查看 IntelliJ IDEA 建议如何修正。 + println("Hello, " + name + "!") + + for (i in 1..5) { + //TIP 按 开始调试代码。我们已经设置了一个 断点 + // 但您始终可以通过按 添加更多断点。 + println("i = $i") + } +} \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml new file mode 100644 index 00000000..2a334a45 --- /dev/null +++ b/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,16 @@ + + com.github.idea-claude-code-gui + Claude Code GUI + Your Name + IntelliJ IDEA plugin for viewing Claude Code history with an intuitive GUI + + com.intellij.modules.platform + + + + + + diff --git a/src/main/resources/html/claude-java-history.html b/src/main/resources/html/claude-java-history.html new file mode 100644 index 00000000..237cf7a1 --- /dev/null +++ b/src/main/resources/html/claude-java-history.html @@ -0,0 +1,638 @@ + + + + + + Claude History - Java Backend + + + + + +
+
+

+ 🤖 Claude 本地历史记录 (Java Backend) +
+ + {{ connectionStatus }} +
+

+
+
+ 📝 + 总消息: + {{ stats.totalMessages }} +
+
+ 📁 + 项目数: + {{ stats.totalProjects }} +
+
+ 📅 + 最后活动: + {{ lastActivityTime }} +
+
+
+ +
+ + +
+
+

{{ selectedProject ? selectedProject.name + ' 的消息' : '最近消息' }}

+
+ 显示 {{ displayMessages.length }} / {{ totalMessages }} 条 +
+
+ +
+
+
💭
+

暂无消息

+
+ +
+
{{ msg.display }}
+
+ {{ getProjectName(msg.project) }} + {{ formatDateTime(msg.timestamp) }} +
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ ⚠️ {{ error }} +
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/html/claude-real-history.html b/src/main/resources/html/claude-real-history.html new file mode 100644 index 00000000..128628ad --- /dev/null +++ b/src/main/resources/html/claude-real-history.html @@ -0,0 +1,882 @@ + + + + + + Claude Real History Viewer + + + + + + + +
+ +
+

+ + Claude 本地历史记录查看器 +

+
+
+ 📝 + 总消息数: + {{ stats.totalMessages }} +
+
+ 📁 + 项目数: + {{ stats.totalProjects }} +
+
+ 📅 + 最后活动: + {{ lastActivityTime }} +
+
+ 🔄 + 状态: + {{ connectionStatus }} +
+
+
+ + +
+ + + + +
+
+

{{ selectedProject ? selectedProject.name + ' 的消息' : '所有消息' }}

+
+ 共 {{ displayMessages.length }} 条消息 +
+
+ +
+
+
💭
+

暂无消息

+
+ + +
+
{{ group.date }}
+
+
{{ msg.display }}
+
+ {{ getProjectName(msg.project) }} + {{ formatTime(msg.timestamp) }} +
+
+
+
+ +
+ + + +
+
+ + +
+
+
📊 统计概览
+
+

开始时间: {{ formatDate(stats.firstMessage?.timestamp) }}

+

最后时间: {{ formatDate(stats.lastMessage?.timestamp) }}

+

使用天数: {{ usageDays }}

+

日均消息: {{ avgMessagesPerDay }}

+
+
+ +
+
📈 活动趋势
+
+ +
+
+ +
+
🏆 最活跃项目
+
+
+
+ {{ index + 1 }}. {{ project.name }} + {{ project.count }} +
+
+
+
+ +
+
🔍 快速操作
+
+ + + +
+
+
+
+ + +
+ ⚠️ {{ error }} +
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/html/index.html b/src/main/resources/html/index.html new file mode 100644 index 00000000..237cf7a1 --- /dev/null +++ b/src/main/resources/html/index.html @@ -0,0 +1,638 @@ + + + + + + Claude History - Java Backend + + + + + +
+
+

+ 🤖 Claude 本地历史记录 (Java Backend) +
+ + {{ connectionStatus }} +
+

+
+
+ 📝 + 总消息: + {{ stats.totalMessages }} +
+
+ 📁 + 项目数: + {{ stats.totalProjects }} +
+
+ 📅 + 最后活动: + {{ lastActivityTime }} +
+
+
+ +
+ + +
+
+

{{ selectedProject ? selectedProject.name + ' 的消息' : '最近消息' }}

+
+ 显示 {{ displayMessages.length }} / {{ totalMessages }} 条 +
+
+ +
+
+
💭
+

暂无消息

+
+ +
+
{{ msg.display }}
+
+ {{ getProjectName(msg.project) }} + {{ formatDateTime(msg.timestamp) }} +
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ ⚠️ {{ error }} +
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/html/index_backup.html b/src/main/resources/html/index_backup.html new file mode 100644 index 00000000..a4ae84d6 --- /dev/null +++ b/src/main/resources/html/index_backup.html @@ -0,0 +1,476 @@ + + + + + + Vue Claude Plugin + + + + + + + +
+
+ +

+ + Vue + Claude 集成插件 +

+ + +
+ +
+
+ + +
+
+ API Key 已保存到本地存储 +
+
+ + +
+ + +
+ + +
+ + + +
+ + +
+
+

正在与 Claude 通信...

+
+ + +
+ {{ error }} +
+ + +
+

对话历史:

+
+
+ {{ msg.role === 'user' ? '你' : 'Claude' }}: +
+
{{ msg.content }}
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/html/index_with_history.html b/src/main/resources/html/index_with_history.html new file mode 100644 index 00000000..83e05fc7 --- /dev/null +++ b/src/main/resources/html/index_with_history.html @@ -0,0 +1,941 @@ + + + + + + Vue Claude Plugin with History + + + + + + + +
+ + + + +
+
+ +

+ + Vue + Claude 历史记录版 +

+ + +
+ +
+
+ + +
+
+ API Key 已保存 +
+
+ + +
+ + +
+ + +
+ + + + +
+ + +
+
+

正在与 Claude 通信...

+
+ + +
+ {{ error }} +
+ + +
+

当前对话

+
+ 共 {{ conversation.length }} 条消息 | + 会话ID: {{ currentSessionId }} | + 开始时间: {{ formatDate(currentSessionTimestamp) }} +
+
+
+ {{ msg.role === 'user' ? '👤 你' : '🤖 Claude' }}: + {{ formatTime(msg.timestamp) }} +
+
{{ msg.content }}
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/icons/cc-gui-icon.svg b/src/main/resources/icons/cc-gui-icon.svg new file mode 100644 index 00000000..ac056044 --- /dev/null +++ b/src/main/resources/icons/cc-gui-icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file