From 61ca638f929b5c73dd6e94826fc840f11b93bb17 Mon Sep 17 00:00:00 2001 From: qmstyle Date: Thu, 3 Apr 2025 18:14:12 +0800 Subject: [PATCH] =?UTF-8?q?2025=E5=B9=B44=E6=9C=883=E6=97=A518:13:53?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 107 +++++++ readme.md | 99 ++++++ src/main/java/com/aiclient/Application.java | 12 + src/main/java/com/aiclient/Main.java | 30 ++ .../com/aiclient/controller/ChatListCell.java | 19 ++ .../aiclient/controller/MainController.java | 287 ++++++++++++++++++ src/main/java/com/aiclient/model/Chat.java | 65 ++++ src/main/java/com/aiclient/model/Message.java | 23 ++ .../com/aiclient/service/ChatService.java | 76 +++++ .../com/aiclient/service/MarkdownService.java | 113 +++++++ .../com/aiclient/service/ShortcutService.java | 35 +++ .../com/aiclient/service/ThemeService.java | 52 ++++ src/main/resources/css/dark-theme.css | 55 ++++ src/main/resources/css/light-theme.css | 51 ++++ src/main/resources/css/style.css | 97 ++++++ src/main/resources/fxml/main.fxml | 48 +++ 16 files changed, 1169 insertions(+) create mode 100644 pom.xml create mode 100644 readme.md create mode 100644 src/main/java/com/aiclient/Application.java create mode 100644 src/main/java/com/aiclient/Main.java create mode 100644 src/main/java/com/aiclient/controller/ChatListCell.java create mode 100644 src/main/java/com/aiclient/controller/MainController.java create mode 100644 src/main/java/com/aiclient/model/Chat.java create mode 100644 src/main/java/com/aiclient/model/Message.java create mode 100644 src/main/java/com/aiclient/service/ChatService.java create mode 100644 src/main/java/com/aiclient/service/MarkdownService.java create mode 100644 src/main/java/com/aiclient/service/ShortcutService.java create mode 100644 src/main/java/com/aiclient/service/ThemeService.java create mode 100644 src/main/resources/css/dark-theme.css create mode 100644 src/main/resources/css/light-theme.css create mode 100644 src/main/resources/css/style.css create mode 100644 src/main/resources/fxml/main.fxml diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3f89e1c --- /dev/null +++ b/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + com.aiclient + ai-chat-client + 1.0-SNAPSHOT + + + 17 + 17 + 17.0.2 + UTF-8 + + + + + + org.openjfx + javafx-controls + ${javafx.version} + win + + + org.openjfx + javafx-fxml + ${javafx.version} + win + + + org.openjfx + javafx-web + ${javafx.version} + win + + + org.openjfx + javafx-base + ${javafx.version} + win + + + org.openjfx + javafx-graphics + ${javafx.version} + win + + + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + + + + org.xerial + sqlite-jdbc + 3.42.0.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.openjfx + javafx-maven-plugin + 0.0.8 + + com.aiclient.Main + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + com.aiclient.Main + + + + + + + + + \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..459afaf --- /dev/null +++ b/readme.md @@ -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 + diff --git a/src/main/java/com/aiclient/Application.java b/src/main/java/com/aiclient/Application.java new file mode 100644 index 0000000..fb1ba1c --- /dev/null +++ b/src/main/java/com/aiclient/Application.java @@ -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); + } +} diff --git a/src/main/java/com/aiclient/Main.java b/src/main/java/com/aiclient/Main.java new file mode 100644 index 0000000..f3ef890 --- /dev/null +++ b/src/main/java/com/aiclient/Main.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/aiclient/controller/ChatListCell.java b/src/main/java/com/aiclient/controller/ChatListCell.java new file mode 100644 index 0000000..5167027 --- /dev/null +++ b/src/main/java/com/aiclient/controller/ChatListCell.java @@ -0,0 +1,19 @@ +package com.aiclient.controller; + +import com.aiclient.model.Chat; +import javafx.scene.control.ListCell; + +public class ChatListCell extends ListCell { + @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"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/aiclient/controller/MainController.java b/src/main/java/com/aiclient/controller/MainController.java new file mode 100644 index 0000000..0c5ee91 --- /dev/null +++ b/src/main/java/com/aiclient/controller/MainController.java @@ -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 chatListView; + + @FXML + private ComboBox 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/aiclient/model/Chat.java b/src/main/java/com/aiclient/model/Chat.java new file mode 100644 index 0000000..c4531b5 --- /dev/null +++ b/src/main/java/com/aiclient/model/Chat.java @@ -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 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 getMessages() { + return messages; + } + + public void setMessages(List 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/aiclient/model/Message.java b/src/main/java/com/aiclient/model/Message.java new file mode 100644 index 0000000..f0052a2 --- /dev/null +++ b/src/main/java/com/aiclient/model/Message.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/aiclient/service/ChatService.java b/src/main/java/com/aiclient/service/ChatService.java new file mode 100644 index 0000000..4bbd647 --- /dev/null +++ b/src/main/java/com/aiclient/service/ChatService.java @@ -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 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(); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/aiclient/service/MarkdownService.java b/src/main/java/com/aiclient/service/MarkdownService.java new file mode 100644 index 0000000..7621edc --- /dev/null +++ b/src/main/java/com/aiclient/service/MarkdownService.java @@ -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", "
") + .replaceAll("\\*\\*(.+?)\\*\\*", "$1") // 粗体 + .replaceAll("\\*(.+?)\\*", "$1") // 斜体 + .replaceAll("`([^`]+)`", "$1") // 行内代码 + .replaceAll("```([\\s\\S]*?)```", "
$1
"); // 代码块 + + 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(""" + + + + + + + %s + + + """, + backgroundColor, textColor, + codeBackground, codeBackground, + borderColor, codeBackground, + borderColor, isDarkTheme ? "#cccccc" : "#666666", + borderColor, html); + } +} \ No newline at end of file diff --git a/src/main/java/com/aiclient/service/ShortcutService.java b/src/main/java/com/aiclient/service/ShortcutService.java new file mode 100644 index 0000000..9c80864 --- /dev/null +++ b/src/main/java/com/aiclient/service/ShortcutService.java @@ -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 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) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/aiclient/service/ThemeService.java b/src/main/java/com/aiclient/service/ThemeService.java new file mode 100644 index 0000000..10e22d0 --- /dev/null +++ b/src/main/java/com/aiclient/service/ThemeService.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/resources/css/dark-theme.css b/src/main/resources/css/dark-theme.css new file mode 100644 index 0000000..5752ffa --- /dev/null +++ b/src/main/resources/css/dark-theme.css @@ -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; +} \ No newline at end of file diff --git a/src/main/resources/css/light-theme.css b/src/main/resources/css/light-theme.css new file mode 100644 index 0000000..02eac83 --- /dev/null +++ b/src/main/resources/css/light-theme.css @@ -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; +} \ No newline at end of file diff --git a/src/main/resources/css/style.css b/src/main/resources/css/style.css new file mode 100644 index 0000000..4e65733 --- /dev/null +++ b/src/main/resources/css/style.css @@ -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; +} \ No newline at end of file diff --git a/src/main/resources/fxml/main.fxml b/src/main/resources/fxml/main.fxml new file mode 100644 index 0000000..15dc9e9 --- /dev/null +++ b/src/main/resources/fxml/main.fxml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + +
+ + + + + + + +