2025年4月3日18:13:53
commit
61ca638f92
|
|
@ -0,0 +1,107 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.aiclient</groupId>
|
||||
<artifactId>ai-chat-client</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<javafx.version>17.0.2</javafx.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- JavaFX -->
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-fxml</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-web</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-base</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-graphics</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON Processing -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- SQLite for local storage -->
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
<version>3.42.0.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<source>${maven.compiler.source}</source>
|
||||
<target>${maven.compiler.target}</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-maven-plugin</artifactId>
|
||||
<version>0.0.8</version>
|
||||
<configuration>
|
||||
<mainClass>com.aiclient.Main</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>com.aiclient.Main</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# AI 聊天客户端 (类 Deepseek)
|
||||
|
||||
## 项目简介
|
||||
这是一个基于 JavaFX 开发的 AI 聊天客户端,通过 Ollama API 实现与多个 AI 模型的对话功能。本项目旨在提供一个简洁、易用、功能完善的 AI 聊天体验。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 对话管理
|
||||
- 支持多轮对话,保持上下文连贯性
|
||||
- 会话历史记录的保存与加载
|
||||
- 支持创建多个独立会话
|
||||
- 支持会话分组管理
|
||||
|
||||
### 2. AI 模型支持
|
||||
- 选用ollama + spring ai,
|
||||
- 显示模型响应状态和加载进度
|
||||
|
||||
### 3. 用户界面
|
||||
- 现代化的界面设计,支持亮色/暗色主题
|
||||
- 支持代码高亮显示
|
||||
- Markdown 格式渲染
|
||||
- 消息复制
|
||||
- 快捷键支持
|
||||
- 系统提示词(System Prompt)配置
|
||||
|
||||
## 快捷键
|
||||
|
||||
### 对话操作
|
||||
- `Ctrl/Cmd + Enter`: 发送消息
|
||||
- `Ctrl/Cmd + N`: 新建会话
|
||||
- `Ctrl/Cmd + L`: 清空当前会话
|
||||
- `Ctrl/Cmd + C`: 复制选中文本
|
||||
|
||||
### 界面操作
|
||||
- `Ctrl/Cmd + T`: 切换主题(亮色/暗色)
|
||||
|
||||
注:在 macOS 上,`Ctrl` 键对应 `Command(⌘)` 键
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 前端(JavaFX)
|
||||
- 使用 JavaFX 17+ 构建用户界面
|
||||
- 采用 MVVM 架构模式
|
||||
- 使用 CSS 实现主题定制
|
||||
- 使用 WebView 支持 Markdown 渲染
|
||||
|
||||
### 后端集成
|
||||
- 集成 Ollama REST API
|
||||
- 实现异步通信,避免界面卡顿
|
||||
- 支持流式响应,实现打字机效果
|
||||
- 本地数据持久化存储
|
||||
|
||||
## 开发计划
|
||||
|
||||
### 第一阶段(基础功能)
|
||||
1. 搭建基础项目框架
|
||||
2. 实现基本的对话界面
|
||||
3. 完成 Ollama API 集成
|
||||
4. 实现会话管理功能
|
||||
|
||||
### 第二阶段(功能完善)
|
||||
1. 添加主题支持
|
||||
2. 实现代码高亮
|
||||
3. 添加多语言支持
|
||||
4. 完善会话管理功能
|
||||
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 环境要求
|
||||
- Java 17 或更高版本
|
||||
- Ollama 服务已安装并运行
|
||||
- 系统内存建议 8GB 以上
|
||||
|
||||
### 安装步骤
|
||||
1. 下载最新版本安装包
|
||||
2. 运行安装程序
|
||||
3. 配置 Ollama 服务地址
|
||||
4. 开始使用
|
||||
|
||||
### 基本使用
|
||||
1. 创建新会话
|
||||
2. 选择 AI 模型
|
||||
3. 输入问题并发送
|
||||
4. 查看 AI 响应
|
||||
|
||||
## 注意事项
|
||||
- 请确保 Ollama 服务正常运行
|
||||
- 建议定期备份重要对话
|
||||
- 注意网络连接状态
|
||||
|
||||
## 反馈与支持
|
||||
- 问题反馈请提交 Issue
|
||||
- 功能建议欢迎提交 PR
|
||||
- 邮件支持:[待添加]
|
||||
|
||||
## 开源协议
|
||||
MIT License
|
||||
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.aiclient;
|
||||
|
||||
/**
|
||||
* @author zm
|
||||
* @date 2025/4/3 15:48
|
||||
* @version: 1.0
|
||||
*/
|
||||
public class Application {
|
||||
public static void main(String[] args) {
|
||||
javafx.application.Application.launch(Main.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.aiclient;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
import com.aiclient.controller.MainController;
|
||||
|
||||
public class Main extends Application {
|
||||
|
||||
@Override
|
||||
public void start(Stage primaryStage) throws Exception {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/main.fxml"));
|
||||
Parent root = loader.load();
|
||||
Scene scene = new Scene(root, 1200, 800);
|
||||
|
||||
// 设置控制器
|
||||
MainController controller = loader.getController();
|
||||
controller.setScene(scene);
|
||||
|
||||
primaryStage.setTitle("AI Chat Client");
|
||||
primaryStage.setScene(scene);
|
||||
primaryStage.show();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
launch(args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.aiclient.controller;
|
||||
|
||||
import com.aiclient.model.Chat;
|
||||
import javafx.scene.control.ListCell;
|
||||
|
||||
public class ChatListCell extends ListCell<Chat> {
|
||||
@Override
|
||||
protected void updateItem(Chat chat, boolean empty) {
|
||||
super.updateItem(chat, empty);
|
||||
|
||||
if (empty || chat == null) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setText(chat.getTitle());
|
||||
getStyleClass().add("chat-list-cell");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
package com.aiclient.controller;
|
||||
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import com.aiclient.service.ChatService;
|
||||
import com.aiclient.service.MarkdownService;
|
||||
import com.aiclient.service.ThemeService;
|
||||
import com.aiclient.model.Chat;
|
||||
import com.aiclient.model.Message;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyCodeCombination;
|
||||
import javafx.scene.input.KeyCombination;
|
||||
import com.aiclient.service.ShortcutService;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
|
||||
public class MainController {
|
||||
|
||||
@FXML
|
||||
private ListView<Chat> chatListView;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> modelSelector;
|
||||
|
||||
@FXML
|
||||
private VBox chatContainer;
|
||||
|
||||
@FXML
|
||||
private TextArea inputArea;
|
||||
|
||||
private ChatService chatService;
|
||||
private MarkdownService markdownService;
|
||||
private ThemeService themeService;
|
||||
private Chat currentChat;
|
||||
private ShortcutService shortcutService;
|
||||
private Scene scene;
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
chatService = new ChatService();
|
||||
markdownService = new MarkdownService();
|
||||
themeService = new ThemeService();
|
||||
shortcutService = new ShortcutService();
|
||||
|
||||
// 初始化模型选择器
|
||||
modelSelector.getItems().addAll("deepseek-coder:latest");
|
||||
modelSelector.setValue("deepseek-coder:latest");
|
||||
|
||||
// 初始化聊天列表
|
||||
chatListView.setCellFactory(param -> new ChatListCell());
|
||||
chatListView.getSelectionModel().selectedItemProperty().addListener(
|
||||
(observable, oldValue, newValue) -> loadChat(newValue)
|
||||
);
|
||||
|
||||
// 创建初始会话
|
||||
createNewChat();
|
||||
|
||||
// 配置快捷键
|
||||
setupShortcuts();
|
||||
}
|
||||
|
||||
private void setupShortcuts() {
|
||||
// 发送消息 (Ctrl/Cmd + Enter)
|
||||
KeyCombination sendCombination = new KeyCodeCombination(KeyCode.ENTER,
|
||||
KeyCombination.SHORTCUT_DOWN);
|
||||
inputArea.setOnKeyPressed(event -> {
|
||||
if (sendCombination.match(event)) {
|
||||
event.consume();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// 新建会话 (Ctrl/Cmd + N)
|
||||
shortcutService.addShortcut(
|
||||
new KeyCodeCombination(KeyCode.N, KeyCombination.SHORTCUT_DOWN),
|
||||
this::createNewChat
|
||||
);
|
||||
|
||||
// 切换主题 (Ctrl/Cmd + T)
|
||||
shortcutService.addShortcut(
|
||||
new KeyCodeCombination(KeyCode.T, KeyCombination.SHORTCUT_DOWN),
|
||||
this::toggleTheme
|
||||
);
|
||||
|
||||
// 清空当前会话 (Ctrl/Cmd + L)
|
||||
shortcutService.addShortcut(
|
||||
new KeyCodeCombination(KeyCode.L, KeyCombination.SHORTCUT_DOWN),
|
||||
this::clearCurrentChat
|
||||
);
|
||||
|
||||
// 复制选中文本 (Ctrl/Cmd + C)
|
||||
shortcutService.addShortcut(
|
||||
new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN),
|
||||
this::copySelectedText
|
||||
);
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void createNewChat() {
|
||||
Chat newChat = chatService.createNewChat();
|
||||
chatListView.getItems().add(newChat);
|
||||
chatListView.getSelectionModel().select(newChat);
|
||||
currentChat = newChat;
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void sendMessage() {
|
||||
String messageText = inputArea.getText();
|
||||
if (messageText.trim().isEmpty()) return;
|
||||
|
||||
// 禁用输入框和发送按钮
|
||||
inputArea.setDisable(true);
|
||||
|
||||
Message userMessage = new Message("user", messageText);
|
||||
currentChat.addMessage(userMessage);
|
||||
addMessageToChat(userMessage);
|
||||
|
||||
// 清空输入框
|
||||
inputArea.clear();
|
||||
|
||||
// 创建助手消息
|
||||
Message assistantMessage = new Message("assistant", "");
|
||||
currentChat.addMessage(assistantMessage);
|
||||
HBox messageBox = addMessageToChat(assistantMessage);
|
||||
TextArea textArea = ((TextArea) ((VBox) messageBox.getChildren().get(0)).getChildren().get(0));
|
||||
|
||||
StringBuilder contentBuilder = new StringBuilder();
|
||||
|
||||
// 发送到服务器并获取流式响应
|
||||
chatService.sendMessageStream(
|
||||
messageText,
|
||||
modelSelector.getValue(),
|
||||
chunk -> {
|
||||
// 在JavaFX线程中更新UI
|
||||
// contentBuilder.append(chunk);
|
||||
javafx.application.Platform.runLater(() -> {
|
||||
assistantMessage.setContent(contentBuilder.toString());
|
||||
updateMessage(textArea, chunk);
|
||||
});
|
||||
},
|
||||
() -> {
|
||||
// 完成后在JavaFX线程中更新UI
|
||||
javafx.application.Platform.runLater(() -> {
|
||||
inputArea.setDisable(false);
|
||||
inputArea.requestFocus();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private HBox addMessageToChat(Message message) {
|
||||
TextFlow textFlow = new TextFlow();
|
||||
return addMessageToChat(message, textFlow);
|
||||
}
|
||||
|
||||
private HBox addMessageToChat(Message message, TextFlow textFlow) {
|
||||
// 创建一个VBox来包含消息内容
|
||||
VBox messageContent = new VBox(5); // 5是内部间距
|
||||
messageContent.setMaxWidth(800); // 限制最大宽度
|
||||
|
||||
// 创建文本显示区域
|
||||
TextArea textArea = new TextArea(message.getContent());
|
||||
textArea.setWrapText(true);
|
||||
textArea.setEditable(false);
|
||||
textArea.setPrefRowCount(1); // 初始显示1行
|
||||
textArea.setStyle(
|
||||
"-fx-background-color: transparent;" +
|
||||
"-fx-text-fill: " + (themeService.isDarkTheme() ? "white" : "black") + ";" +
|
||||
"-fx-focus-color: transparent;" +
|
||||
"-fx-faint-focus-color: transparent;"
|
||||
);
|
||||
|
||||
// 自动调整高度
|
||||
textArea.textProperty().addListener((obs, old, newText) -> {
|
||||
int lineCount = (int) newText.lines().count();
|
||||
textArea.setPrefRowCount(Math.max(1, lineCount));
|
||||
});
|
||||
|
||||
messageContent.getChildren().add(textArea);
|
||||
|
||||
// 设置消息样式
|
||||
HBox messageBox = new HBox(messageContent);
|
||||
messageBox.getStyleClass().add(message.getRole() + "-message");
|
||||
messageBox.setPadding(new javafx.geometry.Insets(10));
|
||||
|
||||
if ("user".equals(message.getRole())) {
|
||||
messageBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
} else {
|
||||
messageBox.setAlignment(Pos.CENTER_LEFT);
|
||||
}
|
||||
|
||||
chatContainer.getChildren().add(messageBox);
|
||||
return messageBox;
|
||||
}
|
||||
|
||||
private void updateMessage(TextArea textArea, String content) {
|
||||
if (!textArea.getText().isEmpty()){
|
||||
textArea.appendText(content);
|
||||
}else {
|
||||
textArea.setText(content);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void loadChat(Chat chat) {
|
||||
if (chat == null) return;
|
||||
currentChat = chat;
|
||||
chatContainer.getChildren().clear();
|
||||
chat.getMessages().forEach(this::addMessageToChat);
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void toggleTheme() {
|
||||
if (themeService == null) return;
|
||||
themeService.toggleTheme();
|
||||
markdownService.setDarkTheme(themeService.isDarkTheme());
|
||||
reloadMessages();
|
||||
}
|
||||
|
||||
private void reloadMessages() {
|
||||
VBox oldContainer = chatContainer;
|
||||
chatContainer = new VBox(10);
|
||||
oldContainer.getChildren().clear();
|
||||
loadChat(currentChat);
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void clearCurrentChat() {
|
||||
if (currentChat != null) {
|
||||
currentChat.getMessages().clear();
|
||||
chatContainer.getChildren().clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void copySelectedText() {
|
||||
if (scene == null) return;
|
||||
|
||||
// 获取当前焦点节点
|
||||
Node focusedNode = scene.getFocusOwner();
|
||||
String selectedText = null;
|
||||
|
||||
// 根据不同的节点类型获取选中的文本
|
||||
if (focusedNode instanceof TextArea) {
|
||||
TextArea textArea = (TextArea) focusedNode;
|
||||
selectedText = textArea.getSelectedText();
|
||||
} else if (focusedNode instanceof ScrollPane) {
|
||||
// 如果是消息容器,获取其中的文本内容
|
||||
ScrollPane scrollPane = (ScrollPane) focusedNode;
|
||||
Node content = scrollPane.getContent();
|
||||
if (content instanceof TextFlow) {
|
||||
TextFlow textFlow = (TextFlow) content;
|
||||
// 获取文本内容
|
||||
for (Node node : textFlow.getChildren()) {
|
||||
if (node instanceof Text) {
|
||||
Text text = (Text) node;
|
||||
selectedText = text.getText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有文本,复制到剪贴板
|
||||
if (selectedText != null && !selectedText.isEmpty()) {
|
||||
final Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||
final ClipboardContent content = new ClipboardContent();
|
||||
content.putString(selectedText);
|
||||
clipboard.setContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
public void setScene(Scene scene) {
|
||||
if (scene == null) return;
|
||||
this.scene = scene;
|
||||
themeService.setScene(scene);
|
||||
shortcutService.setScene(scene);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.aiclient.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public class Chat {
|
||||
private String id;
|
||||
private String title;
|
||||
private LocalDateTime createdAt;
|
||||
private List<Message> messages = new ArrayList<>();
|
||||
|
||||
public Chat() {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.title = "新会话";
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public List<Message> getMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
public void setMessages(List<Message> messages) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
public void addMessage(Message message) {
|
||||
messages.add(message);
|
||||
if (messages.size() == 1 && message.getRole().equals("user")) {
|
||||
title = message.getContent().length() > 20
|
||||
? message.getContent().substring(0, 20) + "..."
|
||||
: message.getContent();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.aiclient.model;
|
||||
|
||||
public class Message {
|
||||
private final String role;
|
||||
private String content;
|
||||
|
||||
public Message(String role, String content) {
|
||||
this.role = role;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.aiclient.service;
|
||||
|
||||
import com.aiclient.model.Chat;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class ChatService {
|
||||
private final HttpClient client;
|
||||
private final ObjectMapper mapper;
|
||||
private final String BASE_URL = "http://localhost:8083/ai/v1/ollama/redis/chat";
|
||||
|
||||
public ChatService() {
|
||||
this.client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(30))
|
||||
.build();
|
||||
this.mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public Chat createNewChat() {
|
||||
return new Chat();
|
||||
}
|
||||
|
||||
public void sendMessageStream(String message, String model, Consumer<String> onChunk, Runnable onComplete) {
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
String encodedMessage = URLEncoder.encode(message, StandardCharsets.UTF_8);
|
||||
String url = String.format("%s?userId=user&input=%s&stream=true", BASE_URL, encodedMessage);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.GET()
|
||||
.timeout(Duration.ofMinutes(2))
|
||||
.build();
|
||||
|
||||
StringBuilder responseBuilder = new StringBuilder();
|
||||
client.send(request, HttpResponse.BodyHandlers.ofLines())
|
||||
.body()
|
||||
.forEach(line -> {
|
||||
try {
|
||||
// JsonNode jsonNode = mapper.readTree(line);
|
||||
String chunk = line;
|
||||
|
||||
// if (jsonNode.has("data")) {
|
||||
// chunk = jsonNode.get("data").asText();
|
||||
// } else if (jsonNode.has("message")) {
|
||||
// chunk = jsonNode.get("message").asText();
|
||||
// }
|
||||
|
||||
if (chunk != null && !chunk.isEmpty()) {
|
||||
responseBuilder.append(chunk);
|
||||
onChunk.accept(chunk);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
onComplete.run();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
onChunk.accept("发送消息失败: " + e.getMessage());
|
||||
onComplete.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package com.aiclient.service;
|
||||
|
||||
public class MarkdownService {
|
||||
private boolean isDarkTheme = false;
|
||||
|
||||
public void setDarkTheme(boolean dark) {
|
||||
this.isDarkTheme = dark;
|
||||
}
|
||||
|
||||
public boolean isDarkTheme() {
|
||||
return isDarkTheme;
|
||||
}
|
||||
|
||||
public String convertToHtml(String markdown) {
|
||||
if (markdown == null || markdown.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 简单的 Markdown 转换
|
||||
String html = markdown
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\n", "<br>")
|
||||
.replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>") // 粗体
|
||||
.replaceAll("\\*(.+?)\\*", "<em>$1</em>") // 斜体
|
||||
.replaceAll("`([^`]+)`", "<code>$1</code>") // 行内代码
|
||||
.replaceAll("```([\\s\\S]*?)```", "<pre><code>$1</code></pre>"); // 代码块
|
||||
|
||||
return wrapWithStyles(html);
|
||||
}
|
||||
|
||||
private String wrapWithStyles(String html) {
|
||||
String backgroundColor = isDarkTheme ? "#2d2d2d" : "#ffffff";
|
||||
String textColor = isDarkTheme ? "#ffffff" : "#000000";
|
||||
String codeBackground = isDarkTheme ? "#363636" : "#f6f8fa";
|
||||
String borderColor = isDarkTheme ? "#505050" : "#dfe2e5";
|
||||
|
||||
return String.format("""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
background-color: %s;
|
||||
color: %s;
|
||||
}
|
||||
pre {
|
||||
background-color: %s;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
background-color: %s;
|
||||
border-radius: 3px;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid %s;
|
||||
padding: 6px 13px;
|
||||
}
|
||||
th {
|
||||
background-color: %s;
|
||||
}
|
||||
img {
|
||||
max-width: 100%%;
|
||||
}
|
||||
a {
|
||||
color: #0096c9;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid %s;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: %s;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid %s;
|
||||
margin: 16px 0;
|
||||
}
|
||||
ul, ol {
|
||||
padding-left: 2em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
backgroundColor, textColor,
|
||||
codeBackground, codeBackground,
|
||||
borderColor, codeBackground,
|
||||
borderColor, isDarkTheme ? "#cccccc" : "#666666",
|
||||
borderColor, html);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.aiclient.service;
|
||||
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.input.KeyCombination;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ShortcutService {
|
||||
private final Map<KeyCombination, Runnable> shortcuts = new HashMap<>();
|
||||
private Scene scene;
|
||||
|
||||
public void setScene(Scene scene) {
|
||||
this.scene = scene;
|
||||
registerShortcuts();
|
||||
}
|
||||
|
||||
public void addShortcut(KeyCombination combination, Runnable action) {
|
||||
shortcuts.put(combination, action);
|
||||
if (scene != null) {
|
||||
scene.getAccelerators().put(combination, action);
|
||||
}
|
||||
}
|
||||
|
||||
public void registerShortcuts() {
|
||||
if (scene == null) return;
|
||||
|
||||
// 清除现有快捷键
|
||||
scene.getAccelerators().clear();
|
||||
|
||||
// 重新注册所有快捷键
|
||||
shortcuts.forEach((combination, action) ->
|
||||
scene.getAccelerators().put(combination, action)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.aiclient.service;
|
||||
|
||||
import javafx.scene.Scene;
|
||||
import java.util.Objects;
|
||||
|
||||
public class ThemeService {
|
||||
private static final String LIGHT_THEME = "/css/light-theme.css";
|
||||
private static final String DARK_THEME = "/css/dark-theme.css";
|
||||
private static final String BASE_STYLE = "/css/style.css";
|
||||
|
||||
private Scene scene;
|
||||
public boolean isDarkTheme = false;
|
||||
|
||||
public void setScene(Scene scene) {
|
||||
if (scene == null) return;
|
||||
this.scene = scene;
|
||||
applyBaseStyle();
|
||||
applyTheme(isDarkTheme);
|
||||
}
|
||||
|
||||
private void applyBaseStyle() {
|
||||
if (scene == null) return;
|
||||
String baseStyle = Objects.requireNonNull(getClass().getResource(BASE_STYLE)).toExternalForm();
|
||||
if (!scene.getStylesheets().contains(baseStyle)) {
|
||||
scene.getStylesheets().add(baseStyle);
|
||||
}
|
||||
}
|
||||
|
||||
public void toggleTheme() {
|
||||
if (scene == null) return;
|
||||
isDarkTheme = !isDarkTheme;
|
||||
applyTheme(isDarkTheme);
|
||||
}
|
||||
|
||||
private void applyTheme(boolean dark) {
|
||||
if (scene == null) return;
|
||||
|
||||
// 移除现有主题
|
||||
scene.getStylesheets().removeIf(style ->
|
||||
style.endsWith("light-theme.css") || style.endsWith("dark-theme.css")
|
||||
);
|
||||
|
||||
// 应用新主题
|
||||
String theme = dark ? DARK_THEME : LIGHT_THEME;
|
||||
String themeStyle = Objects.requireNonNull(getClass().getResource(theme)).toExternalForm();
|
||||
scene.getStylesheets().add(themeStyle);
|
||||
}
|
||||
|
||||
public boolean isDarkTheme() {
|
||||
return isDarkTheme;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
.root {
|
||||
-fx-background-color: #1e1e1e;
|
||||
-fx-text-fill: #ffffff;
|
||||
}
|
||||
|
||||
.button {
|
||||
-fx-background-color: #3c3c3c;
|
||||
-fx-text-fill: #ffffff;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
-fx-background-color: #505050;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
-fx-background-color: #2d2d2d;
|
||||
-fx-text-fill: #ffffff;
|
||||
-fx-control-inner-background: #2d2d2d;
|
||||
}
|
||||
|
||||
.list-view {
|
||||
-fx-background-color: #2d2d2d;
|
||||
-fx-control-inner-background: #2d2d2d;
|
||||
}
|
||||
|
||||
.list-cell {
|
||||
-fx-background-color: #2d2d2d;
|
||||
-fx-text-fill: #ffffff;
|
||||
}
|
||||
|
||||
.list-cell:selected {
|
||||
-fx-background-color: #0096c9;
|
||||
-fx-text-fill: white;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
-fx-background-color: #264f73;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
-fx-background-color: #363636;
|
||||
}
|
||||
|
||||
.combo-box {
|
||||
-fx-background-color: #2d2d2d;
|
||||
-fx-border-color: #505050;
|
||||
}
|
||||
|
||||
.scroll-pane {
|
||||
-fx-background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.web-view {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
.root {
|
||||
-fx-background-color: #ffffff;
|
||||
-fx-text-fill: #000000;
|
||||
}
|
||||
|
||||
.button {
|
||||
-fx-background-color: #e3e3e3;
|
||||
-fx-text-fill: #000000;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
-fx-background-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
-fx-background-color: #ffffff;
|
||||
-fx-text-fill: #000000;
|
||||
-fx-control-inner-background: #ffffff;
|
||||
}
|
||||
|
||||
.list-view {
|
||||
-fx-background-color: #ffffff;
|
||||
-fx-control-inner-background: #ffffff;
|
||||
}
|
||||
|
||||
.list-cell {
|
||||
-fx-background-color: #ffffff;
|
||||
-fx-text-fill: #000000;
|
||||
}
|
||||
|
||||
.list-cell:selected {
|
||||
-fx-background-color: #0096c9;
|
||||
-fx-text-fill: white;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
-fx-background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
-fx-background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.combo-box {
|
||||
-fx-background-color: #ffffff;
|
||||
-fx-border-color: #cccccc;
|
||||
}
|
||||
|
||||
.scroll-pane {
|
||||
-fx-background-color: #ffffff;
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/* 基础样式 */
|
||||
.root {
|
||||
-fx-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.button {
|
||||
-fx-background-radius: 5;
|
||||
-fx-padding: 8 16;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
-fx-background-insets: 0;
|
||||
-fx-background-radius: 0;
|
||||
-fx-padding: 0;
|
||||
-fx-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-fx-font-size: 14px;
|
||||
}
|
||||
|
||||
.text-area .content {
|
||||
-fx-background-color: transparent;
|
||||
-fx-padding: 0;
|
||||
}
|
||||
|
||||
.text-area:focused {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
||||
.text-area .scroll-pane {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
||||
.text-area .scroll-pane .viewport {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
||||
.text-area .scroll-pane .content {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
||||
.user-message, .assistant-message {
|
||||
-fx-background-radius: 5;
|
||||
-fx-max-width: 800;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
-fx-background-color: #007AFF22;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
-fx-background-color: #36363622;
|
||||
}
|
||||
|
||||
.tool-bar {
|
||||
-fx-background-color: transparent;
|
||||
-fx-padding: 0;
|
||||
}
|
||||
|
||||
.text-area .prompt-text {
|
||||
-fx-text-fill: #888888;
|
||||
}
|
||||
|
||||
.scroll-pane {
|
||||
-fx-background-color: transparent;
|
||||
-fx-padding: 0;
|
||||
}
|
||||
|
||||
.scroll-pane > .viewport {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
||||
.text-flow {
|
||||
-fx-line-spacing: 5;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.scroll-bar:vertical {
|
||||
-fx-pref-width: 12;
|
||||
}
|
||||
|
||||
.scroll-bar:horizontal {
|
||||
-fx-pref-height: 12;
|
||||
}
|
||||
|
||||
.scroll-bar > .thumb {
|
||||
-fx-background-radius: 6;
|
||||
}
|
||||
|
||||
/* 组合框样式 */
|
||||
.combo-box {
|
||||
-fx-background-radius: 5;
|
||||
}
|
||||
|
||||
/* 列表视图样式 */
|
||||
.list-view {
|
||||
-fx-background-radius: 5;
|
||||
-fx-padding: 5;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.geometry.Insets?>
|
||||
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.aiclient.controller.MainController"
|
||||
prefHeight="800.0" prefWidth="1200.0"
|
||||
stylesheets="@../css/style.css">
|
||||
|
||||
<left>
|
||||
<VBox spacing="10" style="-fx-padding: 10;">
|
||||
<ListView fx:id="chatListView" VBox.vgrow="ALWAYS"/>
|
||||
</VBox>
|
||||
</left>
|
||||
|
||||
<center>
|
||||
<VBox spacing="10">
|
||||
<padding>
|
||||
<Insets top="10" right="10" bottom="10" left="10"/>
|
||||
</padding>
|
||||
|
||||
<ToolBar>
|
||||
<HBox spacing="10" alignment="CENTER_LEFT" HBox.hgrow="ALWAYS">
|
||||
<Label text="选择模型:"/>
|
||||
<ComboBox fx:id="modelSelector"/>
|
||||
<Region HBox.hgrow="ALWAYS" />
|
||||
<Button text="新建会话 (Ctrl+N)" onAction="#createNewChat"/>
|
||||
<Button text="切换主题 (Ctrl+T)" onAction="#toggleTheme"/>
|
||||
<Button text="清空会话 (Ctrl+L)" onAction="#clearCurrentChat"/>
|
||||
</HBox>
|
||||
</ToolBar>
|
||||
|
||||
<ScrollPane VBox.vgrow="ALWAYS" fitToWidth="true" styleClass="chat-scroll-pane">
|
||||
<VBox fx:id="chatContainer" spacing="10"/>
|
||||
</ScrollPane>
|
||||
|
||||
<HBox spacing="10">
|
||||
<TextArea fx:id="inputArea" HBox.hgrow="ALWAYS" prefRowCount="3"
|
||||
promptText="输入消息,按 Ctrl+Enter 发送"/>
|
||||
<Button text="发送" onAction="#sendMessage"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</center>
|
||||
|
||||
</BorderPane>
|
||||
Loading…
Reference in New Issue