mirror of
https://github.com/zhukunpenglinyutong/jetbrains-cc-gui.git
synced 2026-08-28 16:31:27 +08:00
feat: 提交2025-11-19日代码,完成cc历史消息读取功能
This commit is contained in:
+45
@@ -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
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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)
|
||||
|
||||
<img width="400" alt="Image" src="https://claudecodecn-1253302184.cos.ap-beijing.myqcloud.com/idea/v0.0.1/1.png" />
|
||||
|
||||
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
|
||||
```
|
||||
@@ -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 = """
|
||||
<ul>
|
||||
<li>初始版本发布</li>
|
||||
<li>添加 Vue.js 视图支持</li>
|
||||
<li>实现右侧工具栏窗口</li>
|
||||
<li>更新支持IDEA 2024.2版本</li>
|
||||
</ul>
|
||||
"""
|
||||
}
|
||||
|
||||
runIde {
|
||||
// 启用 JCEF 支持
|
||||
jvmArgs '-Djcef.sandbox.enable=false'
|
||||
}
|
||||
|
||||
buildSearchableOptions {
|
||||
enabled = false
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<ConversationMessage> 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<HistoryEntry> history = reader.readHistory();
|
||||
System.out.println("历史记录条数: " + history.size());
|
||||
|
||||
// 获取项目列表
|
||||
List<ProjectInfo> 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<SessionInfo> 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日
|
||||
@@ -0,0 +1 @@
|
||||
kotlin.code.style=official
|
||||
Vendored
BIN
Binary file not shown.
+6
@@ -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
|
||||
@@ -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" "$@"
|
||||
Vendored
+89
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'idea-claude-code-gui'
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
|
||||
}
|
||||
rootProject.name = "idea-claude-code-gui"
|
||||
@@ -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 """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Microsoft YaHei', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
#app {
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.button {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
background: white;
|
||||
color: #764ba2;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>{{ message }}</h1>
|
||||
<button class="button" @click="count++">
|
||||
点击次数: {{ count }}
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
message: 'CC-GUI',
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 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<String, String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("<!DOCTYPE html>\n");
|
||||
html.append("<html>\n");
|
||||
html.append("<head>\n");
|
||||
html.append("<meta charset=\"UTF-8\">\n");
|
||||
html.append("<script src=\"https://unpkg.com/vue@3/dist/vue.global.js\"></script>\n");
|
||||
html.append("<style>\n");
|
||||
html.append(":root {\n");
|
||||
html.append(" --bg-color: #1e1e1e;\n");
|
||||
html.append(" --card-bg: #252526;\n");
|
||||
html.append(" --text-primary: #cccccc;\n");
|
||||
html.append(" --text-secondary: #858585;\n");
|
||||
html.append(" --accent-color: #4a90e2;\n");
|
||||
html.append(" --border-color: #3e3e42;\n");
|
||||
html.append(" --user-msg-bg: #2d2d2d;\n");
|
||||
html.append(" --ai-msg-bg: #252526;\n");
|
||||
html.append("}\n");
|
||||
html.append("body {\n");
|
||||
html.append(" font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\n");
|
||||
html.append(" background: var(--bg-color);\n");
|
||||
html.append(" color: var(--text-primary);\n");
|
||||
html.append(" margin: 0;\n");
|
||||
html.append(" padding: 0;\n");
|
||||
html.append(" height: 100vh;\n");
|
||||
html.append(" display: flex;\n");
|
||||
html.append(" flex-direction: column;\n");
|
||||
html.append("}\n");
|
||||
html.append(".header {\n");
|
||||
html.append(" padding: 16px;\n");
|
||||
html.append(" background: var(--card-bg);\n");
|
||||
html.append(" border-bottom: 1px solid var(--border-color);\n");
|
||||
html.append(" position: sticky;\n");
|
||||
html.append(" top: 0;\n");
|
||||
html.append(" z-index: 100;\n");
|
||||
html.append("}\n");
|
||||
html.append("h1 {\n");
|
||||
html.append(" font-size: 18px;\n");
|
||||
html.append(" margin: 0 0 8px 0;\n");
|
||||
html.append(" color: var(--text-primary);\n");
|
||||
html.append(" display: flex;\n");
|
||||
html.append(" align-items: center;\n");
|
||||
html.append(" gap: 8px;\n");
|
||||
html.append("}\n");
|
||||
html.append(".project-path {\n");
|
||||
html.append(" font-size: 12px;\n");
|
||||
html.append(" color: var(--text-secondary);\n");
|
||||
html.append(" word-break: break-all;\n");
|
||||
html.append("}\n");
|
||||
html.append(".stats {\n");
|
||||
html.append(" display: flex;\n");
|
||||
html.append(" gap: 16px;\n");
|
||||
html.append(" font-size: 12px;\n");
|
||||
html.append(" color: var(--text-secondary);\n");
|
||||
html.append(" margin-top: 8px;\n");
|
||||
html.append("}\n");
|
||||
html.append(".message-list {\n");
|
||||
html.append(" flex: 1;\n");
|
||||
html.append(" overflow-y: auto;\n");
|
||||
html.append(" padding: 16px;\n");
|
||||
html.append("}\n");
|
||||
html.append(".message-item {\n");
|
||||
html.append(" background: var(--card-bg);\n");
|
||||
html.append(" border: 1px solid #3e3e42;\n");
|
||||
html.append(" border-radius: 8px;\n");
|
||||
html.append(" padding: 16px;\n");
|
||||
html.append(" margin-bottom: 12px;\n");
|
||||
html.append(" transition: background-color 0.2s;\n");
|
||||
html.append(" cursor: pointer;\n");
|
||||
html.append("}\n");
|
||||
html.append(".message-item:hover {\n");
|
||||
html.append(" background: #2d2d2d;\n");
|
||||
html.append("}\n");
|
||||
html.append(".message-header {\n");
|
||||
html.append(" display: flex;\n");
|
||||
html.append(" justify-content: space-between;\n");
|
||||
html.append(" margin-bottom: 24px;\n");
|
||||
html.append("}\n");
|
||||
html.append(".message-title {\n");
|
||||
html.append(" font-size: 15px;\n");
|
||||
html.append(" font-weight: 600;\n");
|
||||
html.append(" color: #e0e0e0;\n");
|
||||
html.append(" white-space: nowrap;\n");
|
||||
html.append(" overflow: hidden;\n");
|
||||
html.append(" text-overflow: ellipsis;\n");
|
||||
html.append(" margin-right: 16px;\n");
|
||||
html.append(" flex: 1;\n");
|
||||
html.append("}\n");
|
||||
html.append(".message-time {\n");
|
||||
html.append(" font-size: 13px;\n");
|
||||
html.append(" color: #858585;\n");
|
||||
html.append(" white-space: nowrap;\n");
|
||||
html.append("}\n");
|
||||
html.append(".message-footer {\n");
|
||||
html.append(" display: flex;\n");
|
||||
html.append(" justify-content: space-between;\n");
|
||||
html.append(" align-items: center;\n");
|
||||
html.append(" font-size: 13px;\n");
|
||||
html.append(" color: #858585;\n");
|
||||
html.append("}\n");
|
||||
html.append(".message-id {\n");
|
||||
html.append(" font-family: monospace;\n");
|
||||
html.append(" color: #666;\n");
|
||||
html.append("}\n");
|
||||
html.append(".empty-state {\n");
|
||||
html.append(" display: flex;\n");
|
||||
html.append(" flex-direction: column;\n");
|
||||
html.append(" align-items: center;\n");
|
||||
html.append(" justify-content: center;\n");
|
||||
html.append(" height: 100%;\n");
|
||||
html.append(" color: var(--text-secondary);\n");
|
||||
html.append(" text-align: center;\n");
|
||||
html.append("}\n");
|
||||
html.append("::-webkit-scrollbar {\n");
|
||||
html.append(" width: 8px;\n");
|
||||
html.append("}\n");
|
||||
html.append("::-webkit-scrollbar-track {\n");
|
||||
html.append(" background: var(--bg-color);\n");
|
||||
html.append("}\n");
|
||||
html.append("::-webkit-scrollbar-thumb {\n");
|
||||
html.append(" background: #424242;\n");
|
||||
html.append(" border-radius: 4px;\n");
|
||||
html.append("}\n");
|
||||
html.append("::-webkit-scrollbar-thumb:hover {\n");
|
||||
html.append(" background: #4f4f4f;\n");
|
||||
html.append("}\n");
|
||||
html.append("</style>\n");
|
||||
html.append("</head>\n");
|
||||
html.append("<body>\n");
|
||||
html.append("<div id=\"app\">\n");
|
||||
|
||||
// 头部区域
|
||||
html.append(" <div class=\"header\">\n");
|
||||
html.append(" <h1>🤖 Claude 项目历史</h1>\n");
|
||||
html.append(" <div class=\"project-path\" v-if=\"data && data.currentProject\">\n");
|
||||
html.append(" {{ data.currentProject }}\n");
|
||||
html.append(" </div>\n");
|
||||
html.append(" <div class=\"stats\" v-if=\"data && data.success\">\n");
|
||||
html.append(" <span>📝 {{ data.sessions ? data.sessions.length : 0 }} 个会话</span>\n");
|
||||
html.append(" <span>💬 {{ data.total || 0 }} 条消息</span>\n");
|
||||
html.append(" </div>\n");
|
||||
html.append(" </div>\n");
|
||||
|
||||
// 内容区域
|
||||
html.append(" <div class=\"message-list\" v-if=\"data && data.sessions && data.sessions.length > 0\">\n");
|
||||
html.append(" <div v-for=\"session in data.sessions\" :key=\"session.sessionId\" class=\"message-item\">\n");
|
||||
html.append(" <div class=\"message-header\">\n");
|
||||
html.append(" <div class=\"message-title\">{{ session.title }}</div>\n");
|
||||
html.append(" <div class=\"message-time\">{{ timeAgo(session.lastTimestamp) }}</div>\n");
|
||||
html.append(" </div>\n");
|
||||
html.append(" <div class=\"message-footer\">\n");
|
||||
html.append(" <span>{{ session.messageCount }} 条消息</span>\n");
|
||||
html.append(" <span class=\"message-id\">{{ session.sessionId.substring(0, 8) }}</span>\n");
|
||||
html.append(" </div>\n");
|
||||
html.append(" </div>\n");
|
||||
html.append(" </div>\n");
|
||||
|
||||
// 空状态
|
||||
html.append(" <div class=\"empty-state\" v-else-if=\"data && data.success\">\n");
|
||||
html.append(" <h3>暂无历史会话</h3>\n");
|
||||
html.append(" <p>当前项目下没有找到 Claude 会话记录</p>\n");
|
||||
html.append(" </div>\n");
|
||||
|
||||
// 错误状态
|
||||
html.append(" <div v-else class=\"empty-state\">\n");
|
||||
html.append(" <h3>⚠️ 加载失败</h3>\n");
|
||||
html.append(" <p>{{ error || (data && data.error) || '未知错误' }}</p>\n");
|
||||
html.append(" </div>\n");
|
||||
|
||||
html.append("</div>\n");
|
||||
|
||||
html.append("<script>\n");
|
||||
html.append("console.log('Starting Vue initialization...');\n");
|
||||
html.append("console.log('Vue available:', typeof Vue !== 'undefined');\n");
|
||||
html.append("if (typeof Vue === 'undefined') {\n");
|
||||
html.append(" console.error('Vue is not loaded!');\n");
|
||||
html.append(" document.getElementById('app').innerHTML = '<div style=\"color:red;padding:20px;\">错误:Vue.js 未加载</div>';\n");
|
||||
html.append("} else {\n");
|
||||
html.append(" const { createApp } = Vue;\n");
|
||||
html.append(" const claudeDataStr = '").append(escapedJson).append("';\n");
|
||||
html.append(" console.log('Data string length:', claudeDataStr.length);\n");
|
||||
html.append(" let claudeData = null;\n");
|
||||
html.append(" try {\n");
|
||||
html.append(" claudeData = JSON.parse(claudeDataStr);\n");
|
||||
html.append(" console.log('Parsed data:', claudeData);\n");
|
||||
html.append(" } catch(e) {\n");
|
||||
html.append(" console.error('Failed to parse data:', e);\n");
|
||||
html.append(" console.error('Data string:', claudeDataStr.substring(0, 200));\n");
|
||||
html.append(" }\n");
|
||||
html.append(" \n");
|
||||
html.append(" const app = createApp({\n");
|
||||
html.append(" data() {\n");
|
||||
html.append(" return {\n");
|
||||
html.append(" data: claudeData,\n");
|
||||
html.append(" error: claudeData ? null : 'Failed to parse data'\n");
|
||||
html.append(" }\n");
|
||||
html.append(" },\n");
|
||||
html.append(" methods: {\n");
|
||||
html.append(" formatTime(timestamp) {\n");
|
||||
html.append(" if (!timestamp) return '';\n");
|
||||
html.append(" const date = new Date(timestamp);\n");
|
||||
html.append(" return date.toLocaleString();\n");
|
||||
html.append(" },\n");
|
||||
html.append(" timeAgo(timestamp) {\n");
|
||||
html.append(" if (!timestamp) return '';\n");
|
||||
html.append(" const seconds = Math.floor((new Date() - new Date(timestamp)) / 1000);\n");
|
||||
html.append(" let interval = seconds / 31536000;\n");
|
||||
html.append(" if (interval > 1) return Math.floor(interval) + ' 年前';\n");
|
||||
html.append(" interval = seconds / 2592000;\n");
|
||||
html.append(" if (interval > 1) return Math.floor(interval) + ' 个月前';\n");
|
||||
html.append(" interval = seconds / 86400;\n");
|
||||
html.append(" if (interval > 1) return Math.floor(interval) + ' 天前';\n");
|
||||
html.append(" interval = seconds / 3600;\n");
|
||||
html.append(" if (interval > 1) return Math.floor(interval) + ' 小时前';\n");
|
||||
html.append(" interval = seconds / 60;\n");
|
||||
html.append(" if (interval > 1) return Math.floor(interval) + ' 分钟前';\n");
|
||||
html.append(" return Math.floor(seconds) + ' 秒前';\n");
|
||||
html.append(" }\n");
|
||||
html.append(" },\n");
|
||||
html.append(" mounted() {\n");
|
||||
html.append(" console.log('Vue app mounted, data:', this.data);\n");
|
||||
html.append(" }\n");
|
||||
html.append(" });\n");
|
||||
html.append(" \n");
|
||||
html.append(" app.mount('#app');\n");
|
||||
html.append(" console.log('Vue app mounted successfully');\n");
|
||||
html.append("}\n");
|
||||
html.append("</script>\n");
|
||||
html.append("</body>\n");
|
||||
html.append("</html>");
|
||||
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
public JPanel getContent() {
|
||||
return mainPanel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<HistoryEntry> 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<SessionInfo> readProjectSessions(String projectPath) throws IOException {
|
||||
List<SessionInfo> 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<String, List<ConversationMessage>> 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<ConversationMessage> 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<String, List<ConversationMessage>> entry : sessionMessagesMap.entrySet()) {
|
||||
String sessionId = entry.getKey();
|
||||
List<ConversationMessage> 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<ConversationMessage> 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<Map<String, Object>> contentList = (List<Map<String, Object>>) content;
|
||||
// 从后向前查找最后一个 text 类型的项
|
||||
for (int i = contentList.size() - 1; i >= 0; i--) {
|
||||
Map<String, Object> 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<String, Integer> 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<HistoryEntry> readHistory() throws IOException {
|
||||
List<HistoryEntry> 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<ProjectInfo> getProjects(List<HistoryEntry> history) {
|
||||
Map<String, ProjectInfo> 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<HistoryEntry> history) {
|
||||
Statistics stats = new Statistics();
|
||||
stats.totalMessages = history.size();
|
||||
|
||||
if (!history.isEmpty()) {
|
||||
// 获取第一条和最后一条消息
|
||||
List<HistoryEntry> sorted = new ArrayList<>(history);
|
||||
sorted.sort(Comparator.comparingLong(e -> e.timestamp));
|
||||
stats.firstMessage = sorted.get(0);
|
||||
stats.lastMessage = sorted.get(sorted.size() - 1);
|
||||
|
||||
// 统计项目数
|
||||
Set<String> 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<HistoryEntry> searchHistory(List<HistoryEntry> 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<String, Object> getProjectDetails(String projectPath) {
|
||||
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<SessionInfo> sessions = readProjectSessions(projectPath);
|
||||
|
||||
// 计算总消息数
|
||||
int totalMessages = sessions.stream()
|
||||
.mapToInt(s -> s.messageCount)
|
||||
.sum();
|
||||
|
||||
Map<String, Object> 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<HistoryEntry> history = readHistory();
|
||||
List<ProjectInfo> projects = getProjects(history);
|
||||
Statistics stats = getStatistics(history);
|
||||
|
||||
Map<String, Object> 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<String, String> params) {
|
||||
try {
|
||||
switch (endpoint) {
|
||||
case "/history":
|
||||
return getAllDataAsJson();
|
||||
|
||||
case "/stats":
|
||||
List<HistoryEntry> historyForStats = readHistory();
|
||||
Statistics stats = getStatistics(historyForStats);
|
||||
return gson.toJson(ApiResponse.success(stats));
|
||||
|
||||
case "/search":
|
||||
String query = params.get("q");
|
||||
List<HistoryEntry> historyForSearch = readHistory();
|
||||
List<HistoryEntry> searchResults = searchHistory(historyForSearch, query);
|
||||
Map<String, Object> 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<String, Object> 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<HistoryEntry> history = reader.readHistory();
|
||||
System.out.println("历史记录条数: " + history.size());
|
||||
|
||||
// 测试获取项目
|
||||
List<ProjectInfo> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.example
|
||||
|
||||
//TIP 要<b>运行</b>代码,请按 <shortcut actionId="Run"/> 或
|
||||
// 点击装订区域中的 <icon src="AllIcons.Actions.Execute"/> 图标。
|
||||
fun main() {
|
||||
val name = "Kotlin"
|
||||
//TIP 当文本光标位于高亮显示的文本处时按 <shortcut actionId="ShowIntentionActions"/>
|
||||
// 查看 IntelliJ IDEA 建议如何修正。
|
||||
println("Hello, " + name + "!")
|
||||
|
||||
for (i in 1..5) {
|
||||
//TIP 按 <shortcut actionId="Debug"/> 开始调试代码。我们已经设置了一个 <icon src="AllIcons.Debugger.Db_set_breakpoint"/> 断点
|
||||
// 但您始终可以通过按 <shortcut actionId="ToggleLineBreakpoint"/> 添加更多断点。
|
||||
println("i = $i")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<idea-plugin>
|
||||
<id>com.github.idea-claude-code-gui</id>
|
||||
<name>Claude Code GUI</name>
|
||||
<vendor>Your Name</vendor>
|
||||
<description>IntelliJ IDEA plugin for viewing Claude Code history with an intuitive GUI</description>
|
||||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<!-- 注册右侧工具窗口 -->
|
||||
<toolWindow id="CC-GUI"
|
||||
anchor="right"
|
||||
factoryClass="com.github.claudecodegui.CCGuiToolWindowFactorySimple"
|
||||
icon="/icons/cc-gui-icon.svg"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
@@ -0,0 +1,638 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude History - Java Backend</title>
|
||||
<!-- 引入 Vue 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #16213e;
|
||||
border-right: 1px solid #2a2a4e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #2a2a4e;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #0f3460;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
background: #0f3460;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: #1e5f8e;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background: #667eea;
|
||||
border-color: #764ba2;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.project-path {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.project-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.message-area {
|
||||
flex: 1;
|
||||
background: #0f3460;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
padding: 15px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #2a2a4e;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
background: #16213e;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #2a2a4e;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #f44336;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a2a4e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #667eea;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="header">
|
||||
<h1>
|
||||
🤖 Claude 本地历史记录 (Java Backend)
|
||||
<div class="connection-status">
|
||||
<span class="status-dot" :class="{ error: !connected }"></span>
|
||||
{{ connectionStatus }}
|
||||
</div>
|
||||
</h1>
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span>📝</span>
|
||||
<span>总消息:</span>
|
||||
<span class="stat-value">{{ stats.totalMessages }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>📁</span>
|
||||
<span>项目数:</span>
|
||||
<span class="stat-value">{{ stats.totalProjects }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>📅</span>
|
||||
<span>最后活动:</span>
|
||||
<span class="stat-value">{{ lastActivityTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="sidebar">
|
||||
<div class="search-section">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索消息..."
|
||||
@input="performSearch"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="project-list">
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<p>暂无项目</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="project in projects"
|
||||
:key="project.path"
|
||||
:class="['project-item', { active: selectedProject === project }]"
|
||||
@click="selectProject(project)"
|
||||
>
|
||||
<div class="project-name">📁 {{ project.name }}</div>
|
||||
<div class="project-path">{{ project.path }}</div>
|
||||
<div class="project-stats">
|
||||
<span>💬 {{ project.count }}</span>
|
||||
<span>{{ formatDate(project.lastAccess) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-area">
|
||||
<div class="message-header">
|
||||
<h3>{{ selectedProject ? selectedProject.name + ' 的消息' : '最近消息' }}</h3>
|
||||
<div style="margin-top: 5px; font-size: 12px; color: #888;">
|
||||
显示 {{ displayMessages.length }} / {{ totalMessages }} 条
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-list">
|
||||
<div v-if="displayMessages.length === 0" class="empty-state">
|
||||
<div class="empty-icon">💭</div>
|
||||
<p>暂无消息</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(msg, index) in displayMessages"
|
||||
:key="index"
|
||||
class="message-item"
|
||||
>
|
||||
<div class="message-content">{{ msg.display }}</div>
|
||||
<div class="message-meta">
|
||||
<span>{{ getProjectName(msg.project) }}</span>
|
||||
<span>{{ formatDateTime(msg.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<button
|
||||
class="btn"
|
||||
@click="loadMore"
|
||||
v-if="hasMore"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px; background: #16213e; border-top: 1px solid #2a2a4e;">
|
||||
<button class="btn" @click="refreshData">🔄 刷新</button>
|
||||
<button class="btn" @click="exportData">📥 导出</button>
|
||||
<button class="btn" @click="clearSelection">❌ 清除选择</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
⚠️ {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
// 数据
|
||||
history: [],
|
||||
projects: [],
|
||||
stats: {
|
||||
totalMessages: 0,
|
||||
totalProjects: 0,
|
||||
firstMessage: null,
|
||||
lastMessage: null
|
||||
},
|
||||
|
||||
// UI 状态
|
||||
loading: false,
|
||||
error: null,
|
||||
connected: false,
|
||||
connectionStatus: '检查中...',
|
||||
searchQuery: '',
|
||||
selectedProject: null,
|
||||
|
||||
// 显示相关
|
||||
displayMessages: [],
|
||||
displayLimit: 50,
|
||||
totalMessages: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lastActivityTime() {
|
||||
if (this.stats.lastMessage) {
|
||||
return this.formatDate(this.stats.lastMessage.timestamp);
|
||||
}
|
||||
return '无';
|
||||
},
|
||||
|
||||
hasMore() {
|
||||
return this.displayMessages.length < this.totalMessages;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 使用 Java 后端获取数据
|
||||
async fetchHistory() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// 检查 ClaudeAPI 是否可用
|
||||
if (typeof window.ClaudeAPI === 'undefined') {
|
||||
// 等待 API 注入
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
if (typeof window.ClaudeAPI === 'undefined') {
|
||||
throw new Error('ClaudeAPI 未初始化,请确保插件正确加载');
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Java 后端 API
|
||||
window.ClaudeAPI.fetchHistory((response) => {
|
||||
if (response.success) {
|
||||
this.history = response.history || [];
|
||||
this.projects = response.projects || [];
|
||||
this.stats = response.stats || this.stats;
|
||||
this.totalMessages = response.total || this.history.length;
|
||||
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
this.connected = true;
|
||||
this.connectionStatus = 'Java Backend';
|
||||
this.loading = false;
|
||||
} else {
|
||||
throw new Error(response.error || '获取数据失败');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
this.error = error.message;
|
||||
this.connected = false;
|
||||
this.connectionStatus = '连接失败';
|
||||
this.loading = false;
|
||||
|
||||
// 使用模拟数据
|
||||
this.useMockData();
|
||||
}
|
||||
},
|
||||
|
||||
// 搜索
|
||||
performSearch() {
|
||||
if (!this.searchQuery) {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.ClaudeAPI && window.ClaudeAPI.search) {
|
||||
window.ClaudeAPI.search(this.searchQuery, (response) => {
|
||||
if (response.success) {
|
||||
this.displayMessages = response.data.results || [];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 本地搜索
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
this.displayMessages = this.history.filter(msg =>
|
||||
msg.display && msg.display.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// 选择项目
|
||||
selectProject(project) {
|
||||
this.selectedProject = project;
|
||||
if (project && project.messages) {
|
||||
this.displayMessages = project.messages.slice(0, this.displayLimit);
|
||||
this.totalMessages = project.messages.length;
|
||||
} else {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
this.totalMessages = this.history.length;
|
||||
}
|
||||
},
|
||||
|
||||
// 清除选择
|
||||
clearSelection() {
|
||||
this.selectedProject = null;
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
this.totalMessages = this.history.length;
|
||||
},
|
||||
|
||||
// 加载更多
|
||||
loadMore() {
|
||||
this.displayLimit += 50;
|
||||
if (this.selectedProject && this.selectedProject.messages) {
|
||||
this.displayMessages = this.selectedProject.messages.slice(0, this.displayLimit);
|
||||
} else {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新数据
|
||||
refreshData() {
|
||||
this.displayLimit = 50;
|
||||
this.fetchHistory();
|
||||
},
|
||||
|
||||
// 导出数据
|
||||
exportData() {
|
||||
const dataStr = JSON.stringify({
|
||||
history: this.history,
|
||||
projects: this.projects,
|
||||
stats: this.stats,
|
||||
exportDate: new Date().toISOString()
|
||||
}, null, 2);
|
||||
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `claude-history-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// 使用模拟数据
|
||||
useMockData() {
|
||||
this.history = [
|
||||
{ display: '示例消息 1', timestamp: Date.now(), project: '/example/project1' },
|
||||
{ display: '示例消息 2', timestamp: Date.now() - 3600000, project: '/example/project2' },
|
||||
{ display: '无法连接到本地数据,显示示例数据', timestamp: Date.now() - 7200000, project: '/demo' }
|
||||
];
|
||||
this.projects = [
|
||||
{ path: '/example/project1', name: 'Project 1', count: 1, lastAccess: Date.now() },
|
||||
{ path: '/example/project2', name: 'Project 2', count: 1, lastAccess: Date.now() - 3600000 }
|
||||
];
|
||||
this.displayMessages = this.history;
|
||||
this.totalMessages = this.history.length;
|
||||
this.stats = {
|
||||
totalMessages: 3,
|
||||
totalProjects: 2,
|
||||
firstMessage: this.history[2],
|
||||
lastMessage: this.history[0]
|
||||
};
|
||||
},
|
||||
|
||||
// 格式化函数
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '未知';
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
},
|
||||
|
||||
formatDateTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
getProjectName(path) {
|
||||
if (!path) return '未知项目';
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1] || 'Root';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('Claude History Viewer (Java Backend) 已启动');
|
||||
|
||||
// 立即尝试获取数据
|
||||
this.fetchHistory();
|
||||
|
||||
// 定期刷新
|
||||
setInterval(() => {
|
||||
if (this.connected) {
|
||||
this.fetchHistory();
|
||||
}
|
||||
}, 60000); // 每分钟刷新
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,882 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude Real History Viewer</title>
|
||||
<!-- 引入 Vue 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<!-- 引入 Axios -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶部栏 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.claude-logo {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧项目列表 */
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #16213e;
|
||||
border-right: 1px solid #2a2a4e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #2a2a4e;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #0f3460;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
background: #0f3460;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #1e5f8e;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
background: #0f3460;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: #1e5f8e;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background: #667eea;
|
||||
border-color: #764ba2;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.project-path {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.project-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* 中间消息列表 */
|
||||
.message-area {
|
||||
flex: 1;
|
||||
background: #0f3460;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
padding: 15px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #2a2a4e;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
background: #16213e;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.message-project {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* 右侧详情面板 */
|
||||
.detail-panel {
|
||||
width: 350px;
|
||||
background: #16213e;
|
||||
border-left: 1px solid #2a2a4e;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
background: #0f3460;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #2a2a4e;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.error-message {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: #0f3460;
|
||||
border-top: 1px solid #2a2a4e;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a2a4e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
/* 标签样式 */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #667eea;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.time-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.time-group-header {
|
||||
font-size: 12px;
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid #2a2a4e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 顶部栏 -->
|
||||
<div class="header">
|
||||
<h1>
|
||||
<span class="claude-logo">🤖</span>
|
||||
Claude 本地历史记录查看器
|
||||
</h1>
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span>📝</span>
|
||||
<span>总消息数:</span>
|
||||
<span class="stat-value">{{ stats.totalMessages }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>📁</span>
|
||||
<span>项目数:</span>
|
||||
<span class="stat-value">{{ stats.totalProjects }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>📅</span>
|
||||
<span>最后活动:</span>
|
||||
<span class="stat-value">{{ lastActivityTime }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>🔄</span>
|
||||
<span>状态:</span>
|
||||
<span class="stat-value">{{ connectionStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-content">
|
||||
<!-- 左侧项目列表 -->
|
||||
<div class="sidebar">
|
||||
<div class="search-section">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索项目或消息..."
|
||||
@input="performSearch"
|
||||
>
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: filterType === 'all' }"
|
||||
@click="setFilter('all')"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: filterType === 'recent' }"
|
||||
@click="setFilter('recent')"
|
||||
>
|
||||
最近
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: filterType === 'active' }"
|
||||
@click="setFilter('active')"
|
||||
>
|
||||
活跃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-list">
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredProjects.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<p>暂无项目</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.path"
|
||||
:class="['project-item', { active: selectedProject === project }]"
|
||||
@click="selectProject(project)"
|
||||
>
|
||||
<div class="project-name">
|
||||
📁 {{ project.name }}
|
||||
</div>
|
||||
<div class="project-path">{{ project.path }}</div>
|
||||
<div class="project-stats">
|
||||
<span>💬 {{ project.count }} 条消息</span>
|
||||
<span>{{ formatDate(project.lastAccess) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间消息列表 -->
|
||||
<div class="message-area">
|
||||
<div class="message-header">
|
||||
<h3>{{ selectedProject ? selectedProject.name + ' 的消息' : '所有消息' }}</h3>
|
||||
<div style="margin-top: 5px; font-size: 12px; color: #888;">
|
||||
共 {{ displayMessages.length }} 条消息
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-list">
|
||||
<div v-if="displayMessages.length === 0" class="empty-state">
|
||||
<div class="empty-icon">💭</div>
|
||||
<p>暂无消息</p>
|
||||
</div>
|
||||
|
||||
<!-- 按时间分组显示 -->
|
||||
<div v-for="group in messageGroups" :key="group.date" class="time-group">
|
||||
<div class="time-group-header">{{ group.date }}</div>
|
||||
<div
|
||||
v-for="(msg, index) in group.messages"
|
||||
:key="index"
|
||||
class="message-item"
|
||||
>
|
||||
<div class="message-content">{{ msg.display }}</div>
|
||||
<div class="message-meta">
|
||||
<span class="message-project">{{ getProjectName(msg.project) }}</span>
|
||||
<span>{{ formatTime(msg.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn" @click="refreshData">
|
||||
🔄 刷新
|
||||
</button>
|
||||
<button class="btn" @click="exportData">
|
||||
📥 导出数据
|
||||
</button>
|
||||
<button class="btn" @click="loadMore" :disabled="!hasMore">
|
||||
⬇️ 加载更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情面板 -->
|
||||
<div class="detail-panel">
|
||||
<div class="detail-section">
|
||||
<div class="detail-title">📊 统计概览</div>
|
||||
<div class="detail-content">
|
||||
<p>开始时间: {{ formatDate(stats.firstMessage?.timestamp) }}</p>
|
||||
<p>最后时间: {{ formatDate(stats.lastMessage?.timestamp) }}</p>
|
||||
<p>使用天数: {{ usageDays }}</p>
|
||||
<p>日均消息: {{ avgMessagesPerDay }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-title">📈 活动趋势</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="activityChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-title">🏆 最活跃项目</div>
|
||||
<div class="detail-content">
|
||||
<div v-for="(project, index) in topProjects" :key="index" style="margin-bottom: 8px;">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>{{ index + 1 }}. {{ project.name }}</span>
|
||||
<span class="tag">{{ project.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-title">🔍 快速操作</div>
|
||||
<div class="detail-content">
|
||||
<button class="btn" style="width: 100%; margin-bottom: 8px;" @click="openInFinder">
|
||||
📂 打开数据目录
|
||||
</button>
|
||||
<button class="btn" style="width: 100%; margin-bottom: 8px;" @click="showRawData">
|
||||
📝 查看原始数据
|
||||
</button>
|
||||
<button class="btn" style="width: 100%;" @click="clearCache">
|
||||
🗑️ 清除缓存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-message">
|
||||
⚠️ {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
const API_BASE = 'http://localhost:3333';
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
// 数据
|
||||
history: [],
|
||||
projects: [],
|
||||
stats: {
|
||||
totalMessages: 0,
|
||||
totalProjects: 0,
|
||||
firstMessage: null,
|
||||
lastMessage: null,
|
||||
messagesByDay: {}
|
||||
},
|
||||
|
||||
// UI 状态
|
||||
loading: false,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
filterType: 'all',
|
||||
selectedProject: null,
|
||||
displayLimit: 50,
|
||||
connectionStatus: '未连接',
|
||||
|
||||
// 过滤后的数据
|
||||
filteredProjects: [],
|
||||
displayMessages: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lastActivityTime() {
|
||||
if (this.stats.lastMessage) {
|
||||
return this.formatDate(this.stats.lastMessage.timestamp);
|
||||
}
|
||||
return '无';
|
||||
},
|
||||
|
||||
messageGroups() {
|
||||
const groups = {};
|
||||
this.displayMessages.forEach(msg => {
|
||||
const date = this.formatDateGroup(msg.timestamp);
|
||||
if (!groups[date]) {
|
||||
groups[date] = [];
|
||||
}
|
||||
groups[date].push(msg);
|
||||
});
|
||||
|
||||
return Object.keys(groups).map(date => ({
|
||||
date,
|
||||
messages: groups[date]
|
||||
}));
|
||||
},
|
||||
|
||||
hasMore() {
|
||||
return this.displayMessages.length < this.history.length;
|
||||
},
|
||||
|
||||
topProjects() {
|
||||
return [...this.projects]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
},
|
||||
|
||||
usageDays() {
|
||||
if (this.stats.firstMessage && this.stats.lastMessage) {
|
||||
const days = Math.floor(
|
||||
(this.stats.lastMessage.timestamp - this.stats.firstMessage.timestamp) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
return days + 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
avgMessagesPerDay() {
|
||||
if (this.usageDays > 0) {
|
||||
return (this.stats.totalMessages / this.usageDays).toFixed(1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 获取历史数据
|
||||
async fetchHistory() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/api/history`);
|
||||
if (response.data.success) {
|
||||
this.history = response.data.history;
|
||||
this.projects = response.data.projects;
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
this.filteredProjects = [...this.projects];
|
||||
this.connectionStatus = '已连接';
|
||||
|
||||
// 获取统计信息
|
||||
await this.fetchStats();
|
||||
} else {
|
||||
throw new Error('获取数据失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = '无法连接到历史记录服务器。请确保服务器正在运行。';
|
||||
this.connectionStatus = '连接失败';
|
||||
console.error('Error fetching history:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取统计信息
|
||||
async fetchStats() {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/api/stats`);
|
||||
if (response.data.success) {
|
||||
this.stats = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 搜索
|
||||
async performSearch() {
|
||||
if (!this.searchQuery) {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
this.filteredProjects = [...this.projects];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/api/search`, {
|
||||
params: { q: this.searchQuery }
|
||||
});
|
||||
if (response.data.success) {
|
||||
this.displayMessages = response.data.results;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 设置过滤器
|
||||
setFilter(type) {
|
||||
this.filterType = type;
|
||||
this.applyFilter();
|
||||
},
|
||||
|
||||
// 应用过滤器
|
||||
applyFilter() {
|
||||
switch (this.filterType) {
|
||||
case 'recent':
|
||||
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
this.filteredProjects = this.projects.filter(p =>
|
||||
p.lastAccess > oneWeekAgo
|
||||
);
|
||||
break;
|
||||
case 'active':
|
||||
this.filteredProjects = this.projects.filter(p => p.count > 10);
|
||||
break;
|
||||
default:
|
||||
this.filteredProjects = [...this.projects];
|
||||
}
|
||||
},
|
||||
|
||||
// 选择项目
|
||||
selectProject(project) {
|
||||
this.selectedProject = project;
|
||||
if (project) {
|
||||
this.displayMessages = this.history.filter(msg =>
|
||||
msg.project === project.path
|
||||
);
|
||||
} else {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
}
|
||||
},
|
||||
|
||||
// 加载更多
|
||||
loadMore() {
|
||||
this.displayLimit += 50;
|
||||
if (this.selectedProject) {
|
||||
this.displayMessages = this.history.filter(msg =>
|
||||
msg.project === this.selectedProject.path
|
||||
);
|
||||
} else {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新数据
|
||||
refreshData() {
|
||||
this.fetchHistory();
|
||||
},
|
||||
|
||||
// 导出数据
|
||||
exportData() {
|
||||
const dataStr = JSON.stringify({
|
||||
history: this.history,
|
||||
projects: this.projects,
|
||||
stats: this.stats,
|
||||
exportDate: new Date().toISOString()
|
||||
}, null, 2);
|
||||
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `claude-history-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// 打开数据目录
|
||||
openInFinder() {
|
||||
// 这需要通过Java后端实现
|
||||
alert('请在终端中运行: open ~/.claude');
|
||||
},
|
||||
|
||||
// 显示原始数据
|
||||
showRawData() {
|
||||
console.log('History:', this.history);
|
||||
console.log('Projects:', this.projects);
|
||||
console.log('Stats:', this.stats);
|
||||
alert('原始数据已输出到控制台');
|
||||
},
|
||||
|
||||
// 清除缓存
|
||||
clearCache() {
|
||||
if (confirm('确定要清除本地缓存吗?这不会影响Claude的原始数据。')) {
|
||||
this.history = [];
|
||||
this.projects = [];
|
||||
this.displayMessages = [];
|
||||
this.filteredProjects = [];
|
||||
this.selectedProject = null;
|
||||
alert('缓存已清除,请刷新重新加载');
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化函数
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '未知';
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
formatDateGroup(timestamp) {
|
||||
if (!timestamp) return '未知';
|
||||
const date = new Date(timestamp);
|
||||
const today = new Date();
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return '今天';
|
||||
}
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return '昨天';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
},
|
||||
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
getProjectName(path) {
|
||||
if (!path) return '未知项目';
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1] || 'Root';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('Claude 本地历史记录查看器已启动');
|
||||
this.fetchHistory();
|
||||
|
||||
// 自动刷新
|
||||
setInterval(() => {
|
||||
this.fetchHistory();
|
||||
}, 60000); // 每分钟刷新一次
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,638 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude History - Java Backend</title>
|
||||
<!-- 引入 Vue 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #16213e;
|
||||
border-right: 1px solid #2a2a4e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #2a2a4e;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #0f3460;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
background: #0f3460;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: #1e5f8e;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background: #667eea;
|
||||
border-color: #764ba2;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.project-path {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.project-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.message-area {
|
||||
flex: 1;
|
||||
background: #0f3460;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
padding: 15px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #2a2a4e;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
background: #16213e;
|
||||
border: 1px solid #2a2a4e;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #2a2a4e;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #f44336;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a2a4e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #667eea;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="header">
|
||||
<h1>
|
||||
🤖 Claude 本地历史记录 (Java Backend)
|
||||
<div class="connection-status">
|
||||
<span class="status-dot" :class="{ error: !connected }"></span>
|
||||
{{ connectionStatus }}
|
||||
</div>
|
||||
</h1>
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span>📝</span>
|
||||
<span>总消息:</span>
|
||||
<span class="stat-value">{{ stats.totalMessages }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>📁</span>
|
||||
<span>项目数:</span>
|
||||
<span class="stat-value">{{ stats.totalProjects }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>📅</span>
|
||||
<span>最后活动:</span>
|
||||
<span class="stat-value">{{ lastActivityTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="sidebar">
|
||||
<div class="search-section">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索消息..."
|
||||
@input="performSearch"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="project-list">
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<p>暂无项目</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="project in projects"
|
||||
:key="project.path"
|
||||
:class="['project-item', { active: selectedProject === project }]"
|
||||
@click="selectProject(project)"
|
||||
>
|
||||
<div class="project-name">📁 {{ project.name }}</div>
|
||||
<div class="project-path">{{ project.path }}</div>
|
||||
<div class="project-stats">
|
||||
<span>💬 {{ project.count }}</span>
|
||||
<span>{{ formatDate(project.lastAccess) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-area">
|
||||
<div class="message-header">
|
||||
<h3>{{ selectedProject ? selectedProject.name + ' 的消息' : '最近消息' }}</h3>
|
||||
<div style="margin-top: 5px; font-size: 12px; color: #888;">
|
||||
显示 {{ displayMessages.length }} / {{ totalMessages }} 条
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-list">
|
||||
<div v-if="displayMessages.length === 0" class="empty-state">
|
||||
<div class="empty-icon">💭</div>
|
||||
<p>暂无消息</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(msg, index) in displayMessages"
|
||||
:key="index"
|
||||
class="message-item"
|
||||
>
|
||||
<div class="message-content">{{ msg.display }}</div>
|
||||
<div class="message-meta">
|
||||
<span>{{ getProjectName(msg.project) }}</span>
|
||||
<span>{{ formatDateTime(msg.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<button
|
||||
class="btn"
|
||||
@click="loadMore"
|
||||
v-if="hasMore"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px; background: #16213e; border-top: 1px solid #2a2a4e;">
|
||||
<button class="btn" @click="refreshData">🔄 刷新</button>
|
||||
<button class="btn" @click="exportData">📥 导出</button>
|
||||
<button class="btn" @click="clearSelection">❌ 清除选择</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
⚠️ {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
// 数据
|
||||
history: [],
|
||||
projects: [],
|
||||
stats: {
|
||||
totalMessages: 0,
|
||||
totalProjects: 0,
|
||||
firstMessage: null,
|
||||
lastMessage: null
|
||||
},
|
||||
|
||||
// UI 状态
|
||||
loading: false,
|
||||
error: null,
|
||||
connected: false,
|
||||
connectionStatus: '检查中...',
|
||||
searchQuery: '',
|
||||
selectedProject: null,
|
||||
|
||||
// 显示相关
|
||||
displayMessages: [],
|
||||
displayLimit: 50,
|
||||
totalMessages: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lastActivityTime() {
|
||||
if (this.stats.lastMessage) {
|
||||
return this.formatDate(this.stats.lastMessage.timestamp);
|
||||
}
|
||||
return '无';
|
||||
},
|
||||
|
||||
hasMore() {
|
||||
return this.displayMessages.length < this.totalMessages;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 使用 Java 后端获取数据
|
||||
async fetchHistory() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// 检查 ClaudeAPI 是否可用
|
||||
if (typeof window.ClaudeAPI === 'undefined') {
|
||||
// 等待 API 注入
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
if (typeof window.ClaudeAPI === 'undefined') {
|
||||
throw new Error('ClaudeAPI 未初始化,请确保插件正确加载');
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Java 后端 API
|
||||
window.ClaudeAPI.fetchHistory((response) => {
|
||||
if (response.success) {
|
||||
this.history = response.history || [];
|
||||
this.projects = response.projects || [];
|
||||
this.stats = response.stats || this.stats;
|
||||
this.totalMessages = response.total || this.history.length;
|
||||
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
this.connected = true;
|
||||
this.connectionStatus = 'Java Backend';
|
||||
this.loading = false;
|
||||
} else {
|
||||
throw new Error(response.error || '获取数据失败');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
this.error = error.message;
|
||||
this.connected = false;
|
||||
this.connectionStatus = '连接失败';
|
||||
this.loading = false;
|
||||
|
||||
// 使用模拟数据
|
||||
this.useMockData();
|
||||
}
|
||||
},
|
||||
|
||||
// 搜索
|
||||
performSearch() {
|
||||
if (!this.searchQuery) {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.ClaudeAPI && window.ClaudeAPI.search) {
|
||||
window.ClaudeAPI.search(this.searchQuery, (response) => {
|
||||
if (response.success) {
|
||||
this.displayMessages = response.data.results || [];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 本地搜索
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
this.displayMessages = this.history.filter(msg =>
|
||||
msg.display && msg.display.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// 选择项目
|
||||
selectProject(project) {
|
||||
this.selectedProject = project;
|
||||
if (project && project.messages) {
|
||||
this.displayMessages = project.messages.slice(0, this.displayLimit);
|
||||
this.totalMessages = project.messages.length;
|
||||
} else {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
this.totalMessages = this.history.length;
|
||||
}
|
||||
},
|
||||
|
||||
// 清除选择
|
||||
clearSelection() {
|
||||
this.selectedProject = null;
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
this.totalMessages = this.history.length;
|
||||
},
|
||||
|
||||
// 加载更多
|
||||
loadMore() {
|
||||
this.displayLimit += 50;
|
||||
if (this.selectedProject && this.selectedProject.messages) {
|
||||
this.displayMessages = this.selectedProject.messages.slice(0, this.displayLimit);
|
||||
} else {
|
||||
this.displayMessages = this.history.slice(0, this.displayLimit);
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新数据
|
||||
refreshData() {
|
||||
this.displayLimit = 50;
|
||||
this.fetchHistory();
|
||||
},
|
||||
|
||||
// 导出数据
|
||||
exportData() {
|
||||
const dataStr = JSON.stringify({
|
||||
history: this.history,
|
||||
projects: this.projects,
|
||||
stats: this.stats,
|
||||
exportDate: new Date().toISOString()
|
||||
}, null, 2);
|
||||
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `claude-history-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// 使用模拟数据
|
||||
useMockData() {
|
||||
this.history = [
|
||||
{ display: '示例消息 1', timestamp: Date.now(), project: '/example/project1' },
|
||||
{ display: '示例消息 2', timestamp: Date.now() - 3600000, project: '/example/project2' },
|
||||
{ display: '无法连接到本地数据,显示示例数据', timestamp: Date.now() - 7200000, project: '/demo' }
|
||||
];
|
||||
this.projects = [
|
||||
{ path: '/example/project1', name: 'Project 1', count: 1, lastAccess: Date.now() },
|
||||
{ path: '/example/project2', name: 'Project 2', count: 1, lastAccess: Date.now() - 3600000 }
|
||||
];
|
||||
this.displayMessages = this.history;
|
||||
this.totalMessages = this.history.length;
|
||||
this.stats = {
|
||||
totalMessages: 3,
|
||||
totalProjects: 2,
|
||||
firstMessage: this.history[2],
|
||||
lastMessage: this.history[0]
|
||||
};
|
||||
},
|
||||
|
||||
// 格式化函数
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '未知';
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
},
|
||||
|
||||
formatDateTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
getProjectName(path) {
|
||||
if (!path) return '未知项目';
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1] || 'Root';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('Claude History Viewer (Java Backend) 已启动');
|
||||
|
||||
// 立即尝试获取数据
|
||||
this.fetchHistory();
|
||||
|
||||
// 定期刷新
|
||||
setInterval(() => {
|
||||
if (this.connected) {
|
||||
this.fetchHistory();
|
||||
}
|
||||
}, 60000); // 每分钟刷新
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,476 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vue Claude Plugin</title>
|
||||
<!-- 引入 Vue 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<!-- 引入 Axios 用于 API 调用 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
animation: fadeIn 1s ease-in;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.claude-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.api-key-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.input-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 12px 30px;
|
||||
font-size: 16px;
|
||||
background: white;
|
||||
color: #764ba2;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: bold;
|
||||
margin: 10px 5px;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.response-section {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.response-section h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px;
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 10px;
|
||||
background: rgba(0, 255, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.vue-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
animation: rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="container">
|
||||
<!-- 标题部分 -->
|
||||
<h1>
|
||||
<svg class="vue-logo" viewBox="0 0 261.76 226.69" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m161.096.001-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/>
|
||||
<path d="m161.096.001-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/>
|
||||
</svg>
|
||||
Vue + Claude 集成插件
|
||||
</h1>
|
||||
|
||||
<!-- Claude API 集成部分 -->
|
||||
<div class="claude-section">
|
||||
<!-- API Key 配置 -->
|
||||
<div class="api-key-section">
|
||||
<div class="input-group">
|
||||
<label for="apiKey">Claude API Key (可选,使用代理则无需填写):</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
v-model="apiKey"
|
||||
placeholder="sk-ant-api03-..."
|
||||
@input="saveApiKey"
|
||||
>
|
||||
</div>
|
||||
<div class="success" v-if="apiKeySaved">
|
||||
API Key 已保存到本地存储
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息输入区 -->
|
||||
<div class="input-group">
|
||||
<label for="userMessage">向 Claude 提问:</label>
|
||||
<textarea
|
||||
id="userMessage"
|
||||
v-model="userMessage"
|
||||
placeholder="例如:请解释一下 Vue 3 的组合式 API..."
|
||||
@keydown.enter.ctrl="sendMessage"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 按钮组 -->
|
||||
<div>
|
||||
<button
|
||||
class="button"
|
||||
@click="sendMessage"
|
||||
:disabled="loading || !userMessage.trim()"
|
||||
>
|
||||
发送消息
|
||||
</button>
|
||||
<button
|
||||
class="button secondary"
|
||||
@click="clearConversation"
|
||||
:disabled="loading"
|
||||
>
|
||||
清除对话
|
||||
</button>
|
||||
<button
|
||||
class="button secondary"
|
||||
@click="testConnection"
|
||||
:disabled="loading"
|
||||
>
|
||||
测试连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="loading" v-if="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>正在与 Claude 通信...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div class="error" v-if="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- 响应显示区 -->
|
||||
<div class="response-section" v-if="conversation.length > 0">
|
||||
<h3>对话历史:</h3>
|
||||
<div v-for="(msg, index) in conversation" :key="index" style="margin-bottom: 15px;">
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">
|
||||
{{ msg.role === 'user' ? '你' : 'Claude' }}:
|
||||
</div>
|
||||
<div class="response-content">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
// Claude API 代理服务器(用于绕过 CORS)
|
||||
const PROXY_URL = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
apiKey: '',
|
||||
apiKeySaved: false,
|
||||
userMessage: '',
|
||||
conversation: [],
|
||||
loading: false,
|
||||
error: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 保存 API Key 到本地存储
|
||||
saveApiKey() {
|
||||
if (this.apiKey) {
|
||||
localStorage.setItem('claude_api_key', this.apiKey);
|
||||
this.apiKeySaved = true;
|
||||
setTimeout(() => {
|
||||
this.apiKeySaved = false;
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
|
||||
// 测试连接
|
||||
async testConnection() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// 简单的测试消息
|
||||
const response = await this.callClaudeAPI('Hello Claude, please respond with "Connection successful!"');
|
||||
|
||||
if (response) {
|
||||
this.error = null;
|
||||
this.conversation = [{
|
||||
role: 'assistant',
|
||||
content: '连接测试成功!Claude API 已就绪。'
|
||||
}];
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = '连接测试失败:' + err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 发送消息到 Claude
|
||||
async sendMessage() {
|
||||
if (!this.userMessage.trim()) return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// 添加用户消息到对话
|
||||
this.conversation.push({
|
||||
role: 'user',
|
||||
content: this.userMessage
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.callClaudeAPI(this.userMessage);
|
||||
|
||||
if (response) {
|
||||
this.conversation.push({
|
||||
role: 'assistant',
|
||||
content: response
|
||||
});
|
||||
}
|
||||
|
||||
// 清空输入框
|
||||
this.userMessage = '';
|
||||
} catch (err) {
|
||||
this.error = '发送失败:' + err.message;
|
||||
// 移除失败的用户消息
|
||||
this.conversation.pop();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 调用 Claude API
|
||||
async callClaudeAPI(message) {
|
||||
const apiKey = this.apiKey || localStorage.getItem('claude_api_key');
|
||||
|
||||
// 如果没有 API Key,使用模拟响应
|
||||
if (!apiKey) {
|
||||
// 模拟 Claude 响应
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return this.getMockResponse(message);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
PROXY_URL,
|
||||
{
|
||||
model: 'claude-3-sonnet-20240229',
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: message
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data && response.data.content && response.data.content[0]) {
|
||||
return response.data.content[0].text;
|
||||
}
|
||||
|
||||
throw new Error('Invalid response format');
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 401) {
|
||||
throw new Error('API Key 无效,请检查您的密钥');
|
||||
} else if (error.response && error.response.status === 429) {
|
||||
throw new Error('请求过于频繁,请稍后再试');
|
||||
} else {
|
||||
// 如果真实 API 调用失败,返回模拟响应
|
||||
console.warn('API 调用失败,使用模拟响应:', error);
|
||||
return this.getMockResponse(message);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 获取模拟响应
|
||||
getMockResponse(message) {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (lowerMessage.includes('hello') || lowerMessage.includes('你好')) {
|
||||
return '你好!我是 Claude,很高兴为您服务。这是一个集成在 IDEA 插件中的演示版本。';
|
||||
} else if (lowerMessage.includes('vue')) {
|
||||
return 'Vue.js 是一个渐进式的 JavaScript 框架,用于构建用户界面。Vue 3 引入了组合式 API,提供了更好的逻辑复用和类型推导能力。';
|
||||
} else if (lowerMessage.includes('test') || lowerMessage.includes('测试')) {
|
||||
return '测试连接成功!Claude API 集成正常工作。';
|
||||
} else if (lowerMessage.includes('help') || lowerMessage.includes('帮助')) {
|
||||
return '我可以回答您关于编程、技术和各种主题的问题。请随时向我提问!';
|
||||
} else {
|
||||
return `我理解您说的:"${message}"。作为演示版本,我的响应是预设的。要获得真实的 Claude 响应,请配置有效的 API Key。`;
|
||||
}
|
||||
},
|
||||
|
||||
// 清除对话
|
||||
clearConversation() {
|
||||
this.conversation = [];
|
||||
this.userMessage = '';
|
||||
this.error = null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('Vue + Claude 应用已成功挂载!');
|
||||
|
||||
// 从本地存储加载 API Key
|
||||
const savedKey = localStorage.getItem('claude_api_key');
|
||||
if (savedKey) {
|
||||
this.apiKey = savedKey;
|
||||
}
|
||||
|
||||
// 显示欢迎消息
|
||||
this.conversation.push({
|
||||
role: 'assistant',
|
||||
content: '欢迎使用 Vue + Claude 集成插件!您可以在上方输入 API Key(可选),然后向我提问。如果没有 API Key,我会提供模拟响应。'
|
||||
});
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,941 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vue Claude Plugin with History</title>
|
||||
<!-- 引入 Vue 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<!-- 引入 Axios 用于 API 调用 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.history-controls {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.history-controls button {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.history-controls button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 15px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-box::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.history-item.active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.history-item-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.history-item-preview {
|
||||
opacity: 0.8;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.history-item-date {
|
||||
opacity: 0.6;
|
||||
font-size: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* 主内容区样式 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 30px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.claude-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.api-key-section {
|
||||
margin-bottom: 15px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.input-group textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
color: #764ba2;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: bold;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: rgba(255, 100, 100, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.response-section {
|
||||
flex: 1;
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 400px);
|
||||
}
|
||||
|
||||
.response-section h3 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.conversation-stats {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
margin-bottom: 12px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 8px;
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 8px;
|
||||
background: rgba(0, 255, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vue-logo {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
animation: rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
/* 文件上传样式 */
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.file-label:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 左侧历史记录侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<h2>对话历史</h2>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<input
|
||||
type="text"
|
||||
class="search-box"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索历史记录..."
|
||||
@input="filterHistory"
|
||||
>
|
||||
|
||||
<!-- 历史记录控制按钮 -->
|
||||
<div class="history-controls">
|
||||
<button @click="createNewSession">
|
||||
➕ 新建对话
|
||||
</button>
|
||||
<button @click="exportAllHistory">
|
||||
📥 导出所有历史
|
||||
</button>
|
||||
<label class="file-label">
|
||||
📤 导入历史
|
||||
<input
|
||||
type="file"
|
||||
class="file-input"
|
||||
accept=".json"
|
||||
@change="importHistory"
|
||||
>
|
||||
</label>
|
||||
<button @click="clearAllHistory" class="button danger">
|
||||
🗑️ 清除所有历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录列表 -->
|
||||
<div class="history-list">
|
||||
<div
|
||||
v-for="session in filteredSessions"
|
||||
:key="session.id"
|
||||
:class="['history-item', { active: currentSessionId === session.id }]"
|
||||
@click="loadSession(session.id)"
|
||||
>
|
||||
<div class="history-item-title">
|
||||
{{ session.title || '未命名对话' }}
|
||||
</div>
|
||||
<div class="history-item-preview">
|
||||
{{ session.preview }}
|
||||
</div>
|
||||
<div class="history-item-date">
|
||||
{{ formatDate(session.timestamp) }}
|
||||
<span style="float: right;">💬 {{ session.messageCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-content">
|
||||
<div class="container">
|
||||
<!-- 标题部分 -->
|
||||
<h1>
|
||||
<svg class="vue-logo" viewBox="0 0 261.76 226.69" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m161.096.001-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/>
|
||||
<path d="m161.096.001-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/>
|
||||
</svg>
|
||||
Vue + Claude 历史记录版
|
||||
</h1>
|
||||
|
||||
<!-- Claude API 集成部分 -->
|
||||
<div class="claude-section">
|
||||
<!-- API Key 配置 -->
|
||||
<div class="api-key-section">
|
||||
<div class="input-group">
|
||||
<label for="apiKey">Claude API Key (可选):</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
v-model="apiKey"
|
||||
placeholder="sk-ant-api03-..."
|
||||
@input="saveApiKey"
|
||||
>
|
||||
</div>
|
||||
<div class="success" v-if="apiKeySaved">
|
||||
API Key 已保存
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息输入区 -->
|
||||
<div class="input-group">
|
||||
<label for="userMessage">向 Claude 提问:</label>
|
||||
<textarea
|
||||
id="userMessage"
|
||||
v-model="userMessage"
|
||||
placeholder="输入你的问题..."
|
||||
@keydown.enter.ctrl="sendMessage"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 按钮组 -->
|
||||
<div>
|
||||
<button
|
||||
class="button"
|
||||
@click="sendMessage"
|
||||
:disabled="loading || !userMessage.trim()"
|
||||
>
|
||||
发送消息
|
||||
</button>
|
||||
<button
|
||||
class="button secondary"
|
||||
@click="clearCurrentSession"
|
||||
:disabled="loading"
|
||||
>
|
||||
清除当前对话
|
||||
</button>
|
||||
<button
|
||||
class="button secondary"
|
||||
@click="exportCurrentSession"
|
||||
:disabled="!conversation.length"
|
||||
>
|
||||
导出当前对话
|
||||
</button>
|
||||
<button
|
||||
class="button secondary"
|
||||
@click="renameSession"
|
||||
>
|
||||
重命名对话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="loading" v-if="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>正在与 Claude 通信...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div class="error" v-if="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- 响应显示区 -->
|
||||
<div class="response-section" v-if="conversation.length > 0">
|
||||
<h3>当前对话</h3>
|
||||
<div class="conversation-stats">
|
||||
共 {{ conversation.length }} 条消息 |
|
||||
会话ID: {{ currentSessionId }} |
|
||||
开始时间: {{ formatDate(currentSessionTimestamp) }}
|
||||
</div>
|
||||
<div v-for="(msg, index) in conversation" :key="index" class="message-wrapper">
|
||||
<div class="message-role">
|
||||
{{ msg.role === 'user' ? '👤 你' : '🤖 Claude' }}:
|
||||
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="response-content">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
// Claude API 配置
|
||||
const PROXY_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const STORAGE_KEY = 'claude_history';
|
||||
const API_KEY_STORAGE = 'claude_api_key';
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
// API 相关
|
||||
apiKey: '',
|
||||
apiKeySaved: false,
|
||||
|
||||
// 当前对话
|
||||
userMessage: '',
|
||||
conversation: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// 会话管理
|
||||
currentSessionId: null,
|
||||
currentSessionTimestamp: null,
|
||||
sessions: [],
|
||||
|
||||
// 搜索和过滤
|
||||
searchQuery: '',
|
||||
filteredSessions: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 生成唯一ID
|
||||
generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
},
|
||||
|
||||
// 格式化日期
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// 格式化时间
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
return `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// 创建新会话
|
||||
createNewSession() {
|
||||
this.saveCurrentSession();
|
||||
this.currentSessionId = this.generateId();
|
||||
this.currentSessionTimestamp = Date.now();
|
||||
this.conversation = [];
|
||||
this.error = null;
|
||||
},
|
||||
|
||||
// 加载会话
|
||||
loadSession(sessionId) {
|
||||
this.saveCurrentSession();
|
||||
const session = this.sessions.find(s => s.id === sessionId);
|
||||
if (session) {
|
||||
this.currentSessionId = session.id;
|
||||
this.currentSessionTimestamp = session.timestamp;
|
||||
this.conversation = session.messages || [];
|
||||
this.error = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 保存当前会话
|
||||
saveCurrentSession() {
|
||||
if (!this.currentSessionId || this.conversation.length === 0) return;
|
||||
|
||||
const sessionIndex = this.sessions.findIndex(s => s.id === this.currentSessionId);
|
||||
const sessionData = {
|
||||
id: this.currentSessionId,
|
||||
timestamp: this.currentSessionTimestamp,
|
||||
title: this.getSessionTitle(),
|
||||
preview: this.getSessionPreview(),
|
||||
messages: this.conversation,
|
||||
messageCount: this.conversation.length
|
||||
};
|
||||
|
||||
if (sessionIndex >= 0) {
|
||||
this.sessions[sessionIndex] = sessionData;
|
||||
} else {
|
||||
this.sessions.unshift(sessionData);
|
||||
}
|
||||
|
||||
this.saveToStorage();
|
||||
this.filterHistory();
|
||||
},
|
||||
|
||||
// 获取会话标题
|
||||
getSessionTitle() {
|
||||
if (this.conversation.length > 0) {
|
||||
const firstUserMessage = this.conversation.find(m => m.role === 'user');
|
||||
if (firstUserMessage) {
|
||||
return firstUserMessage.content.substring(0, 30) + (firstUserMessage.content.length > 30 ? '...' : '');
|
||||
}
|
||||
}
|
||||
return '新对话';
|
||||
},
|
||||
|
||||
// 获取会话预览
|
||||
getSessionPreview() {
|
||||
if (this.conversation.length > 1) {
|
||||
const lastMessage = this.conversation[this.conversation.length - 1];
|
||||
return lastMessage.content.substring(0, 50) + (lastMessage.content.length > 50 ? '...' : '');
|
||||
}
|
||||
return '无内容';
|
||||
},
|
||||
|
||||
// 保存到本地存储
|
||||
saveToStorage() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.sessions));
|
||||
},
|
||||
|
||||
// 从本地存储加载
|
||||
loadFromStorage() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
this.sessions = JSON.parse(stored);
|
||||
this.filteredSessions = [...this.sessions];
|
||||
} catch (e) {
|
||||
console.error('加载历史记录失败:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 搜索历史
|
||||
filterHistory() {
|
||||
if (!this.searchQuery) {
|
||||
this.filteredSessions = [...this.sessions];
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
this.filteredSessions = this.sessions.filter(session => {
|
||||
const titleMatch = session.title && session.title.toLowerCase().includes(query);
|
||||
const messagesMatch = session.messages && session.messages.some(msg =>
|
||||
msg.content.toLowerCase().includes(query)
|
||||
);
|
||||
return titleMatch || messagesMatch;
|
||||
});
|
||||
},
|
||||
|
||||
// 导出当前会话
|
||||
exportCurrentSession() {
|
||||
if (!this.conversation.length) return;
|
||||
|
||||
const sessionData = {
|
||||
id: this.currentSessionId,
|
||||
timestamp: this.currentSessionTimestamp,
|
||||
title: this.getSessionTitle(),
|
||||
messages: this.conversation,
|
||||
exportDate: new Date().toISOString()
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(sessionData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `claude-session-${this.currentSessionId}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// 导出所有历史
|
||||
exportAllHistory() {
|
||||
const exportData = {
|
||||
sessions: this.sessions,
|
||||
exportDate: new Date().toISOString(),
|
||||
totalSessions: this.sessions.length,
|
||||
totalMessages: this.sessions.reduce((sum, s) => sum + (s.messageCount || 0), 0)
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `claude-all-history-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// 导入历史
|
||||
async importHistory(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (data.sessions) {
|
||||
// 导入多个会话
|
||||
this.sessions = [...data.sessions, ...this.sessions];
|
||||
} else if (data.messages) {
|
||||
// 导入单个会话
|
||||
this.sessions.unshift(data);
|
||||
}
|
||||
|
||||
this.saveToStorage();
|
||||
this.filterHistory();
|
||||
this.error = null;
|
||||
alert('历史记录导入成功!');
|
||||
} catch (e) {
|
||||
this.error = '导入失败:文件格式错误';
|
||||
console.error('导入失败:', e);
|
||||
}
|
||||
|
||||
// 清除文件输入
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
// 清除所有历史
|
||||
clearAllHistory() {
|
||||
if (confirm('确定要清除所有历史记录吗?此操作不可恢复。')) {
|
||||
this.sessions = [];
|
||||
this.filteredSessions = [];
|
||||
this.conversation = [];
|
||||
this.currentSessionId = null;
|
||||
this.currentSessionTimestamp = null;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
this.createNewSession();
|
||||
}
|
||||
},
|
||||
|
||||
// 清除当前会话
|
||||
clearCurrentSession() {
|
||||
this.conversation = [];
|
||||
this.userMessage = '';
|
||||
this.error = null;
|
||||
},
|
||||
|
||||
// 重命名会话
|
||||
renameSession() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
const newTitle = prompt('请输入新的会话标题:', this.getSessionTitle());
|
||||
if (newTitle) {
|
||||
const session = this.sessions.find(s => s.id === this.currentSessionId);
|
||||
if (session) {
|
||||
session.title = newTitle;
|
||||
this.saveToStorage();
|
||||
this.filterHistory();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 保存 API Key
|
||||
saveApiKey() {
|
||||
if (this.apiKey) {
|
||||
localStorage.setItem(API_KEY_STORAGE, this.apiKey);
|
||||
this.apiKeySaved = true;
|
||||
setTimeout(() => {
|
||||
this.apiKeySaved = false;
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
|
||||
// 发送消息到 Claude
|
||||
async sendMessage() {
|
||||
if (!this.userMessage.trim()) return;
|
||||
|
||||
// 确保有当前会话
|
||||
if (!this.currentSessionId) {
|
||||
this.createNewSession();
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const messageTimestamp = Date.now();
|
||||
|
||||
// 添加用户消息到对话
|
||||
this.conversation.push({
|
||||
role: 'user',
|
||||
content: this.userMessage,
|
||||
timestamp: messageTimestamp
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.callClaudeAPI(this.userMessage);
|
||||
|
||||
if (response) {
|
||||
this.conversation.push({
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// 保存会话
|
||||
this.saveCurrentSession();
|
||||
|
||||
// 清空输入框
|
||||
this.userMessage = '';
|
||||
} catch (err) {
|
||||
this.error = '发送失败:' + err.message;
|
||||
// 移除失败的用户消息
|
||||
this.conversation.pop();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 调用 Claude API
|
||||
async callClaudeAPI(message) {
|
||||
const apiKey = this.apiKey || localStorage.getItem(API_KEY_STORAGE);
|
||||
|
||||
// 如果没有 API Key,使用模拟响应
|
||||
if (!apiKey) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return this.getMockResponse(message);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
PROXY_URL,
|
||||
{
|
||||
model: 'claude-3-sonnet-20240229',
|
||||
max_tokens: 1024,
|
||||
messages: this.conversation.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
}))
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data && response.data.content && response.data.content[0]) {
|
||||
return response.data.content[0].text;
|
||||
}
|
||||
|
||||
throw new Error('Invalid response format');
|
||||
} catch (error) {
|
||||
console.warn('API 调用失败,使用模拟响应:', error);
|
||||
return this.getMockResponse(message);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取模拟响应
|
||||
getMockResponse(message) {
|
||||
const responses = [
|
||||
'这是一个模拟响应。要获得真实的 Claude 响应,请配置有效的 API Key。',
|
||||
'我理解你的问题。作为演示版本,我提供预设的回复。',
|
||||
'很好的问题!在实际使用中,Claude 会提供更详细和个性化的回答。',
|
||||
'感谢你的提问。这个历史记录功能可以保存所有的对话记录。'
|
||||
];
|
||||
return responses[Math.floor(Math.random() * responses.length)] + '\n\n你说的是:"' + message + '"';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('Vue + Claude 历史记录版已挂载!');
|
||||
|
||||
// 加载 API Key
|
||||
const savedKey = localStorage.getItem(API_KEY_STORAGE);
|
||||
if (savedKey) {
|
||||
this.apiKey = savedKey;
|
||||
}
|
||||
|
||||
// 加载历史记录
|
||||
this.loadFromStorage();
|
||||
|
||||
// 创建初始会话
|
||||
this.createNewSession();
|
||||
|
||||
// 显示欢迎消息
|
||||
this.conversation.push({
|
||||
role: 'assistant',
|
||||
content: '欢迎使用增强版 Claude 插件!现在支持完整的历史记录功能:\n\n✅ 保存所有对话历史\n✅ 搜索历史记录\n✅ 导入/导出对话\n✅ 多会话管理\n✅ 会话重命名\n\n你可以在左侧查看和管理所有历史对话。',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 261.76 226.69" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m161.096.001-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/>
|
||||
<path d="m161.096.001-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
Reference in New Issue
Block a user