Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4ab2f5ab7 | |||
| c1e934f2b2 | |||
| 8972dfe8e7 | |||
|
|
1879f5ce32 | ||
| fa013e597e | |||
|
|
eb65197e23 | ||
|
|
9bca7a1dff | ||
| c2a4d60edd | |||
| ca389824a1 | |||
|
|
d907a37c2d | ||
|
|
fd6098df40 | ||
| f5b2bea15a | |||
| 7a5b6680f2 | |||
|
|
4bae331d4f | ||
|
|
4708af1e93 | ||
| fad8c10e69 | |||
|
|
84680a9639 | ||
|
|
9710da8b3d | ||
|
|
8b145d79d3 | ||
| 6ca081afa8 | |||
| d6491ecba4 | |||
|
|
cddbba0e0f | ||
|
|
781a3137a6 | ||
| f944dd680c | |||
|
|
d977da5c38 | ||
|
|
e06bd84f29 | ||
| a351d0ac79 | |||
| 9a91093325 | |||
| a2a3bd2bee | |||
| fb3b072975 | |||
| cc774b717e | |||
| 2ab6dd1050 | |||
| 8c2ea4488b | |||
| 5151379726 | |||
| 0c5ab186da | |||
| 4645bf93ff | |||
| a16d39ef52 | |||
| 95956e5a5b | |||
| ba8cbf564d | |||
| 5607d07586 | |||
|
|
c07f2c8dbe | ||
|
|
669fd4cc6a | ||
|
|
0b11680aea | ||
| 84462f165a | |||
| 2d1fe95be0 | |||
| 67fb44fc73 | |||
| 582b9e10fa |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,6 +6,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
.idea
|
||||
.vite
|
||||
.vscode
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
@@ -45,8 +49,6 @@ typings/
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
@@ -87,6 +89,7 @@ typings/
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
*.timestamp-*.mjs
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
||||
|
||||
@@ -1 +1 @@
|
||||
pnpm exec lint-staged
|
||||
npm exec lint-staged
|
||||
|
||||
4
.idea/electron-all.iml
generated
4
.idea/electron-all.iml
generated
@@ -1,7 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.vite" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
||||
133
.junie/AGENTS.md
Normal file
133
.junie/AGENTS.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Zhiju AI Assistant — Developer Notes
|
||||
|
||||
## Project Overview
|
||||
|
||||
Electron desktop app (Electron Forge + Vite + Vue 3) that wraps a locally-spawned `opencode` binary (located in `resources/`). The renderer is a Vue 3 SPA; the main process manages the `opencode` child process and exposes IPC to the renderer via a preload script.
|
||||
|
||||
---
|
||||
|
||||
## Build & Configuration
|
||||
|
||||
### Prerequisites
|
||||
- Node.js ≥ 18
|
||||
- The `opencode` binary must exist at `resources/windows/x64/opencode.exe` (Windows) before packaging. It is **not** committed to the repo — obtain it separately.
|
||||
|
||||
### Install dependencies
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run in development
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
This uses `electron-forge start`, which runs Vite for the renderer and launches Electron. Hot-reload is active for the renderer; changes to `src/main/index.js` or `src/preload/index.js` require a manual restart.
|
||||
|
||||
### Package / distribute
|
||||
```bash
|
||||
npm run make # builds installers for the current platform
|
||||
npm run package # produces an unpackaged app directory
|
||||
```
|
||||
Packaged output lands in `out/`. The `opencode.exe` binary is bundled via `extraResource` in `forge.config.js` and unpacked from ASAR at runtime.
|
||||
|
||||
### Vite configs
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `vite.main.config.mjs` | Main process bundle |
|
||||
| `vite.preload.config.mjs` | Preload script bundle |
|
||||
| `vite.renderer.config.mjs` | Renderer (Vue SPA), alias `@` → `src/renderer` |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **Main process** (`src/main/index.js`): spawns `opencode` binary, resolves a free port starting at `4096`, waits for TCP readiness, then injects `window.__opencodeBaseUrl` into the renderer via `executeJavaScript`. Also runs Bonjour service discovery.
|
||||
- **Preload** (`src/preload/index.js`): bridges IPC between main and renderer using `contextBridge`.
|
||||
- **Renderer** (`src/renderer/`): Vue 3 + Pinia + Vue Router. HTTP calls go through `src/renderer/http/` (axios-based). SSE streaming is handled in `src/renderer/http/sse.js`.
|
||||
- **URL constants** (`src/renderer/http/url.js`): single source of truth for all API endpoint paths. `getBaseUrl()` reads `window.__opencodeBaseUrl` (injected by main) with fallback to `http://127.0.0.1:4096`.
|
||||
- **Crypto** (`src/renderer/utils/crypto.js`): passwords are Base64-encoded then RSA-encrypted (public key hardcoded) before being sent to the login API.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Framework
|
||||
[Vitest](https://vitest.dev/) — chosen for native Vite/ESM compatibility, zero extra config needed.
|
||||
|
||||
### Run all tests
|
||||
```bash
|
||||
npm test # vitest run (single pass, CI-friendly)
|
||||
npm run test:watch # vitest watch mode (development)
|
||||
```
|
||||
|
||||
Or directly:
|
||||
```bash
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
### Writing tests
|
||||
- Place test files alongside the source file as `*.test.js` (Vitest picks them up automatically).
|
||||
- For pure utility/logic modules (e.g. `url.js`, `crypto.js`) no additional setup is needed.
|
||||
- Modules that depend on `window`, `electron`, or Pinia require mocking. Use `vi.stubGlobal` for `window` properties and `vi.mock` for module mocks.
|
||||
|
||||
### Example: testing `url.js`
|
||||
```js
|
||||
// src/renderer/http/url.test.js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import url from './url.js';
|
||||
|
||||
describe('url constants', () => {
|
||||
it('session.detail returns correct path', () => {
|
||||
expect(url.session.detail('abc123')).toBe('/session/abc123');
|
||||
});
|
||||
|
||||
it('message.send returns correct path', () => {
|
||||
expect(url.message.send('sess1')).toBe('/session/sess1/message');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
npx vitest run src/renderer/http/url.test.js
|
||||
```
|
||||
|
||||
### Testing modules that use `window.__opencodeBaseUrl`
|
||||
```js
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('__opencodeBaseUrl', 'http://127.0.0.1:5000');
|
||||
});
|
||||
|
||||
// import and test getBaseUrl() etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Formatter**: Prettier (config in `package.json` defaults). Run `npm run format` to auto-fix, `npm run format:check` for CI check.
|
||||
- **Commit messages**: enforced by commitlint (`@commitlint/config-conventional`). Use conventional commits: `feat:`, `fix:`, `chore:`, etc.
|
||||
- **Pre-commit hook**: husky + lint-staged runs Prettier on staged `*.js` and `*.vue` files automatically.
|
||||
- **No ESLint** is configured — only Prettier for formatting.
|
||||
- Vue components use the Composition API (`<script setup>` style in newer components, options-style `setup()` returning refs in stores).
|
||||
- Pinia stores use the Setup Store pattern (function returning refs/actions), not the Options Store pattern.
|
||||
- Chinese comments are common throughout the codebase — maintain them in Chinese when editing existing files.
|
||||
|
||||
---
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `electron` v41 | Desktop shell |
|
||||
| `electron-forge` v7 | Build/package toolchain |
|
||||
| `vite` v5 + `@vitejs/plugin-vue` | Renderer bundler |
|
||||
| `vue` v3 + `pinia` + `vue-router` | UI framework |
|
||||
| `axios` | HTTP client (renderer) |
|
||||
| `element-plus` | UI component library |
|
||||
| `bonjour-service` | LAN service discovery |
|
||||
| `jsencrypt` + `js-base64` | Password encryption before login |
|
||||
| `unified` / `remark` / `rehype` | Markdown rendering pipeline |
|
||||
| `katex` | Math formula rendering in chat |
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"printWidth": 160,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
|
||||
99
doc/OpenCode API 总结与对话功能接口分析.md
Normal file
99
doc/OpenCode API 总结与对话功能接口分析.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# OpenCode API 总结与对话功能接口分析
|
||||
|
||||
## 1. API 总结表格
|
||||
|
||||
以下表格总结了 OpenCode 服务器暴露的 API 的用途、传参和返回值格式:
|
||||
|
||||
| 分类 | 方法 | 路径 | 描述 | 请求体/查询参数 | 响应 |
|
||||
|---|---|---|:-:|---|---|
|
||||
| **全局** | `GET` | `/global/health` | 获取服务器健康状态和版本 | 无 | `{ healthy: true, version: string }` |
|
||||
| | `GET` | `/global/event` | 获取全局事件(SSE 流) | 无 | 事件流 |
|
||||
| **项目** | `GET` | `/project` | 列出所有项目 | 无 | `Project[]` |
|
||||
| | `GET` | `/project/current` | 获取当前项目 | 无 | `Project` |
|
||||
| **路径和 VCS** | `GET` | `/path` | 获取当前路径 | 无 | `Path` |
|
||||
| | `GET` | `/vcs` | 获取当前项目的 VCS 信息 | 无 | `VcsInfo` |
|
||||
| **实例** | `POST` | `/instance/dispose` | 销毁当前实例 | 无 | `boolean` |
|
||||
| **配置** | `GET` | `/config` | 获取配置信息 | 无 | `Config` |
|
||||
| | `PATCH` | `/config` | 更新配置 | `Config` | `Config` |
|
||||
| | `GET` | `/config/providers` | 列出提供商和默认模型 | 无 | `{ providers: Provider[], default: { [key: string]: string } }` |
|
||||
| **提供商** | `GET` | `/provider` | 列出所有提供商 | 无 | `{ all: Provider[], default: {...}, connected: string[] }` |
|
||||
| | `GET` | `/provider/auth` | 获取提供商认证方式 | 无 | `{ [providerID: string]: ProviderAuthMethod[] }` |
|
||||
| | `POST` | `/provider/{id}/oauth/authorize` | 使用 OAuth 授权提供商 | 无 | `ProviderAuthAuthorization` |
|
||||
| | `POST` | `/provider/{id}/oauth/callback` | 处理提供商的 OAuth 回调 | 无 | `boolean` |
|
||||
| **会话** | `GET` | `/session` | 列出所有会话 | 无 | `Session[]` |
|
||||
| | `POST` | `/session` | 创建新会话 | `{ parentID?, title? }` | `Session` |
|
||||
| | `GET` | `/session/status` | 获取所有会话的状态 | 无 | `{ [sessionID: string]: SessionStatus }` |
|
||||
| | `GET` | `/session/:id` | 获取会话详情 | 无 | `Session` |
|
||||
| | `DELETE` | `/session/:id` | 删除会话及其所有数据 | 无 | `boolean` |
|
||||
| | `PATCH` | `/session/:id` | 更新会话属性 | `{ title? }` | `Session` |
|
||||
| | `GET` | `/session/:id/children` | 获取会话的子会话 | 无 | `Session[]` |
|
||||
| | `GET` | `/session/:id/todo` | 获取会话的待办事项列表 | 无 | `Todo[]` |
|
||||
| | `POST` | `/session/:id/init` | 分析应用并创建 AGENTS.md | `{ messageID, providerID, modelID }` | `boolean` |
|
||||
| | `POST` | `/session/:id/fork` | 在某条消息处分叉现有会话 | `{ messageID? }` | `Session` |
|
||||
| | `POST` | `/session/:id/abort` | 中止正在运行的会话 | 无 | `boolean` |
|
||||
| | `POST` | `/session/:id/share` | 分享会话 | 无 | `Session` |
|
||||
| | `DELETE` | `/session/:id/share` | 取消分享会话 | 无 | `Session` |
|
||||
| | `GET` | `/session/:id/diff` | 获取本次会话的差异 | `messageID?` | `FileDiff[]` |
|
||||
| | `POST` | `/session/:id/summarize` | 总结会话 | `{ providerID, modelID }` | `boolean` |
|
||||
| | `POST` | `/session/:id/revert` | 回退消息 | `{ messageID, partID? }` | `boolean` |
|
||||
| | `POST` | `/session/:id/unrevert` | 恢复所有已回退的消息 | 无 | `boolean` |
|
||||
| | `POST` | `/session/:id/permissions/:permissionID` | 响应权限请求 | `{ response, remember? }` | `boolean` |
|
||||
| **消息** | `GET` | `/session/:id/message` | 列出会话中的消息 | `limit?` | `{ info: Message, parts: Part[]}[]` |
|
||||
| | `POST` | `/session/:id/message` | 发送消息并等待响应 | `{ messageID?, model?, agent?, noReply?, system?, tools?, parts }` | `{ info: Message, parts: Part[]}` |
|
||||
| | `GET` | `/session/:id/message/:messageID` | 获取消息详情 | 无 | `{ info: Message, parts: Part[]}` |
|
||||
| | `POST` | `/session/:id/prompt_async` | 异步发送消息(不等待响应) | 与 `/session/:id/message` 相同 | `204 No Content` |
|
||||
| | `POST` | `/session/:id/command` | 执行斜杠命令 | `{ messageID?, agent?, model?, command, arguments }` | `{ info: Message, parts: Part[]}` |
|
||||
| | `POST` | `/session/:id/shell` | 运行 shell 命令 | `{ agent, model?, command }` | `{ info: Message, parts: Part[]}` |
|
||||
| **命令** | `GET` | `/command` | 列出所有命令 | 无 | `Command[]` |
|
||||
| **文件** | `GET` | `/find?pattern=<pat>` | 在文件中搜索文本 | `pattern` | 匹配对象数组 |
|
||||
| | `GET` | `/find/file?query=<q>` | 按名称查找文件和目录 | `query` | `string[]`(路径) |
|
||||
| | `GET` | `/find/symbol?query=<q>` | 查找工作区符号 | `query` | `Symbol[]` |
|
||||
| | `GET` | `/file?path=<path>` | 列出文件和目录 | `path` | `FileNode[]` |
|
||||
| | `GET` | `/file/content?path=<p>` | 读取文件 | `path` | `FileContent` |
|
||||
| | `GET` | `/file/status` | 获取已跟踪文件的状态 | 无 | `File[]` |
|
||||
| **工具(实验性)** | `GET` | `/experimental/tool/ids` | 列出所有工具 ID | 无 | `ToolIDs` |
|
||||
| | `GET` | `/experimental/tool?provider=<p>&model=<m>` | 列出指定模型的工具及其 JSON Schema | `provider, model` | `ToolList` |
|
||||
| **LSP、格式化器和 MCP** | `GET` | `/lsp` | 获取 LSP 服务器状态 | 无 | `LSPStatus[]` |
|
||||
| | `GET` | `/formatter` | 获取格式化器状态 | 无 | `FormatterStatus[]` |
|
||||
| | `GET` | `/mcp` | 获取 MCP 服务器状态 | 无 | `{ [name: string]: MCPStatus }` |
|
||||
| | `POST` | `/mcp` | 动态添加 MCP 服务器 | `{ name, config }` | MCP 状态对象 |
|
||||
| **代理** | `GET` | `/agent` | 列出所有可用的代理 | 无 | `Agent[]` |
|
||||
| **日志** | `POST` | `/log` | 写入日志条目 | `{ service, level, message, extra? }` | `boolean` |
|
||||
| **TUI** | `POST` | `/tui/append-prompt` | 向提示词追加文本 | 无 | `boolean` |
|
||||
| | `POST` | `/tui/open-help` | 打开帮助对话框 | 无 | `boolean` |
|
||||
| | `POST` | `/tui/open-sessions` | 打开会话选择器 | 无 | `boolean` |
|
||||
| | `POST` | `/tui/open-themes` | 打开主题选择器 | 无 | `boolean` |
|
||||
| | `POST` | `/tui/open-models` | 打开模型选择器 | 无 | `boolean` |
|
||||
| | `POST` | `/tui/submit-prompt` | 提交当前提示词 | 无 | `boolean` |
|
||||
| | `POST` | `/tui/clear-prompt` | 清除提示词 | 无 | `boolean` |
|
||||
| | `POST` | `/tui/execute-command` | 执行命令 | `{ command }` | `boolean` |
|
||||
| | `POST` | `/tui/show-toast` | 显示提示消息 | `{ title?, message, variant }` | `boolean` |
|
||||
| | `GET` | `/tui/control/next` | 等待下一个控制请求 | 无 | 控制请求对象 |
|
||||
| | `POST` | `/tui/control/response` | 响应控制请求 | `{ body }` | `boolean` |
|
||||
| **认证** | `PUT` | `/auth/:id` | 设置认证凭据 | 必须匹配提供商的数据结构 | `boolean` |
|
||||
| **事件** | `GET` | `/event` | 服务器发送事件流 | 无 | 服务器发送事件流 |
|
||||
| **文档** | `GET` | `/doc` | OpenAPI 3.1 规范 | 无 | 包含 OpenAPI 规范的 HTML 页面 |
|
||||
|
||||
## 2. 对话功能接口分析
|
||||
|
||||
根据您的需求,实现对话功能,最核心的接口是 **消息 (Message)** 类别下的 API。
|
||||
|
||||
具体来说,您应该使用以下接口:
|
||||
|
||||
* **`POST /session/:id/message`**: 这个接口用于**发送消息并等待响应**。这是实现对话功能最主要的接口,您可以通过它向 OpenCode 服务器发送用户输入的消息,并获取服务器返回的对话响应。请求体中可以包含 `messageID` (可选), `model` (可选), `agent` (可选), `noReply` (可选), `system` (可选), `tools` (可选), `parts` 等参数,返回 `{ info: Message, parts: Part[]}`,其中 `info` 包含消息的元数据,`parts` 包含消息的具体内容。
|
||||
|
||||
* **`GET /session/:id/message`**: 这个接口用于**列出会话中的消息**。在对话过程中,您可能需要获取历史消息记录以展示给用户或进行上下文管理。通过 `limit?` 查询参数可以限制返回的消息数量,返回 `{ info: Message, parts: Part[]}[]`。
|
||||
|
||||
* **`GET /session/:id/message/:messageID`**: 这个接口用于**获取消息详情**。如果您需要获取特定消息的详细信息,可以使用此接口,返回 `{ info: Message, parts: Part[]}`。
|
||||
|
||||
* **`POST /session/:id/prompt_async`**: 这个接口用于**异步发送消息(不等待响应)**。如果您希望发送消息后立即返回,不等待服务器的响应,可以使用此接口。这在某些场景下可能有助于提高用户体验,例如在后台处理耗时操作时。请求体与 `POST /session/:id/message` 相同,返回 `204 No Content`。
|
||||
|
||||
除了上述消息相关的接口,**会话 (Session)** 类别下的接口也至关重要,特别是:
|
||||
|
||||
* **`POST /session`**: 用于**创建新会话**。在开始新的对话之前,您需要创建一个会话。请求体可以包含 `parentID?` (可选) 和 `title?` (可选),返回 `Session` 对象。
|
||||
|
||||
* **`GET /session`**: 用于**列出所有会话**。您可以获取当前用户的所有会话列表,以便用户选择或管理历史对话。
|
||||
|
||||
**总结:**
|
||||
|
||||
要实现对话功能,您首先需要通过 `POST /session` 创建一个会话,然后使用 `POST /session/:id/message` 发送用户消息并接收响应。同时,您可以使用 `GET /session/:id/message` 来获取历史消息,以维护对话上下文。`POST /session/:id/prompt_async` 可以用于异步发送消息,提升用户体验。
|
||||
149
doc/OpenCode 对话功能实现路径.md
Normal file
149
doc/OpenCode 对话功能实现路径.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# OpenCode 对话功能实现路径
|
||||
|
||||
本文档旨在提供一个在 OpenCode 平台中实现对话功能的完整技术路径,涵盖会话管理、消息发送以及利用事件流(SSE)进行流式渲染的详细步骤和建议。
|
||||
|
||||
## 1. 核心概念回顾
|
||||
|
||||
在 OpenCode 中实现对话功能,主要依赖以下几个核心接口:
|
||||
|
||||
* **`POST /session`**: 用于创建新的对话会话,获取 `sessionID`。
|
||||
* **`POST /session/:id/prompt_async`**: 用于异步发送用户消息到指定会话,服务器会立即返回,不等待 AI 响应完成。
|
||||
* **`GET /event`**: 作为 SSE(Server-Sent Events)接口,用于实时接收服务器产生的各类事件,包括 AI 消息的生成和更新。
|
||||
* **`GET /session/:id/message`**: 用于获取指定会话的历史消息记录。
|
||||
|
||||
## 2. 对话生命周期与接口调用顺序
|
||||
|
||||
以下是实现一个完整的流式对话功能的推荐流程:
|
||||
|
||||
### 步骤 1: 初始化 - 创建会话
|
||||
|
||||
在用户开始对话之前,需要为其创建一个新的会话。每个会话都有一个唯一的 `sessionID`,用于标识和管理该对话的上下文。
|
||||
|
||||
* **接口**: `POST /session`
|
||||
* **用途**: 启动一个新的对话。
|
||||
* **请求体示例**: `{
|
||||
"title": "我的新对话"
|
||||
}` (标题可选)
|
||||
* **响应**: 返回一个 `Session` 对象,其中包含 `id` 字段,即 `sessionID`。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "some-session-id",
|
||||
"title": "我的新对话",
|
||||
"createdAt": "2023-01-01T12:00:00Z",
|
||||
"updatedAt": "2023-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2: 建立事件监听
|
||||
|
||||
为了实现 AI 响应的流式渲染,客户端需要连接到 `/event` 接口,持续监听服务器发出的事件。这将允许您实时接收 AI 生成的文本片段。
|
||||
|
||||
* **接口**: `GET /event`
|
||||
* **用途**: 接收服务器发送的实时事件流(SSE)。
|
||||
* **机制**: 客户端建立一个持久的 HTTP 连接,服务器通过此连接推送事件。您需要一个支持 SSE 的客户端库来处理这个连接。
|
||||
* **过滤**: 客户端需要根据 `sessionID` 和 `messageID`(稍后从 `prompt_async` 的响应中获取或从事件流中识别)来过滤和处理相关事件。
|
||||
|
||||
**伪代码示例 (JavaScript)**:
|
||||
|
||||
```javascript
|
||||
const eventSource = new EventSource('/event');
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
// 根据事件类型和 sessionID 过滤事件
|
||||
if (data.type === 'message.updated' && data.sessionID === currentSessionId) {
|
||||
// 处理消息更新事件,例如追加 AI 生成的文本片段到 UI
|
||||
console.log('Received message update:', data.message.parts);
|
||||
// 假设 data.message.parts[0].text 包含最新文本
|
||||
// updateUIWithStreamingText(data.message.parts[0].text);
|
||||
}
|
||||
// 其他事件处理,例如 message.created, session.updated 等
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('EventSource failed:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
```
|
||||
|
||||
### 步骤 3: 发送用户消息
|
||||
|
||||
当用户输入消息后,通过 `prompt_async` 接口将其发送到服务器。这个接口会立即返回,不会阻塞客户端,让流式渲染可以同时进行。
|
||||
|
||||
* **接口**: `POST /session/:id/prompt_async`
|
||||
* **用途**: 异步发送用户消息,触发 AI 响应生成。
|
||||
* **路径参数**: `:id` 为当前会话的 `sessionID`。
|
||||
* **请求体示例**: `{
|
||||
"parts": [
|
||||
{ "text": "你好,OpenCode!" }
|
||||
],
|
||||
"model": "your-model-id", // 可选,指定使用的模型
|
||||
"agent": "your-agent-id" // 可选,指定使用的代理
|
||||
}`
|
||||
* **响应**: `204 No Content`。这意味着服务器已接收请求并开始处理,但不会立即返回 AI 的完整响应。
|
||||
|
||||
**伪代码示例 (JavaScript)**:
|
||||
|
||||
```javascript
|
||||
async function sendUserMessage(sessionId, messageText) {
|
||||
const response = await fetch(`/session/${sessionId}/prompt_async`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
parts: [{ text: messageText }]
|
||||
})
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
console.log('User message sent successfully, awaiting streaming response via /event.');
|
||||
// 此时前端可以显示用户消息,并准备接收 AI 的流式响应
|
||||
} else {
|
||||
console.error('Failed to send user message:', response.statusText);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 4: 处理流式响应与渲染
|
||||
|
||||
在步骤 2 中建立的 `/event` 监听器会接收到 AI 生成消息的实时更新。您需要解析这些事件,并将 AI 生成的文本片段逐步显示在用户界面上。
|
||||
|
||||
* **事件类型**: 主要关注 `message.updated` 事件,其 `message` 字段会包含 `parts` 数组,其中包含 AI 正在生成的文本。
|
||||
* **渲染逻辑**: 每次收到新的文本片段时,将其追加到 AI 消息的显示区域,而不是替换。
|
||||
* **完成标志**: 当 AI 消息生成完成时,通常会有一个特定的事件或 `message.updated` 事件中的状态标志来指示。例如,当 `message.updated` 事件中的 `message.status` 变为 `completed` 或 `final` 时,表示流式输出结束。
|
||||
|
||||
### 步骤 5: 获取历史消息 (可选)
|
||||
|
||||
如果用户重新加载页面或需要查看之前的对话记录,可以使用此接口获取会话的所有历史消息。
|
||||
|
||||
* **接口**: `GET /session/:id/message`
|
||||
* **用途**: 获取指定会话的所有消息。
|
||||
* **路径参数**: `:id` 为当前会话的 `sessionID`。
|
||||
* **查询参数**: `limit?` (可选) 用于限制返回的消息数量。
|
||||
* **响应**: 返回一个消息数组,每个元素包含 `info: Message` 和 `parts: Part[]`。
|
||||
|
||||
## 3. 完整流程图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户打开应用] --> B{是否已有会话?}
|
||||
B -- 否 --> C[调用 POST /session]
|
||||
C --> D[获取 sessionID]
|
||||
B -- 是 --> D[使用现有 sessionID]
|
||||
D --> E[建立 GET /event SSE 连接]
|
||||
E --> F[用户输入消息]
|
||||
F --> G[调用 POST /session/:id/prompt_async]
|
||||
G --> H[前端显示用户消息]
|
||||
H --> I[通过 SSE 接收 message.updated 事件]
|
||||
I -- 文本片段 --> J[实时渲染 AI 响应]
|
||||
I -- 消息完成 --> K[AI 响应渲染完成]
|
||||
K --> F
|
||||
subgraph 历史消息
|
||||
L[用户请求历史消息] --> M[调用 GET /session/:id/message]
|
||||
M --> N[显示历史消息]
|
||||
end
|
||||
```
|
||||
|
||||
## 4. 总结
|
||||
|
||||
在 OpenCode 中实现流式对话功能,关键在于**分离消息发送和消息接收**。通过 `POST /session/:id/prompt_async` 异步发送消息,并通过 `GET /event` 实时监听服务器的总线事件来获取 AI 生成的文本片段,从而实现流畅的流式问答体验。`GET /event` 接口是您实现流式渲染的正确选择,因为它提供了所有会话相关的实时更新,包括 AI 消息的逐字生成过程。
|
||||
@@ -1,8 +0,0 @@
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
margin: auto;
|
||||
max-width: 38rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://*:* ws://*:*; img-src 'self' data: blob:" />
|
||||
<title>My App</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
23
jsconfig.json
Normal file
23
jsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/renderer/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"jsx": "preserve",
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"typeRoots": ["./node_modules/@types", "./node_modules"]
|
||||
},
|
||||
"include": ["src/**/*", "*.config.mjs", "*.config.js"],
|
||||
"exclude": ["node_modules", "dist", ".vite"]
|
||||
}
|
||||
4240
package-lock.json
generated
4240
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "my-app",
|
||||
"productName": "my-app",
|
||||
"name": "Zhiju",
|
||||
"productName": "Zhiju",
|
||||
"version": "1.0.0",
|
||||
"description": "My Electron application description",
|
||||
"description": "Zhiju Ai Assistant",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -13,10 +13,12 @@
|
||||
"lint": "echo \"No linting configured\"",
|
||||
"prepare": "husky",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "houakang",
|
||||
"author": "zhiju.com.cn",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
@@ -30,29 +32,43 @@
|
||||
"@electron-forge/plugin-fuses": "^7.11.1",
|
||||
"@electron-forge/plugin-vite": "^7.11.1",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"electron": "^41.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"postcss": "^8.5.9",
|
||||
"prettier": "3.8.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^5.4.21"
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^5.4.21",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"await-to-js": "^3.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bonjour-service": "^1.3.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"element-plus": "^2.13.6",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"js-base64": "3.7.5",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pdfkit": "^0.18.0",
|
||||
"pinia": "^3.0.4",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
resources/darwin/arm64/opencode
Normal file
BIN
resources/darwin/arm64/opencode
Normal file
Binary file not shown.
BIN
resources/darwin/x64/opencode
Normal file
BIN
resources/darwin/x64/opencode
Normal file
Binary file not shown.
56
src/main.js
56
src/main.js
@@ -1,56 +0,0 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import path from 'node:path';
|
||||
import started from 'electron-squirrel-startup';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
const createWindow = () => {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
});
|
||||
|
||||
// and load the index.html of the app.
|
||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
|
||||
}
|
||||
|
||||
// Open the DevTools.
|
||||
mainWindow.webContents.openDevTools();
|
||||
};
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and import them here.
|
||||
@@ -1,12 +1,47 @@
|
||||
import { app, BrowserWindow, shell, ipcMain } from 'electron';
|
||||
import { app, BrowserWindow, shell, ipcMain, Menu } from 'electron';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
import started from 'electron-squirrel-startup';
|
||||
import { Bonjour } from 'bonjour-service';
|
||||
|
||||
if (started) app.quit();
|
||||
|
||||
// ========== Bonjour 服务发现 ==========
|
||||
const bonjour = new Bonjour();
|
||||
let discoveredServices = new Map();
|
||||
|
||||
function getDiscoveredServices() {
|
||||
return Array.from(discoveredServices.values());
|
||||
}
|
||||
|
||||
function startBonjourDiscovery() {
|
||||
const browser = bonjour.find({});
|
||||
|
||||
browser.on('up', (service) => {
|
||||
console.log('[bonjour] Service up:', service.name);
|
||||
discoveredServices.set(service.fqdn, service);
|
||||
notifyServicesChanged();
|
||||
});
|
||||
|
||||
browser.on('down', (service) => {
|
||||
console.log('[bonjour] Service down:', service.name);
|
||||
discoveredServices.delete(service.fqdn);
|
||||
notifyServicesChanged();
|
||||
});
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
function notifyServicesChanged() {
|
||||
const services = getDiscoveredServices();
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.webContents.send('bonjour:services-updated', services);
|
||||
});
|
||||
}
|
||||
|
||||
// ========== OpenCode 服务管理 ==========
|
||||
const DEFAULT_PORT = 4096;
|
||||
let opencodeProcess = null;
|
||||
@@ -36,7 +71,10 @@ function waitForReady(port, timeout = 15000) {
|
||||
const start = Date.now();
|
||||
const check = () => {
|
||||
const socket = net.createConnection({ port, host: '127.0.0.1' });
|
||||
socket.once('connect', () => { socket.end(); resolve(); });
|
||||
socket.once('connect', () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
if (Date.now() - start >= timeout) return reject(new Error('OpenCode 服务启动超时'));
|
||||
@@ -66,12 +104,18 @@ function buildEnv(exeDir) {
|
||||
}
|
||||
|
||||
function getExePath() {
|
||||
// 开发模式:__dirname = .vite/build,往上两级到项目根
|
||||
// 打包模式:用 process.resourcesPath
|
||||
// 根据平台和架构确定可执行文件名及目录
|
||||
const isWin = process.platform === 'win32';
|
||||
const exeName = isWin ? 'opencode.exe' : 'opencode';
|
||||
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'opencode.exe');
|
||||
return path.join(process.resourcesPath, exeName);
|
||||
}
|
||||
return path.join(__dirname, '..', '..', 'resources', 'windows', 'x64', 'opencode.exe');
|
||||
|
||||
// 开发模式:__dirname = .vite/build,往上两级到项目根
|
||||
const platformDir = process.platform === 'darwin' ? 'darwin' : 'windows';
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64';
|
||||
return path.join(__dirname, '..', '..', 'resources', platformDir, archDir, exeName);
|
||||
}
|
||||
|
||||
async function startOpencode() {
|
||||
@@ -84,11 +128,16 @@ async function startOpencode() {
|
||||
const exeDir = path.dirname(exePath);
|
||||
await fs.promises.access(exePath, fs.constants.F_OK);
|
||||
|
||||
// macOS/Linux 需要确保可执行权限
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(exePath, 0o755);
|
||||
}
|
||||
|
||||
opencodePort = await resolvePort();
|
||||
opencodeProcess = spawn(exePath, ['serve', '--port', String(opencodePort)], {
|
||||
cwd: exeDir,
|
||||
windowsHide: true,
|
||||
env: buildEnv(exeDir),
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {}),
|
||||
});
|
||||
|
||||
opencodeProcess.stdout?.on('data', (d) => console.log(`[opencode] ${d.toString().trim()}`));
|
||||
@@ -126,7 +175,10 @@ function stopOpencode() {
|
||||
// ========== IPC Handlers ==========
|
||||
function registerIpcHandlers() {
|
||||
ipcMain.handle('opencode:start', () => startOpencode());
|
||||
ipcMain.handle('opencode:stop', () => { stopOpencode(); return buildInfo(); });
|
||||
ipcMain.handle('opencode:stop', () => {
|
||||
stopOpencode();
|
||||
return buildInfo();
|
||||
});
|
||||
ipcMain.handle('opencode:info', () => buildInfo());
|
||||
ipcMain.handle('opencode:port', () => opencodePort);
|
||||
|
||||
@@ -158,13 +210,91 @@ function registerIpcHandlers() {
|
||||
if (!res.ok) throw new Error(`发送消息失败: ${res.status}`);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
ipcMain.handle('opencode:session:prompt-async', async (_e, sessionId, text) => {
|
||||
if (!opencodePort) throw new Error('OpenCode 服务未启动');
|
||||
const res = await fetch(`http://127.0.0.1:${opencodePort}/session/${sessionId}/prompt_async`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parts: [{ type: 'text', text }] }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`发送消息失败: ${res.status}`);
|
||||
// 204 No Content,无需解析响应体
|
||||
return true;
|
||||
});
|
||||
|
||||
// Bonjour
|
||||
ipcMain.handle('bonjour:get-services', () => getDiscoveredServices());
|
||||
|
||||
// opencode 配置写入
|
||||
ipcMain.handle('opencode:write-config', async (_e, { modelInfo, deviceHost, devicePort }) => {
|
||||
const configDir = path.join(os.homedir(), '.config', 'opencode');
|
||||
const configPath = path.join(configDir, 'opencode.json');
|
||||
|
||||
await fs.promises.mkdir(configDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
provider: {
|
||||
zhiju: {
|
||||
name: 'Zhiju AI',
|
||||
env: ['ZHIJU_API_KEY'],
|
||||
options: {
|
||||
baseURL: `http://${deviceHost}:${modelInfo.port}/v1`,
|
||||
apiKey: `${modelInfo.apiKey}`,
|
||||
},
|
||||
models: {
|
||||
[modelInfo.model_name]: {
|
||||
name: modelInfo.model_name,
|
||||
family: 'openai',
|
||||
status: modelInfo.status || 'beta',
|
||||
capabilities: modelInfo.capabilities || {
|
||||
reasoning: false,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
},
|
||||
limit: modelInfo.limit || { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: `zhiju/${modelInfo.model_name}`,
|
||||
};
|
||||
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
console.log('[opencode] config written to:', configPath);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 窗口控制
|
||||
ipcMain.on('window:minimize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
win?.minimize();
|
||||
});
|
||||
ipcMain.on('window:maximize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win?.isMaximized()) {
|
||||
win.unmaximize();
|
||||
} else {
|
||||
win?.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.on('window:close', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
win?.close();
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 窗口 ==========
|
||||
const createWindow = () => {
|
||||
// 移除菜单栏,保留窗口边框和原生按钮
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
width: 1600,
|
||||
height: 1000,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
@@ -172,10 +302,11 @@ const createWindow = () => {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: 'hiddenInset',
|
||||
frame: true,
|
||||
show: false,
|
||||
});
|
||||
|
||||
mainWindow.webContents.openDevTools();
|
||||
mainWindow.once('ready-to-show', () => mainWindow.show());
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
@@ -186,23 +317,20 @@ const createWindow = () => {
|
||||
// 注入 baseUrl,让渲染进程的 getBaseUrl() 能拿到正确端口
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
if (opencodePort) {
|
||||
mainWindow.webContents.executeJavaScript(
|
||||
`window.__opencodeBaseUrl = 'http://127.0.0.1:${opencodePort}'`
|
||||
);
|
||||
mainWindow.webContents.executeJavaScript(`window.__opencodeBaseUrl = 'http://127.0.0.1:${opencodePort}'`);
|
||||
}
|
||||
});
|
||||
|
||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(
|
||||
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)
|
||||
);
|
||||
mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
|
||||
}
|
||||
};
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpcHandlers();
|
||||
startBonjourDiscovery();
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// See the Electron documentation for details on how to use preload scripts:
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
@@ -15,4 +15,18 @@ contextBridge.exposeInMainWorld('opencode', {
|
||||
health: () => ipcRenderer.invoke('opencode:health'),
|
||||
createSession: (data) => ipcRenderer.invoke('opencode:session:create', data),
|
||||
sendMessage: (sessionId, text) => ipcRenderer.invoke('opencode:session:send', sessionId, text),
|
||||
promptAsync: (sessionId, text) => ipcRenderer.invoke('opencode:session:prompt-async', sessionId, text),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('bonjour', {
|
||||
getServices: () => ipcRenderer.invoke('bonjour:get-services'),
|
||||
onServicesUpdated: (callback) => {
|
||||
const listener = (_event, services) => callback(services);
|
||||
ipcRenderer.on('bonjour:services-updated', listener);
|
||||
return () => ipcRenderer.removeListener('bonjour:services-updated', listener);
|
||||
},
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('opencodeConfig', {
|
||||
write: (params) => ipcRenderer.invoke('opencode:write-config', params),
|
||||
});
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* This file will automatically be loaded by vite and run in the "renderer" context.
|
||||
* To learn more about the differences between the "main" and the "renderer" context in
|
||||
* Electron, visit:
|
||||
*
|
||||
* https://electronjs.org/docs/tutorial/process-model
|
||||
*
|
||||
* By default, Node.js integration in this file is disabled. When enabling Node.js integration
|
||||
* in a renderer process, please be aware of potential security implications. You can read
|
||||
* more about security risks here:
|
||||
*
|
||||
* https://electronjs.org/docs/tutorial/security
|
||||
*
|
||||
* To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration`
|
||||
* flag:
|
||||
*
|
||||
* ```
|
||||
* // Create the browser window.
|
||||
* mainWindow = new BrowserWindow({
|
||||
* width: 800,
|
||||
* height: 600,
|
||||
* webPreferences: {
|
||||
* nodeIntegration: true
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import './index.css';
|
||||
|
||||
console.log(
|
||||
'👋 This message is being logged by "renderer.js", included via Vite',
|
||||
);
|
||||
@@ -1,7 +1,292 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<div class="flex flex-col h-screen w-screen overflow-hidden">
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- 侧边栏 -->
|
||||
<aside :class="['flex flex-col bg-[#EEEFF2] border-r border-gray-200 transition-all duration-300 no-drag', appStore.collapsed ? 'w-16' : 'w-[244px]']">
|
||||
<!-- 自定义菜单 -->
|
||||
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
<div
|
||||
v-for="(item, index) in menus"
|
||||
:key="index"
|
||||
:class="[
|
||||
'flex items-center justify-between cursor-pointer transition-all duration-200 ease-out group relative overflow-hidden',
|
||||
appStore.collapsed ? 'justify-center px-0 py-3 mx-auto w-11 rounded-xl' : 'w-[216px] h-[37px] rounded-[10px] pl-3 pr-[146px] py-0 mx-auto',
|
||||
$route.path === item.index ? 'bg-[#DEE0E4] text-gray-900' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700',
|
||||
]"
|
||||
@click="handleMenuClick(item)"
|
||||
>
|
||||
<!-- 图标 -->
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center justify-center shrink-0 transition-transform duration-200',
|
||||
appStore.collapsed ? 'w-6 h-6' : 'w-5 h-5',
|
||||
$route.path === item.index ? 'scale-110' : 'group-hover:scale-105',
|
||||
]"
|
||||
>
|
||||
<LucideIcon :name="item.icon" :size="appStore.collapsed ? 20 : 18" :class="['transition-colors duration-200']" />
|
||||
</div>
|
||||
|
||||
<!-- 文字 -->
|
||||
<span v-show="!appStore.collapsed" class="ml-3 text-sm font-medium whitespace-nowrap transition-all duration-200">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<el-divider></el-divider>
|
||||
<div class="w-54 h-8 text-sm mx-3 py-4 flex items-center" style="color: #8a9097">历史记录</div>
|
||||
<div
|
||||
v-for="(item, index) in historyStore.historyItems"
|
||||
:key="index"
|
||||
:class="[
|
||||
'flex items-center w-[216px] h-[37px] justify-between rounded-[10px] pl-3 pr-3 mx-auto cursor-pointer transition-all duration-200',
|
||||
route.params.id === item.id ? 'bg-[#DEE0E4] text-gray-900' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700',
|
||||
]"
|
||||
@click="onHistoryClick(item)"
|
||||
:title="item.name"
|
||||
>
|
||||
<span class="text-sm font-medium whitespace-nowrap truncate flex-1">{{ item.name }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 服务状态栏 -->
|
||||
<div class="flex items-center justify-between px-3 py-2 border-t border-gray-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500': appStore.serviceStatus === appStore.SERVICE_STATUS.RUNNING,
|
||||
'bg-yellow-500': appStore.serviceStatus === appStore.SERVICE_STATUS.CONNECTING,
|
||||
'bg-red-500': appStore.serviceStatus === appStore.SERVICE_STATUS.FAILED,
|
||||
'bg-gray-400': appStore.serviceStatus === appStore.SERVICE_STATUS.IDLE,
|
||||
}"
|
||||
/>
|
||||
<span class="text-xs text-gray-600">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="appStore.serviceStatus !== appStore.SERVICE_STATUS.RUNNING"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="appStore.serviceStatus === appStore.SERVICE_STATUS.CONNECTING"
|
||||
@click="startService"
|
||||
>
|
||||
{{ appStore.serviceStatus === appStore.SERVICE_STATUS.FAILED ? '重新启动' : '启动' }}
|
||||
</el-button>
|
||||
<el-button v-else size="small" type="danger" plain @click="stopService"> 停止 </el-button>
|
||||
</div>
|
||||
|
||||
<!-- 用户区域 -->
|
||||
<div class="flex items-center justify-between p-3 border-t border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<el-avatar :size="32" />
|
||||
<div v-show="!appStore.collapsed" class="ml-3">
|
||||
<div class="text-sm font-medium">{{ userStore.nickname }}</div>
|
||||
<div class="text-xs text-gray-500">{{ userStore.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<LucideIcon v-show="!appStore.collapsed" name="bolt" color="#808080" size="18"></LucideIcon>
|
||||
</div>
|
||||
|
||||
<!-- <!– 折叠按钮 –>-->
|
||||
<!-- <div class="p-3 border-t border-gray-200">-->
|
||||
<!-- <el-button :icon="appStore.collapsed ? Expand : Fold" circle size="small" @click="appStore.toggleSidebar" />-->
|
||||
<!-- </div>-->
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex flex-col flex-1 overflow-hidden">
|
||||
<!-- 页面内容 -->
|
||||
<main class="flex-1 overflow-hidden p-6">
|
||||
<div class="h-full overflow-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// App 根组件,路由视图入口
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useHistoryStore } from '@/stores/history';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { House, Monitor, Expand, Fold, ChatDotRound, Search, Collection, Clock } from '@element-plus/icons-vue';
|
||||
import router from '@/router';
|
||||
import axios from 'axios';
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
switch (appStore.serviceStatus) {
|
||||
case appStore.SERVICE_STATUS.CONNECTING:
|
||||
return '连接中';
|
||||
case appStore.SERVICE_STATUS.RUNNING:
|
||||
return '服务运行中';
|
||||
case appStore.SERVICE_STATUS.FAILED:
|
||||
return '连接失败';
|
||||
case appStore.SERVICE_STATUS.IDLE:
|
||||
default:
|
||||
return '服务未启动';
|
||||
}
|
||||
});
|
||||
|
||||
const menus = ref([
|
||||
{ name: '新对话', index: '/', icon: 'plus' },
|
||||
{ name: '知识空间', index: '/knowledge', icon: 'book' },
|
||||
// { name: 'opencode对话', index: '/chat', icon: 'bot' },
|
||||
{ name: '发现设备', index: '/bonjour', icon: 'server' },
|
||||
{ name: '测试页', index: '/test', icon: 'flask-conical' },
|
||||
]);
|
||||
|
||||
// 处理菜单点击
|
||||
function handleMenuClick(item) {
|
||||
if (item.index) {
|
||||
router.push(item.index);
|
||||
}
|
||||
}
|
||||
|
||||
const isServiceRunning = ref(false);
|
||||
let checkInterval = null;
|
||||
let retryCount = 0;
|
||||
const MAX_RETRY = 3;
|
||||
const RETRY_DELAY = 3000;
|
||||
|
||||
// 查询历史会话列表
|
||||
async function loadHistorySessions() {
|
||||
await historyStore.loadHistorySessions();
|
||||
}
|
||||
|
||||
// 点击历史会话,跳转到对话页面并加载该会话
|
||||
async function onHistoryClick(item) {
|
||||
// 跳转到对话页面
|
||||
router.push({
|
||||
name: 'Chat',
|
||||
params: { id: item.id },
|
||||
});
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
async function startService() {
|
||||
if (appStore.serviceStatus === appStore.SERVICE_STATUS.CONNECTING) return;
|
||||
|
||||
appStore.serviceStatus = appStore.SERVICE_STATUS.CONNECTING;
|
||||
retryCount = 0;
|
||||
doStartService();
|
||||
}
|
||||
|
||||
async function doStartService() {
|
||||
try {
|
||||
const info = await window.opencode.start();
|
||||
if (info.running) {
|
||||
isServiceRunning.value = true;
|
||||
appStore.serviceStatus = appStore.SERVICE_STATUS.RUNNING;
|
||||
// 更新 baseUrl 供 http 层使用
|
||||
if (info.url) window.__opencodeBaseUrl = info.url;
|
||||
// 服务启动成功后,初始化 SSE 连接
|
||||
appStore.initSSE();
|
||||
retryCount = 0;
|
||||
} else {
|
||||
handleRetry();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('启动服务失败:', err);
|
||||
handleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
if (retryCount < MAX_RETRY) {
|
||||
retryCount++;
|
||||
console.log(`[App.vue] 启动失败,第 ${retryCount} 次重连中...`);
|
||||
setTimeout(doStartService, RETRY_DELAY);
|
||||
} else {
|
||||
appStore.serviceStatus = appStore.SERVICE_STATUS.FAILED;
|
||||
isServiceRunning.value = false;
|
||||
console.error(`[App.vue] 重连 ${MAX_RETRY} 次后依然失败,停止重连。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 停止服务
|
||||
async function stopService() {
|
||||
try {
|
||||
await window.opencode.stop();
|
||||
isServiceRunning.value = false;
|
||||
appStore.serviceStatus = appStore.SERVICE_STATUS.IDLE;
|
||||
historyStore.clearHistory();
|
||||
appStore.closeSSE();
|
||||
} catch (err) {
|
||||
console.error('停止服务失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查服务状态
|
||||
async function checkServiceStatus() {
|
||||
// 如果正在重连中,则不进行状态更新,避免干扰
|
||||
if (appStore.serviceStatus === appStore.SERVICE_STATUS.CONNECTING) return;
|
||||
|
||||
try {
|
||||
const info = await window.opencode?.info();
|
||||
const isRunning = info?.running || false;
|
||||
|
||||
// 更新服务状态
|
||||
isServiceRunning.value = isRunning;
|
||||
if (isRunning) {
|
||||
appStore.serviceStatus = appStore.SERVICE_STATUS.RUNNING;
|
||||
// 服务运行中,更新 baseUrl 并加载历史记录
|
||||
if (info?.url) {
|
||||
window.__opencodeBaseUrl = info.url;
|
||||
}
|
||||
// 如果历史记录为空,则加载
|
||||
if (historyStore.historyItems.length === 0) {
|
||||
loadHistorySessions();
|
||||
}
|
||||
// 确保 SSE 连接已建立
|
||||
if (!appStore.sseConnected) {
|
||||
appStore.initSSE();
|
||||
}
|
||||
} else {
|
||||
// 只有当前是运行中才变更为未启动,防止覆盖 FAILED 状态
|
||||
if (appStore.serviceStatus === appStore.SERVICE_STATUS.RUNNING) {
|
||||
appStore.serviceStatus = appStore.SERVICE_STATUS.IDLE;
|
||||
}
|
||||
// 服务未运行,清空历史记录
|
||||
historyStore.clearHistory();
|
||||
}
|
||||
} catch (err) {
|
||||
// 获取状态失败,视为服务断开
|
||||
isServiceRunning.value = false;
|
||||
if (appStore.serviceStatus === appStore.SERVICE_STATUS.RUNNING) {
|
||||
appStore.serviceStatus = appStore.SERVICE_STATUS.IDLE;
|
||||
}
|
||||
historyStore.clearHistory();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始检查
|
||||
checkServiceStatus();
|
||||
// 定期检查服务状态(每2秒)
|
||||
checkInterval = setInterval(checkServiceStatus, 2000);
|
||||
|
||||
// 监听启动服务的指令
|
||||
watch(
|
||||
() => appStore.startServiceFlag,
|
||||
(newVal) => {
|
||||
if (newVal > 0) {
|
||||
startService();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
64
src/renderer/assets/icons/document-organization.svg
Normal file
64
src/renderer/assets/icons/document-organization.svg
Normal file
@@ -0,0 +1,64 @@
|
||||
<svg width="65" height="62" viewBox="0 0 65 62" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_2042_338)">
|
||||
<path d="M12 18C12 14.6863 14.6863 12 18 12H20.8336C21.7651 12 22.6837 12.2169 23.5169 12.6334L26.3779 14.0639C27.211 14.4805 28.1297 14.6974 29.0611 14.6974H47C50.3137 14.6974 53 17.3837 53 20.6974V43.7632C53 47.0769 50.3137 49.7632 47 49.7632H18C14.6863 49.7632 12 47.0769 12 43.7632V18Z" fill="url(#paint0_linear_2042_338)"/>
|
||||
<g filter="url(#filter1_d_2042_338)">
|
||||
<rect x="29.2632" y="20.6316" width="20.5" height="20.5" rx="3" fill="white"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d_2042_338)">
|
||||
<rect x="15.6741" y="20.2372" width="20.5" height="20.5" rx="3" transform="rotate(-3.70895 15.6741 20.2372)" fill="white"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_i_2042_338)">
|
||||
<rect x="12" y="24.9474" width="41" height="24.8158" rx="5" fill="url(#paint1_linear_2042_338)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_2042_338" x="-1" y="-2" width="66" height="66" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.870588 0 0 0 0 0.878431 0 0 0 0 0.894118 0 0 0 0.8 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_338"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_338" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_2042_338" x="21.2632" y="14.6316" width="36.5" height="36.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="4"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0205971 0 0 0 0 0.215922 0 0 0 0 0.589337 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_338"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_338" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d_2042_338" x="7.86179" y="13.0987" width="37.4078" height="37.4078" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="4"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_338"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_338" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter3_i_2042_338" x="12" y="24.9474" width="41" height="25.8158" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.64902 0 0 0 0 0.847983 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2042_338"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2042_338" x1="15.2368" y1="12" x2="49.7632" y2="49.7632" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#387CFF"/>
|
||||
<stop offset="1" stop-color="#0751DF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2042_338" x1="26.5658" y1="24.9474" x2="38.9737" y2="49.7631" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#80C8FF"/>
|
||||
<stop offset="1" stop-color="#52B4FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
85
src/renderer/assets/icons/official-document.svg
Normal file
85
src/renderer/assets/icons/official-document.svg
Normal file
@@ -0,0 +1,85 @@
|
||||
<svg width="66" height="75" viewBox="0 0 66 75" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_2042_81)">
|
||||
<path d="M44.1604 26.9325C43.8016 27.9772 44.3577 29.1149 45.4024 29.4736L53.8256 32.3661L45.8055 55.7222C44.9221 58.2947 42.1206 59.664 39.5481 58.7806L15.3268 50.4634C12.7543 49.58 11.3849 46.7785 12.2683 44.206L22.185 15.3268C23.0684 12.7543 25.8699 11.3849 28.4424 12.2683L47.0069 18.643L44.1604 26.9325Z" fill="#DEE0E4"/>
|
||||
<g opacity="0.56" filter="url(#filter1_d_2042_81)">
|
||||
<path d="M44.2052 27.022L47.0745 18.6661L53.8032 32.4324L45.4473 29.5631C44.4026 29.2044 43.8465 28.0667 44.2052 27.022Z" fill="#8A9097"/>
|
||||
</g>
|
||||
<rect x="24.6594" y="17.2181" width="15.7597" height="1.96996" rx="0.984981" transform="rotate(18.9517 24.6594 17.2181)" fill="#DEE0E4"/>
|
||||
<rect x="16.9825" y="39.5762" width="27.5795" height="1.96996" rx="0.984981" transform="rotate(18.9517 16.9825 39.5762)" fill="#DEE0E4"/>
|
||||
<rect x="23.0601" y="21.876" width="9.84981" height="1.96996" rx="0.984981" transform="rotate(18.9517 23.0601 21.876)" fill="#DEE0E4"/>
|
||||
<rect x="15.3832" y="44.2341" width="9.84981" height="1.96996" rx="0.984981" transform="rotate(18.9517 15.3832 44.2341)" fill="#DEE0E4"/>
|
||||
<rect x="21.4608" y="26.5339" width="27.5795" height="11.8198" rx="2" transform="rotate(18.9517 21.4608 26.5339)" fill="#F5F6F7"/>
|
||||
<path d="M39.0387 25.4412C39.0387 26.5458 39.9342 27.4412 41.0387 27.4412H49.3008V50.678C49.3008 53.2374 47.2261 55.3121 44.6667 55.3121H20.5691C18.0098 55.3121 15.935 53.2374 15.935 50.678V21.9463C15.935 19.3869 18.0098 17.3121 20.5691 17.3121H39.0387V25.4412Z" fill="white"/>
|
||||
<g filter="url(#filter2_d_2042_81)">
|
||||
<path d="M39.1057 25.5073V17.3121L49.3008 27.5073H41.1057C40.0011 27.5073 39.1057 26.6118 39.1057 25.5073Z" fill="white"/>
|
||||
</g>
|
||||
<rect x="18.7155" y="22.8731" width="14.8293" height="1.85366" rx="0.926829" fill="#DEE0E4"/>
|
||||
<rect x="18.7155" y="45.117" width="25.9512" height="1.85366" rx="0.926829" fill="#DEE0E4"/>
|
||||
<rect x="18.7155" y="27.5073" width="9.26829" height="1.85366" rx="0.926829" fill="#DEE0E4"/>
|
||||
<rect x="18.7155" y="49.7512" width="9.26829" height="1.85366" rx="0.926829" fill="#DEE0E4"/>
|
||||
<rect x="18.7155" y="32.1414" width="25.9512" height="11.122" rx="2" fill="#F5F6F7"/>
|
||||
<path d="M41.3438 53.8844C42.5231 52.5414 42.3921 50.5076 41.0497 49.3255L41.0496 49.3245C39.708 48.145 37.6722 48.2721 36.4887 49.6177L30.9782 55.8856C30.2349 56.7317 30.313 58.0187 31.1613 58.7645L31.1623 58.7645C31.3628 58.9419 31.5963 59.0787 31.8496 59.1655C32.1036 59.2526 32.3732 59.2887 32.6412 59.2712C32.909 59.2537 33.1713 59.1834 33.4117 59.0641C33.6521 58.9449 33.8669 58.7789 34.0429 58.5763L39.5478 52.3128L39.5524 52.3076C39.6926 52.1381 39.7615 51.9195 39.7443 51.7003C39.7268 51.4811 39.6242 51.2767 39.4591 51.1314C39.2939 50.9864 39.0782 50.9117 38.8586 50.9225C38.6389 50.9334 38.4318 51.0288 38.2817 51.1895L38.2772 51.1957L32.7694 57.4595L32.7667 57.4635C32.7374 57.498 32.7008 57.5263 32.6605 57.5467C32.6201 57.567 32.5758 57.5794 32.5307 57.5824C32.4856 57.5855 32.4401 57.5793 32.3974 57.5646C32.3762 57.5573 32.3551 57.5475 32.3359 57.5363L32.2804 57.4958L32.2784 57.494C32.1352 57.3681 32.1205 57.1499 32.2497 57.0026L34.6098 54.3203L34.6108 54.3202L37.7613 50.7357L37.7622 50.7346C37.8951 50.5828 38.0569 50.4586 38.2378 50.3694C38.419 50.28 38.6164 50.2276 38.8179 50.2146C39.0193 50.2017 39.2212 50.2286 39.4121 50.2939C39.6031 50.3593 39.7803 50.4615 39.9316 50.5951L39.9326 50.5961C40.0846 50.729 40.2085 50.8915 40.2979 51.0726C40.3872 51.2537 40.4407 51.4511 40.4536 51.6527C40.4665 51.8542 40.4389 52.0569 40.3735 52.2479C40.308 52.4387 40.2058 52.6152 40.0721 52.7664L40.0712 52.7674L37.7789 55.3769L37.7769 55.377L34.557 59.0394C34.0787 59.5832 33.4034 59.9151 32.6807 59.9615C31.958 60.0078 31.2465 59.7644 30.7026 59.2863C30.1589 58.8081 29.8272 58.1334 29.7806 57.4109C29.7342 56.6881 29.9766 55.9758 30.4548 55.4319L30.5606 55.3126L28.5242 55.3124C28.1959 55.9928 28.0423 56.7515 28.0916 57.5185C28.1671 58.6891 28.7046 59.7823 29.5856 60.5568C30.4666 61.3313 31.6195 61.7246 32.7902 61.6494C33.961 61.5741 35.0548 61.0364 35.8295 60.1554L41.3438 53.8844Z" fill="#F5900B"/>
|
||||
<g filter="url(#filter3_d_2042_81)">
|
||||
<circle cx="32.935" cy="36.3121" r="10" fill="url(#paint0_linear_2042_81)"/>
|
||||
</g>
|
||||
<g filter="url(#filter4_d_2042_81)">
|
||||
<path d="M28.1422 40.6687L28.0215 40.5487C27.9827 40.5099 27.9555 40.4612 27.9429 40.4078C27.9303 40.3545 27.9328 40.2987 27.9502 40.2468L29.622 35.2071C29.7133 34.9302 29.9041 34.6977 30.1575 34.5533L32.4538 33.3745C32.51 33.3424 32.5753 33.3296 32.6395 33.3382C32.7037 33.3468 32.7633 33.3762 32.8092 33.422L35.8205 36.4309C35.9144 36.5247 35.9344 36.6697 35.8687 36.7853L34.6912 39.0823C34.5473 39.3348 34.3145 39.5261 34.038 39.618L29.0025 41.2968C28.9504 41.3143 28.8945 41.3169 28.841 41.3043C28.7875 41.2917 28.7386 41.2644 28.6997 41.2256L28.5696 41.0956L31.5497 38.1179C31.6501 38.1466 31.7563 38.148 31.8575 38.1221C31.9587 38.0962 32.0511 38.0438 32.1253 37.9704C32.1802 37.9143 32.2235 37.848 32.2528 37.7753C32.282 37.7025 32.2966 37.6247 32.2958 37.5462C32.2949 37.4678 32.2785 37.3903 32.2477 37.3182C32.2168 37.2461 32.172 37.1808 32.1159 37.126C32.0611 37.0698 31.9957 37.025 31.9235 36.9942C31.8514 36.9633 31.7738 36.9469 31.6953 36.946C31.6168 36.9451 31.5389 36.9596 31.466 36.9888C31.3932 37.0179 31.3268 37.0611 31.2706 37.116C31.197 37.19 31.1445 37.2823 31.1184 37.3834C31.0923 37.4845 31.0937 37.5906 31.1223 37.691L28.1422 40.6687ZM35.2255 31.5038L37.7426 34.0177C37.9991 34.2739 37.9991 34.6889 37.7426 34.9452L36.7447 35.9421C36.7172 35.9696 36.6846 35.9914 36.6488 36.0062C36.6129 36.0211 36.5745 36.0287 36.5357 36.0287C36.4969 36.0287 36.4584 36.0211 36.4226 36.0062C36.3867 35.9914 36.3541 35.9696 36.3267 35.9421L33.2997 32.9182C33.2722 32.8908 33.2504 32.8583 33.2356 32.8225C33.2207 32.7866 33.213 32.7482 33.213 32.7095C33.213 32.6707 33.2207 32.6323 33.2356 32.5965C33.2504 32.5606 33.2722 32.5281 33.2997 32.5007L34.2976 31.5038C34.4208 31.381 34.5876 31.3121 34.7616 31.3121C34.9355 31.3121 35.1024 31.381 35.2255 31.5038Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_2042_81" x="-0.0650024" y="0" width="66" height="74.9535" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.870588 0 0 0 0 0.878431 0 0 0 0 0.894118 0 0 0 0.8 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_81"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_81" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_2042_81" x="38.0963" y="14.6661" width="21.7069" height="25.7663" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_81"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_81" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d_2042_81" x="33.1057" y="13.3121" width="22.1951" height="22.1951" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_81"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_81" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter3_d_2042_81" x="18.935" y="22.3121" width="28" height="28" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_81"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_81" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter4_d_2042_81" x="23.935" y="29.3121" width="18" height="18" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.00838089 0 0 0 0 0.186991 0 0 0 0 0.531269 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_81"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_81" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2042_81" x1="29.81" y1="26.9371" x2="39.185" y2="43.8121" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#78A6FF"/>
|
||||
<stop offset="1" stop-color="#316BDC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
32
src/renderer/assets/icons/pdf.svg
Normal file
32
src/renderer/assets/icons/pdf.svg
Normal file
@@ -0,0 +1,32 @@
|
||||
<svg width="63" height="65" viewBox="0 0 63 65" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_2042_361)">
|
||||
<path d="M39.9277 20.9287C39.9277 22.0333 40.8232 22.9287 41.9277 22.9287H51V48C51 50.7614 48.7614 53 46 53H20C17.2386 53 15 50.7614 15 48V17C15 14.2386 17.2386 12 20 12H39.9277V20.9287Z" fill="white"/>
|
||||
<g filter="url(#filter1_d_2042_361)">
|
||||
<path d="M40 21V12L51 23H42C40.8954 23 40 22.1046 40 21Z" fill="white"/>
|
||||
</g>
|
||||
<rect x="12" y="30" width="22" height="18" rx="3" fill="#EF4444"/>
|
||||
<path d="M19.98 34.004H24.124C26.532 34.004 27.75 35.026 27.75 37.07C27.75 39.128 26.532 40.164 24.096 40.164H21.618V44H19.98V34.004ZM21.618 35.404V38.764H23.998C24.726 38.764 25.258 38.624 25.594 38.372C25.93 38.106 26.098 37.672 26.098 37.07C26.098 36.468 25.916 36.048 25.58 35.796C25.244 35.53 24.712 35.404 23.998 35.404H21.618Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_2042_361" x="0" y="0" width="66" height="66" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.870588 0 0 0 0 0.878431 0 0 0 0 0.894118 0 0 0 0.8 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_361"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_361" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_2042_361" x="34" y="8" width="23" height="23" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2042_361"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2042_361" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/renderer/assets/logo.png
Normal file
BIN
src/renderer/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
174
src/renderer/components/LoginDialog.vue
Normal file
174
src/renderer/components/LoginDialog.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :show-close="false" width="680px" border-radius="16px" :close-on-click-modal="true" class="login-dialog">
|
||||
<div class="flex h-[420px] rounded-2xl overflow-hidden">
|
||||
<!-- 左侧表单区 -->
|
||||
<div class="flex flex-col items-center justify-center w-1/2 px-10 py-8 bg-white">
|
||||
<!-- Logo 占位,后续替换为真实图片 -->
|
||||
<div class="mb-4">
|
||||
<div class="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center text-2xl">🤖</div>
|
||||
</div>
|
||||
|
||||
<p class="text-base font-semibold text-gray-700 mb-6">登录后体验更多功能</p>
|
||||
|
||||
<el-form :model="form" :rules="rules" ref="formRef" class="w-full" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="sparkDevice">
|
||||
<el-select v-model="form.sparkDevice" placeholder="请选择设备" size="large" class="w-full" :no-data-text="'暂未发现设备,请先扫描'">
|
||||
<el-option
|
||||
v-for="device in sparkStore.devices"
|
||||
:key="device.name"
|
||||
:label="`${device.name} (${device.addresses?.find((a) => !a.includes(':')) || device.referer?.address}:${device.port})`"
|
||||
:value="device.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入账号" :prefix-icon="User" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password size="large" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="w-full flex justify-end mb-4">
|
||||
<span class="text-xs text-gray-400 cursor-pointer hover:text-green-500">忘记密码?</span>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="w-full !bg-black !border-black !rounded-full !text-white font-semibold"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2 text-xs text-gray-400">
|
||||
<el-checkbox v-model="agreed" size="small" />
|
||||
<span>请先阅读<a href="#" class="text-green-500">用户协议与隐私政策</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧品牌区 -->
|
||||
<div class="flex flex-col items-center justify-between w-1/2 bg-[#5DC98A] px-8 py-8 rounded-r-2xl">
|
||||
<div class="self-end text-white text-2xl font-bold tracking-widest">玄鉴</div>
|
||||
<div class="flex flex-col items-center gap-3 text-white text-center">
|
||||
<p class="text-xl font-semibold">我是AI工作助手</p>
|
||||
<p class="text-sm opacity-80">能独立思考、自主执行的工作伙伴</p>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { User } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useSparkStore } from '@/stores/spark';
|
||||
import { loginAction, getUserInfoAction } from '@/http/api.js';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
const sparkStore = useSparkStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'login-success']);
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => (visible.value = val)
|
||||
);
|
||||
watch(visible, (val) => emit('update:modelValue', val));
|
||||
|
||||
const formRef = ref(null);
|
||||
const loading = ref(false);
|
||||
const agreed = ref(false);
|
||||
|
||||
const form = ref({
|
||||
sparkDevice: sparkStore.selectedDevice?.name || '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
sparkDevice: [{ required: true, message: '请选择设备', trigger: 'change' }],
|
||||
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
async function handleLogin() {
|
||||
if (!agreed.value) {
|
||||
ElMessage.warning('请先阅读并同意用户协议与隐私政策');
|
||||
return;
|
||||
}
|
||||
await formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const device = sparkStore.devices.find((d) => d.name === form.value.sparkDevice);
|
||||
if (device) sparkStore.selectDevice(device);
|
||||
|
||||
const selectedDevice = sparkStore.selectedDevice;
|
||||
console.log('[Login] spark device:', selectedDevice);
|
||||
console.log('[Login] target url:', sparkStore.selectedDeviceUrl);
|
||||
|
||||
await loginAction({ email: form.value.username, password: form.value.password });
|
||||
|
||||
// 登录成功后获取用户信息并保存
|
||||
const userRes = await getUserInfoAction();
|
||||
const modelInfo = userRes.data?.xuanjian_model_info;
|
||||
userStore.setUserInfo({ nickname: userRes.data?.nickname, email: userRes.data?.email });
|
||||
|
||||
// 写入 opencode 配置文件
|
||||
if (modelInfo && selectedDevice) {
|
||||
try {
|
||||
const deviceHost = selectedDevice.host;
|
||||
console.log('[Config] modelInfo:', modelInfo);
|
||||
console.log('[Config] deviceHost:', deviceHost, 'port:', selectedDevice.port);
|
||||
await window.opencodeConfig.write({
|
||||
modelInfo,
|
||||
deviceHost: selectedDevice.host,
|
||||
devicePort: selectedDevice.port,
|
||||
});
|
||||
console.log('[Config] 写入成功');
|
||||
} catch (configErr) {
|
||||
console.error('[Config] 写入失败:', configErr);
|
||||
}
|
||||
} else {
|
||||
console.warn('[Config] 跳过写入,modelInfo:', modelInfo, 'selectedDevice:', selectedDevice);
|
||||
}
|
||||
|
||||
ElMessage.success(`登录成功 | ${sparkStore.selectedDeviceUrl ?? '未选择设备'}`);
|
||||
emit('login-success', { username: form.value.username, device: selectedDevice });
|
||||
visible.value = false;
|
||||
} catch (err) {
|
||||
ElMessage.error('登录失败,请重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-dialog) {
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.el-dialog__header) {
|
||||
display: none;
|
||||
}
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
201
src/renderer/components/MarkdownRender/CitationList.vue
Normal file
201
src/renderer/components/MarkdownRender/CitationList.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useIndexQaStore } from '@/stores/indexQa.js';
|
||||
import { useDocReadingQa } from '@/stores/readQa.js';
|
||||
import { useKbQaStore } from '@/stores/kbQa.js';
|
||||
import { useThinkBase } from '@/stores/thinkBase.js';
|
||||
|
||||
const props = defineProps({
|
||||
nums: { type: String, default: '' },
|
||||
messageIndex: { type: Number, default: -1 },
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const popoverVisible = ref(false);
|
||||
|
||||
// 根据路由判断使用哪个 store
|
||||
const currentStore = computed(() => {
|
||||
const path = route.path;
|
||||
|
||||
// 判断是 smart-answer 路由、doc 路由、repository 路由还是 deep-think 路由
|
||||
if (path.startsWith('/smart-answer')) {
|
||||
return useIndexQaStore();
|
||||
} else if (path.startsWith('/doc/')) {
|
||||
return useDocReadingQa();
|
||||
} else if (path.startsWith('/repository')) {
|
||||
return useKbQaStore();
|
||||
} else if (path.startsWith('/deep-think')) {
|
||||
return useThinkBase();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// 获取 reference 数组
|
||||
const referenceList = computed(() => {
|
||||
if (!currentStore.value || !currentStore.value.reference) return [];
|
||||
return Array.isArray(currentStore.value.reference) ? currentStore.value.reference : [];
|
||||
});
|
||||
|
||||
// 计算当前消息对应 reference 的索引(只计算 assistant 消息)
|
||||
const referenceIndex = computed(() => {
|
||||
if (!currentStore.value || props.messageIndex < 0) return -1;
|
||||
|
||||
const messages = currentStore.value.message || [];
|
||||
// 统计当前消息之前有多少条 assistant 消息
|
||||
let assistantCount = 0;
|
||||
for (let i = 0; i <= props.messageIndex; i++) {
|
||||
if (messages[i]?.role === 'assistant') {
|
||||
assistantCount++;
|
||||
}
|
||||
}
|
||||
// 返回 reference 数组的索引(从 0 开始)
|
||||
return assistantCount > 0 ? assistantCount - 1 : -1;
|
||||
});
|
||||
|
||||
// 获取当前对话轮次的 reference 数据
|
||||
const currentReference = computed(() => {
|
||||
if (referenceIndex.value < 0 || referenceIndex.value >= referenceList.value.length) {
|
||||
return null;
|
||||
}
|
||||
return referenceList.value[referenceIndex.value];
|
||||
});
|
||||
|
||||
// 根据 ID 查找对应的 chunk 数据
|
||||
const citationData = computed(() => {
|
||||
// 优先从 citations 字典中查找(新格式 CIT:n)
|
||||
if (currentStore.value && currentStore.value.citations) {
|
||||
const citations = currentStore.value.citations;
|
||||
const key = `CIT:${props.nums}`;
|
||||
const data = citations[key];
|
||||
if (data) {
|
||||
return {
|
||||
content: data.content,
|
||||
document_name: data.document_keyword,
|
||||
page: data.page,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧格式:根据 props.nums 作为索引从 currentReference.chunks 中查找
|
||||
const id = parseInt(props.nums);
|
||||
if (!isNaN(id) && currentReference.value) {
|
||||
const chunks = currentReference.value.chunks || [];
|
||||
const data = chunks[id];
|
||||
if (data) {
|
||||
return {
|
||||
content: data.content,
|
||||
document_name: data.document_name,
|
||||
page: data.page,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// 点击时打印数据用于调试
|
||||
const handlePopoverShow = () => {
|
||||
console.log('Citation Clicked:', {
|
||||
nums: props.nums,
|
||||
messageIndex: props.messageIndex,
|
||||
referenceIndex: referenceIndex.value,
|
||||
currentStoreCitations: currentStore.value?.citations,
|
||||
currentReference: currentReference.value,
|
||||
citationData: citationData.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--<el-popover v-model:visible="popoverVisible" :width="300" trigger="click" @show="handlePopoverShow">-->
|
||||
<el-popover v-model:visible="popoverVisible" :width="400" trigger="click" @show="handlePopoverShow">
|
||||
<template #reference>
|
||||
<sup class="ref-sup">
|
||||
{{ Number(props.nums) + 1 }}
|
||||
</sup>
|
||||
</template>
|
||||
<div class="popover-content">
|
||||
<template v-if="citationData">
|
||||
<div class="citation-info">
|
||||
<p class="citation-title">引用 #{{ Number(props.nums) + 1 }}</p>
|
||||
<div class="citation-detail">
|
||||
<p v-if="citationData.content" class="citation-content">{{ citationData.content }}</p>
|
||||
<p v-if="citationData.document_name" class="citation-meta truncate w-full">
|
||||
<span class="text-gray-600">来自文档:</span>
|
||||
<span class="font-mediumbreak-all" :title="citationData.document_name">{{ citationData.document_name }}</span>
|
||||
</p>
|
||||
<p v-if="citationData.page" class="citation-meta">
|
||||
<span class="text-gray-600">页码:</span>
|
||||
<span>{{ citationData.page }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-gray-500">引用编号:{{ props.nums }}(暂无详细信息)</p>
|
||||
</template>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ref-sup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background: rgba(76, 138, 253, 0.1);
|
||||
color: #4c8afd;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin: 0 2px;
|
||||
vertical-align: text-top;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ref-sup:hover {
|
||||
background: #4c8afd;
|
||||
color: #fff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.citation-info {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.citation-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.citation-detail {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.citation-content {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.citation-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
191
src/renderer/components/MarkdownRender/ThinkBlock.vue
Normal file
191
src/renderer/components/MarkdownRender/ThinkBlock.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import LucideIcon from '@/components/base/LucideIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
content: { type: String, default: '' },
|
||||
isThinking: { type: Boolean, default: false },
|
||||
thinkTime: { type: [Number, String], default: '' },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const isExpanded = ref(!props.collapsed); // 默认根据 collapsed 初始化
|
||||
|
||||
watch(
|
||||
() => props.collapsed,
|
||||
(newVal) => {
|
||||
isExpanded.value = !newVal;
|
||||
}
|
||||
);
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
const beforeEnter = (el) => {
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
el.style.marginTop = '0';
|
||||
};
|
||||
|
||||
const enter = (el, done) => {
|
||||
el.offsetHeight; // trigger reflow
|
||||
el.style.transition = 'height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease, margin-top 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
el.style.opacity = '1';
|
||||
el.style.marginTop = '12px';
|
||||
el.addEventListener('transitionend', done, { once: true });
|
||||
};
|
||||
|
||||
const afterEnter = (el) => {
|
||||
el.style.height = 'auto';
|
||||
el.style.marginTop = '12px';
|
||||
};
|
||||
|
||||
const beforeLeave = (el) => {
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
el.style.opacity = '1';
|
||||
el.style.marginTop = '12px';
|
||||
};
|
||||
|
||||
const leave = (el, done) => {
|
||||
el.offsetHeight; // trigger reflow
|
||||
el.style.transition = 'height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease, margin-top 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
el.style.marginTop = '0';
|
||||
el.addEventListener('transitionend', done, { once: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cot-capsule" :class="{ open: isExpanded }">
|
||||
<div class="cot-header" :class="{ thinking: isThinking }" @click="toggleExpand">
|
||||
<LucideIcon v-if="isThinking" name="loader-2" class="cot-icon cot-icon-spin" />
|
||||
<LucideIcon v-else name="lightbulb" class="cot-icon" style="color: #f59e0b" />
|
||||
<span class="cot-status">
|
||||
{{ isThinking ? '正在深度思考...' : '深度思考过程' }}
|
||||
<span v-if="!isThinking && thinkTime" class="ml-1 opacity-60">({{ thinkTime }}s)</span>
|
||||
</span>
|
||||
<LucideIcon name="chevron-down" class="cot-chevron" />
|
||||
</div>
|
||||
<transition name="think-expand" @before-enter="beforeEnter" @enter="enter" @after-enter="afterEnter" @before-leave="beforeLeave" @leave="leave">
|
||||
<div v-show="isExpanded" class="cot-body">
|
||||
<div class="think-content">
|
||||
{{ props.content }}
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cot-capsule {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cot-header {
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 100px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cot-header:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.cot-header.thinking {
|
||||
border-color: rgba(76, 138, 253, 0.3);
|
||||
color: #4c8afd;
|
||||
background: linear-gradient(270deg, #ffffff, #eff6ff, #f5f3ff, #eff6ff, #ffffff);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient-flow 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-flow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.cot-icon-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.cot-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cot-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.4;
|
||||
transition: transform 0.3s;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.cot-capsule.open .cot-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.cot-body {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.think-content {
|
||||
padding: 16px 20px;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.think-expand-enter-from,
|
||||
.think-expand-leave-to {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.think-expand-enter-to,
|
||||
.think-expand-leave-from {
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
|
||||
.think-expand-enter-active,
|
||||
.think-expand-leave-active {
|
||||
transition:
|
||||
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.4s ease,
|
||||
margin-top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
131
src/renderer/components/MarkdownRender/ThumbDownDialog.vue
Normal file
131
src/renderer/components/MarkdownRender/ThumbDownDialog.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { thumbUpAction } from '@/http/api.js';
|
||||
// 受控组件:由父组件通过 v-model:visible 控制打开/关闭
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
width: { type: [String, Number], default: '660' },
|
||||
messageId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm']);
|
||||
|
||||
const btnNames = ['问题理解有误', '内容太浮夸', '逻辑不清晰', '重复输出', '遗忘上下文', '引用网页质量不高', '语言风格不喜欢'];
|
||||
const feedback = ref('');
|
||||
// 将外部的 visible 与内部 v-model 绑定
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val),
|
||||
});
|
||||
|
||||
const btnLoading = ref(false);
|
||||
const handleClose = () => {
|
||||
feedback.value = '';
|
||||
// 关闭弹窗时清空表单,防止下次打开仍然显示旧数据
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
// 执行表单校验
|
||||
try {
|
||||
// 例如:await api.createRepository({ name: form.email })
|
||||
btnLoading.value = true;
|
||||
let res = await thumbUpAction({
|
||||
conversation_id: props.conversationId,
|
||||
message_id: props.messageId,
|
||||
feedback: feedback.value,
|
||||
thumbup: false,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
emit('confirm');
|
||||
ElMessage.success('感谢您的反馈');
|
||||
handleClose();
|
||||
// 保存成功后重置表单并关闭弹窗
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
// 通知父组件确认事件(如需外部继续处理)
|
||||
} catch (error) {
|
||||
const message = (error && (error.message || error.msg)) || '提交失败,请稍后重试';
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
btnLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:width="width"
|
||||
align-center
|
||||
:before-close="handleClose"
|
||||
:append-to-body="true"
|
||||
class="gradient-dialog"
|
||||
style="background: linear-gradient(180deg, #e8f0ff 0%, #ffffff 44%)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-title text-[20px]">抱歉,让你有不好的体验</div>
|
||||
<div class="text-[14px] text-quaternary-text">你的反馈将帮助我们更好的进步。</div>
|
||||
</template>
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
<el-button v-for="(btn, index) in btnNames" :key="index" @click="feedback = btn">{{ btn }}</el-button>
|
||||
</div>
|
||||
|
||||
<textarea :rows="8" v-model="feedback" class="mt-8 w-full common-border rounded-lg p-2" placeholder="其他反馈建议内容" maxlength="300" />
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button color="#1570ef" :loading="btnLoading" @click="onConfirm">确 定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
/* 给当前对话框应用渐变背景色 */
|
||||
.gradient-dialog {
|
||||
background: linear-gradient(180deg, #e8f0ff 0%, #ffffff 44%);
|
||||
}
|
||||
|
||||
/* 让头部、主体、底部背景透明,以便显示整体渐变 */
|
||||
:deep(.gradient-dialog .el-dialog__header),
|
||||
:deep(.gradient-dialog .el-dialog__body),
|
||||
:deep(.gradient-dialog .el-dialog__footer) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 搜索编辑区文本域样式 */
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
caret-color: #111827;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
}
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
63
src/renderer/components/MarkdownRender/ThumbUp.vue
Normal file
63
src/renderer/components/MarkdownRender/ThumbUp.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { copyText } from '@/utils/clipboard.js';
|
||||
import { thumbUpAction } from '@/http/api.js';
|
||||
import ThumbDownDialog from '@/components/MarkdownRenderer/ThumbDownDialog.vue';
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
|
||||
const thumbUp = async () => {
|
||||
if (!props.id) {
|
||||
ElMessage.error('暂无相关会话信息');
|
||||
return;
|
||||
}
|
||||
|
||||
let res = await thumbUpAction({ conversation_id: props.conversationId, message_id: props.id, thumbup: true });
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('点赞成功');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyText(props.content);
|
||||
ElMessage.success('复制成功');
|
||||
} catch {
|
||||
ElMessage.error('复制失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex gap-4">
|
||||
<div class="inline-flex items-center justify-center cursor-pointer rounded-full h-10 w-10 hover:bg-[#f2f3f4]" @click="handleCopy">
|
||||
<IconPark name="copy-jci9dmeo" size="20"></IconPark>
|
||||
</div>
|
||||
<div class="inline-flex items-center justify-center cursor-pointer rounded-full h-10 w-10 hover:bg-[#f2f3f4]" @click="thumbUp">
|
||||
<IconPark name="thumbs-up" size="20"></IconPark>
|
||||
</div>
|
||||
<div class="inline-flex items-center justify-center cursor-pointer rounded-full h-10 w-10 hover:bg-[#f2f3f4]" @click="dialogVisible = true">
|
||||
<IconPark name="thumbs-down" size="20"></IconPark>
|
||||
</div>
|
||||
</div>
|
||||
<ThumbDownDialog v-model:visible="dialogVisible" :conversation-id="conversationId" :message-id="id"></ThumbDownDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
114
src/renderer/components/MarkdownRender/citation.js
Normal file
114
src/renderer/components/MarkdownRender/citation.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
/**
|
||||
* 自定义 remark 插件来处理 citation 标记
|
||||
*/
|
||||
export const remarkCitation = () => {
|
||||
return (tree) => {
|
||||
visit(tree, 'text', (node, index, parent) => {
|
||||
const citationRegex = /\[CIT:(\d+)]/g;
|
||||
const matches = [...node.value.matchAll(citationRegex)];
|
||||
|
||||
if (matches.length === 0) return;
|
||||
|
||||
const newChildren = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
matches.forEach((match) => {
|
||||
const [fullMatch, num] = match;
|
||||
const startIndex = match.index;
|
||||
|
||||
// 添加匹配前的文本
|
||||
if (startIndex > lastIndex) {
|
||||
newChildren.push({
|
||||
type: 'text',
|
||||
value: node.value.slice(lastIndex, startIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加citations节点
|
||||
newChildren.push({
|
||||
type: 'citations',
|
||||
data: {
|
||||
hName: 'citations',
|
||||
hProperties: {
|
||||
dataNums: num,
|
||||
},
|
||||
},
|
||||
children: [{ type: 'text', value: num }],
|
||||
});
|
||||
|
||||
lastIndex = startIndex + fullMatch.length;
|
||||
});
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastIndex < node.value.length) {
|
||||
newChildren.push({
|
||||
type: 'text',
|
||||
value: node.value.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// 替换原节点
|
||||
parent.children.splice(index, 1, ...newChildren);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义 remark 插件来处理 <think> 标签
|
||||
* 将 <think>.....</think> 转换为自定义节点
|
||||
*/
|
||||
export const remarkThink = () => {
|
||||
return (tree) => {
|
||||
visit(tree, 'text', (node, index, parent) => {
|
||||
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
|
||||
const matches = [...node.value.matchAll(thinkRegex)];
|
||||
|
||||
if (matches.length === 0) return;
|
||||
|
||||
const newChildren = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
matches.forEach((match) => {
|
||||
const [fullMatch, content] = match;
|
||||
const startIndex = match.index;
|
||||
|
||||
// 添加匹配前的文本
|
||||
if (startIndex > lastIndex) {
|
||||
newChildren.push({
|
||||
type: 'text',
|
||||
value: node.value.slice(lastIndex, startIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加 think 节点
|
||||
newChildren.push({
|
||||
type: 'thinkBlock',
|
||||
data: {
|
||||
hName: 'thinkBlock',
|
||||
hProperties: {
|
||||
content: content,
|
||||
},
|
||||
},
|
||||
children: [{ type: 'text', value: content }],
|
||||
});
|
||||
|
||||
lastIndex = startIndex + fullMatch.length;
|
||||
});
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastIndex < node.value.length) {
|
||||
newChildren.push({
|
||||
type: 'text',
|
||||
value: node.value.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// 替换原节点
|
||||
parent.children.splice(index, 1, ...newChildren);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export default remarkCitation;
|
||||
37
src/renderer/components/MarkdownRender/deep-think.js
Normal file
37
src/renderer/components/MarkdownRender/deep-think.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
/**
|
||||
* 自定义 remark 插件来处理 think 代码块
|
||||
* 将 ```think ``` 转换为自定义节点
|
||||
*/
|
||||
export const remarkThink = () => {
|
||||
return (tree) => {
|
||||
// console.log('🔍 remarkThink 开始处理')
|
||||
|
||||
visit(tree, 'code', (node, index, parent) => {
|
||||
// console.log('📝 检查 code 节点, lang:', node.lang, 'value 预览:', node.value?.substring(0, 50))
|
||||
|
||||
// 检查是否是 think 代码块
|
||||
if (node.lang !== 'think') return;
|
||||
|
||||
// console.log('✅ 找到 think 代码块,内容长度:', node.value?.length)
|
||||
|
||||
// 替换为 thinkBlock 节点
|
||||
const thinkNode = {
|
||||
type: 'thinkBlock',
|
||||
data: {
|
||||
hName: 'thinkBlock',
|
||||
hProperties: {
|
||||
content: node.value || '',
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
|
||||
parent.children[index] = thinkNode;
|
||||
// console.log('🔄 替换节点完成')
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export default remarkThink;
|
||||
234
src/renderer/components/MarkdownRender/index.vue
Normal file
234
src/renderer/components/MarkdownRender/index.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<script setup>
|
||||
import { h, ref, watch, defineOptions, useAttrs, computed } from 'vue';
|
||||
import { processMarkdown } from './processor';
|
||||
// import CitationList from './CitationList.vue'
|
||||
import ThinkBlock from './ThinkBlock.vue';
|
||||
// import { getDocInfoAction } from '@/http/api.js'
|
||||
// import { compact, isEqual, uniq } from 'lodash-es'
|
||||
defineOptions({
|
||||
name: 'MarkdownRender',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
const props = defineProps({
|
||||
content: String,
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
content: '',
|
||||
role: '',
|
||||
}),
|
||||
},
|
||||
// 差异化的doc_id
|
||||
doc_ids: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// 当前消息在 message 数组中的索引
|
||||
messageIndex: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
});
|
||||
|
||||
// 资源图标(与 AnswerView 中保持一致)
|
||||
const pdfIconUrl = new URL('@/assets/pdf.svg', import.meta.url).href;
|
||||
const loadingIconUrl = new URL('@/assets/loading.svg', import.meta.url).href;
|
||||
|
||||
// 与 AnswerView/SearchView 保持一致的文件大小格式化
|
||||
function formatSize(size) {
|
||||
if (size === 0) return '0B';
|
||||
if (!size || isNaN(size)) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(size) / Math.log(1024));
|
||||
const value = size / Math.pow(1024, i);
|
||||
return `${value.toFixed(2)}${units[i]}`;
|
||||
}
|
||||
const localDocInfos = ref([]);
|
||||
|
||||
// 当外部传入 doc_ids 且不为空时,直接以其为参数查询 getDocInfo;否则不做任何处理
|
||||
watch(
|
||||
() => props.doc_ids,
|
||||
// async (ids, newids) => {
|
||||
// if (isEqual(ids, newids)) return
|
||||
// const currIds = uniq(compact(Array.isArray(ids) ? ids : []))
|
||||
//
|
||||
// if (currIds.length === 0) {
|
||||
// // 未传或为空:不展示
|
||||
// localDocInfos.value = []
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// try {
|
||||
// const res = await getDocInfoAction({ doc_ids: currIds })
|
||||
// if (res && res.code === 0) {
|
||||
// localDocInfos.value = Array.isArray(res.data) ? res.data : []
|
||||
// } else {
|
||||
// localDocInfos.value = []
|
||||
// }
|
||||
// } catch {
|
||||
// // ignore fetch errors here; UI 仅为展示
|
||||
// localDocInfos.value = []
|
||||
// }
|
||||
// },
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 渲染用的文档信息列表:仅当当前 doc_ids 与 pre_doc_ids 的差集非空时展示
|
||||
const docsToRender = computed(() => {
|
||||
return localDocInfos.value || [];
|
||||
});
|
||||
|
||||
// incoming attrs from parent (e.g., class passed on <MarkdownRenderer class="..." />)
|
||||
const attrs = useAttrs();
|
||||
|
||||
// 将 HAST 的属性映射为 Vue/DOM 可识别的属性
|
||||
function mapProps(properties = {}) {
|
||||
const mapped = { ...properties };
|
||||
// HAST 使用 className(数组或字符串),Vue/DOM 需要 class
|
||||
if (mapped.className) {
|
||||
mapped.class = Array.isArray(mapped.className) ? mapped.className.join(' ') : mapped.className;
|
||||
delete mapped.className;
|
||||
}
|
||||
// 兼容 htmlFor → for
|
||||
if (mapped.htmlFor) {
|
||||
mapped.for = mapped.htmlFor;
|
||||
delete mapped.htmlFor;
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
const rootNode = ref(null);
|
||||
const containerRef = ref(null);
|
||||
const astToVNode = (ast) => {
|
||||
if (ast.type === 'text') {
|
||||
return ast.value;
|
||||
}
|
||||
if (ast.type === 'element') {
|
||||
//console.log('🔍 处理元素节点:', ast.tagName, '属性:', ast.properties)
|
||||
if (ast.tagName === 'citations') {
|
||||
return h(CitationList, {
|
||||
nums: ast.properties?.dataNums || '',
|
||||
messageIndex: props.messageIndex,
|
||||
});
|
||||
}
|
||||
// 注意:HTML 标签名会被转换为小写
|
||||
if (ast.tagName === 'thinkblock') {
|
||||
//console.log('✨ 渲染 ThinkBlock 组件,content:', ast.properties?.content?.substring(0, 50))
|
||||
return h(ThinkBlock, {
|
||||
content: ast.properties?.content || '',
|
||||
});
|
||||
}
|
||||
const elementProps = mapProps(ast.properties);
|
||||
return h(ast.tagName, elementProps, ast.children?.map(astToVNode) || []);
|
||||
}
|
||||
if (ast.type === 'thinkBlock') {
|
||||
//console.log('⚠️ 发现 thinkBlock 类型节点(非 element):', ast)
|
||||
return h(ThinkBlock, {
|
||||
content: ast.data?.hProperties?.content || ast.properties?.content || '',
|
||||
});
|
||||
}
|
||||
//console.log('⚠️ 未处理的节点类型:', ast.type)
|
||||
return null;
|
||||
};
|
||||
|
||||
// 监听 content 变化
|
||||
watch(
|
||||
() => props.content,
|
||||
async (newContent) => {
|
||||
//console.log('📥 收到内容:', newContent?.substring(0, 100), '包含<think>:', newContent?.includes('<think>'))
|
||||
const ast = await processMarkdown(newContent || '');
|
||||
// We bind the markdown-container class here.
|
||||
// External attributes (like user-answer/robot-answer classes)
|
||||
// are bound to the wrapper div in the template.
|
||||
rootNode.value = h('div', { class: 'markdown-container' }, ast.children?.map(astToVNode) || []);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-bind="attrs" ref="containerRef">
|
||||
<component :is="rootNode"></component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import 'katex/dist/katex.min.css';
|
||||
|
||||
.markdown-container {
|
||||
line-height: 1.6;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-container > *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* 基础 Markdown 样式 */
|
||||
.markdown-container p {
|
||||
//color: #333;
|
||||
}
|
||||
|
||||
.markdown-container h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
.markdown-container h2 {
|
||||
font-size: 1.5em;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.markdown-container pre {
|
||||
background-color: #f6f8fa;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-container code {
|
||||
font-family: monospace;
|
||||
background-color: rgba(175, 184, 193, 0.2);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdown-container blockquote {
|
||||
border-left: 4px solid #dfe2e5;
|
||||
color: #6a737d;
|
||||
padding: 0 1em;
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
|
||||
.markdown-container table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-container th,
|
||||
.markdown-container td {
|
||||
border: 1px solid #dfe2e5;
|
||||
padding: 6px 13px;
|
||||
}
|
||||
|
||||
.markdown-container tr {
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #c6cbd1;
|
||||
}
|
||||
|
||||
.markdown-container tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
/* 数学公式样式微调,避免溢出并统一间距 */
|
||||
.markdown-container .katex-display {
|
||||
margin: 1em 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.markdown-container .katex {
|
||||
font-size: 1em;
|
||||
}
|
||||
</style>
|
||||
99
src/renderer/components/MarkdownRender/processor.js
Normal file
99
src/renderer/components/MarkdownRender/processor.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
// 启用 KaTeX 的化学公式扩展(支持 \ce{})
|
||||
import 'katex/contrib/mhchem';
|
||||
|
||||
import { remarkCitation } from '@/components/MarkdownRender/citation.js';
|
||||
import { remarkThink } from '@/components/MarkdownRender/deep-think.js';
|
||||
|
||||
// 调试插件函数
|
||||
// function createDebugPlugin(stepName) {
|
||||
// return function debugPlugin() {
|
||||
// return function transformer(tree,) {
|
||||
// console.log(`=== ${stepName} ===`)
|
||||
// console.log('Node type:', tree.type)
|
||||
// console.log('Children count:', tree.children ? tree.children.length : 0)
|
||||
// // 只输出前几个节点避免控制台爆炸
|
||||
// if (tree.children) {
|
||||
// console.log('First 5 children:', tree.children.slice(0, 5))
|
||||
// }
|
||||
// console.log('=================')
|
||||
// return tree
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 将 Markdown 内容处理为 HTML 字符串的异步函数
|
||||
*
|
||||
* @param content - 原始 Markdown 文本内容
|
||||
* @returns 处理后的 HTML 字符串
|
||||
*/
|
||||
export async function processMarkdown(content) {
|
||||
// 确保 content 是字符串,防止 replace 报错
|
||||
const safeContent = typeof content === 'string' ? content : String(content || '');
|
||||
|
||||
// 预处理:在 Markdown 解析之前,将 <think> 标签转换为特殊的 Markdown 标记
|
||||
// 这样可以确保它们不会被当作 HTML 处理
|
||||
let processed = safeContent.replace(/<think>([\s\S]*?)<\/think>/g, (match, thinkContent) => {
|
||||
// 使用特殊的代码块标记来保护内容
|
||||
return `
|
||||
|
||||
~~~think
|
||||
${thinkContent}
|
||||
~~~
|
||||
|
||||
`;
|
||||
});
|
||||
|
||||
// console.log('🔧 预处理后包含~~~think:', processed.includes('~~~think'))
|
||||
|
||||
// 预处理数学公式标记:
|
||||
// 1. 将 \(公式\) 格式的行内数学公式转换为 $公式$ 格式,以便 remark-math 正确识别
|
||||
// 2. 将 \[公式\] 格式的块级数学公式转换为 $$公式$$ 格式,以便 remark-math 正确识别
|
||||
processed = processed.replace(/\\\(([^]*?)\\\)/g, (_, math) => `$${math}$`).replace(/\\\[([^]*?)\\\]/g, (_, math) => `$$${math}$$`);
|
||||
|
||||
// 创建统一处理器实例,并配置处理流水线
|
||||
const processor = unified()
|
||||
// 使用 remark-parse 将 Markdown 文本解析为 MDAST
|
||||
.use(remarkParse)
|
||||
// .use(createDebugPlugin('After remarkParse'))
|
||||
// 先处理 <think> 标签,然后处理 citation
|
||||
.use(remarkThink)
|
||||
.use(remarkCitation)
|
||||
// 使用 remark-gfm 添加对 GFM 扩展语法的支持
|
||||
.use(remarkGfm)
|
||||
// 使用 remark-math 识别和解析数学公式语法
|
||||
.use(remarkMath)
|
||||
// 使用 remark-rehype 将 MDAST 转换为 HAST (HTML AST)
|
||||
.use(remarkRehype)
|
||||
// .use(createDebugPlugin('After remarkRehype'))
|
||||
// 使用 rehype-raw 允许保留原始 HTML 标签
|
||||
.use(rehypeRaw)
|
||||
// 使用 rehype-katex 将数学/化学公式渲染为 KaTeX
|
||||
.use(rehypeKatex, { throwOnError: false, strict: 'ignore' });
|
||||
// 添加 rehype-stringify 将 HAST 编译为 HTML 字符串
|
||||
// .use(rehypeStringify)
|
||||
|
||||
// 执行处理流程,将预处理后的内容转换为 HTML
|
||||
// const file = await processor.process(processed)
|
||||
// 返回处理结果中的 HTML 字符串
|
||||
// return file.value
|
||||
|
||||
// 先解析为MDAST
|
||||
const mdast = processor.parse(processed);
|
||||
// console.log('🌳 MDAST 节点类型统计:', mdast.children?.map((n) => n.type).join(', '))
|
||||
// 在进行转换
|
||||
const result = await processor.run(mdast);
|
||||
// console.log('✅ HAST 结果:', result)
|
||||
// console.log(
|
||||
// '🎯 HAST 子节点:',
|
||||
// result.children?.map((n) => ({ type: n.type, tagName: n.tagName })),
|
||||
// )
|
||||
return result;
|
||||
}
|
||||
30
src/renderer/components/base/AppIcon.vue
Normal file
30
src/renderer/components/base/AppIcon.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<component
|
||||
:is="icon"
|
||||
:size="size"
|
||||
:color="color"
|
||||
:stroke-width="strokeWidth"
|
||||
class="lucide-icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
icon: {
|
||||
type: Object, // 注意:这里接收的是 Lucide 图标组件对象
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 24,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'currentColor',
|
||||
},
|
||||
strokeWidth: {
|
||||
type: [Number, String],
|
||||
default: 2,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
42
src/renderer/components/base/LucideIcon.vue
Normal file
42
src/renderer/components/base/LucideIcon.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import * as LucideIcons from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 24,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'currentColor',
|
||||
},
|
||||
strokeWidth: {
|
||||
type: [Number, String],
|
||||
default: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
// Lucide 图标通常是 PascalCase,用户输入可能是 kebab-case
|
||||
const pascalName = props.name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
|
||||
return LucideIcons[pascalName] || LucideIcons[props.name] || null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="icon" v-if="icon" :size="size" :color="color" :stroke-width="strokeWidth" class="lucide-icon" />
|
||||
<span
|
||||
v-else
|
||||
class="lucide-icon-fallback"
|
||||
:style="{ width: size + 'px', height: size + 'px', display: 'inline-block' }"
|
||||
></span>
|
||||
</template>
|
||||
@@ -1,20 +1,28 @@
|
||||
import { getAction, postAction, deleteAction } from './manage.js'
|
||||
import url, { getBaseUrl } from './url.js'
|
||||
import { getAction, postAction, deleteAction } from './manage.js';
|
||||
import url, { getBaseUrl } from './url.js';
|
||||
import { encryptPassword } from '@/utils/crypto.js';
|
||||
|
||||
// 健康检查
|
||||
export const getHealthAction = () => getAction(url.health)
|
||||
export const getHealthAction = () => getAction(url.health);
|
||||
|
||||
// 用户登录
|
||||
export const loginAction = (data) => postAction(url.user.login, { email: data.email, password: encryptPassword(data.password) });
|
||||
|
||||
// 获取用户信息
|
||||
export const getUserInfoAction = () => getAction(url.user.getUserInfo);
|
||||
|
||||
// 会话
|
||||
export const createSessionAction = (data) => postAction(url.session.create, data)
|
||||
export const getSessionAction = (id) => getAction(url.session.detail(id))
|
||||
export const listSessionsAction = () => getAction(url.session.list)
|
||||
export const deleteSessionAction = (id) => deleteAction(url.session.delete(id))
|
||||
export const createSessionAction = (data) => postAction(url.session.create, data);
|
||||
export const getSessionAction = (id) => getAction(url.session.detail(id));
|
||||
export const listSessionsAction = () => getAction(url.session.list);
|
||||
export const deleteSessionAction = (id) => deleteAction(url.session.delete(id));
|
||||
|
||||
// 消息
|
||||
export const sendMessageAction = (sessionId, data) => postAction(url.message.send(sessionId), data)
|
||||
export const listMessagesAction = (sessionId) => getAction(url.message.list(sessionId))
|
||||
export const sendMessageAction = (sessionId, data) => postAction(url.message.send(sessionId), data);
|
||||
export const promptAsyncAction = (sessionId, data) => postAction(url.message.promptAsync(sessionId), data);
|
||||
export const listMessagesAction = (sessionId) => getAction(url.message.list(sessionId));
|
||||
|
||||
// SSE 事件流(返回 EventSource 实例,由调用方管理生命周期)
|
||||
export function createEventSource() {
|
||||
return new EventSource(`${getBaseUrl()}${url.event}`)
|
||||
return new EventSource(`${getBaseUrl()}${url.event}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getBaseUrl } from './url.js'
|
||||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getBaseUrl } from './url.js';
|
||||
import { useSparkStore } from '@/stores/spark';
|
||||
|
||||
// baseURL 由主进程动态分配端口,通过 getBaseUrl() 运行时获取
|
||||
const axiosInstance = axios.create({
|
||||
@@ -10,131 +11,143 @@ const axiosInstance = axios.create({
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
},
|
||||
responseType: 'json',
|
||||
})
|
||||
});
|
||||
|
||||
// 每次请求前动态更新 baseURL:优先用选中的 spark 设备地址,否则回退到 opencode 地址
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const sparkStore = useSparkStore();
|
||||
config.baseURL = sparkStore.selectedDeviceUrl || getBaseUrl();
|
||||
return config;
|
||||
});
|
||||
|
||||
// 请求拦截
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
config.headers = config.headers || {}
|
||||
let Authorization = localStorage.getItem('Authorization')
|
||||
config.headers = config.headers || {};
|
||||
let Authorization = localStorage.getItem('Authorization');
|
||||
// 优先使用本地持久化的 Authorization 头(完整值)
|
||||
config.headers.Authorization = Authorization || ''
|
||||
config.headers.Authorization = Authorization || '';
|
||||
|
||||
if ('get' === config?.method?.toLowerCase()) {
|
||||
if (config.params) {
|
||||
config.params.timestamp = new Date().getTime()
|
||||
config.params.timestamp = new Date().getTime();
|
||||
}
|
||||
}
|
||||
// 移除敏感信息日志
|
||||
// console.log(config, 'axios request.use config')
|
||||
return config
|
||||
})
|
||||
return config;
|
||||
});
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
// 移除敏感信息日志
|
||||
// console.log(response, 'response response')
|
||||
// 若请求为二进制下载(blob),直接透传响应,交由调用方自行处理
|
||||
try {
|
||||
const isBlob = response?.config?.responseType === 'blob'
|
||||
const isBlob = response?.config?.responseType === 'blob';
|
||||
if (isBlob) {
|
||||
// 仍然尝试持久化可能返回的 Authorization
|
||||
const respHeaders = response?.headers || {}
|
||||
const newAuthorization = respHeaders['authorization'] || respHeaders['Authorization']
|
||||
const respHeaders = response?.headers || {};
|
||||
const newAuthorization = respHeaders['authorization'] || respHeaders['Authorization'];
|
||||
if (newAuthorization && typeof newAuthorization === 'string') {
|
||||
localStorage.setItem('Authorization', newAuthorization)
|
||||
localStorage.setItem('Authorization', newAuthorization);
|
||||
}
|
||||
return response
|
||||
return response;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
console.log(e);
|
||||
// 忽略检查失败
|
||||
}
|
||||
// 如果响应头里带有 Authorization,则使用 useStorage 持久化到 localStorage,
|
||||
// 以便后续请求自动携带该请求头
|
||||
try {
|
||||
const respHeaders = response?.headers || {}
|
||||
const newAuthorization = respHeaders['authorization'] || respHeaders['Authorization']
|
||||
const respHeaders = response?.headers || {};
|
||||
const newAuthorization = respHeaders['authorization'] || respHeaders['Authorization'];
|
||||
if (newAuthorization && typeof newAuthorization === 'string') {
|
||||
localStorage.setItem('Authorization', newAuthorization)
|
||||
localStorage.setItem('Authorization', newAuthorization);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略持久化失败,避免影响主流程
|
||||
console.warn('持久化 Authorization 失败:', e)
|
||||
console.warn('持久化 Authorization 失败:', e);
|
||||
}
|
||||
// 204 No Content(如 prompt_async)直接视为成功
|
||||
if (response.status === 204) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
const res = response.data || {}
|
||||
const code = res.code
|
||||
const msg = res.message || res.msg
|
||||
const res = response.data || {};
|
||||
const code = res.code;
|
||||
const msg = res.message || res.msg;
|
||||
|
||||
// 明确的 200 成功,但需要按业务码再判断
|
||||
if (code === 0) {
|
||||
// 业务成功
|
||||
return Promise.resolve(res)
|
||||
return Promise.resolve(res);
|
||||
}
|
||||
|
||||
// 特殊业务码处理
|
||||
if (code === 401) {
|
||||
// 清除持久化的 Authorization,避免后续使用失效的头部
|
||||
localStorage.removeItem('Authorization')
|
||||
sessionStorage.removeItem('Token')
|
||||
localStorage.removeItem('Authorization');
|
||||
sessionStorage.removeItem('Token');
|
||||
// 延迟跳转,确保消息显示
|
||||
setTimeout(() => {
|
||||
window.location.href = '/#/login'
|
||||
}, 500)
|
||||
return Promise.reject(new Error('认证失败,请重新登录'))
|
||||
window.location.href = '/#/login';
|
||||
}, 500);
|
||||
return Promise.reject(new Error('认证失败,请重新登录'));
|
||||
}
|
||||
|
||||
// 其余非 0 的业务码统一拦截提示,但不在这里显示 ElMessage
|
||||
// 交由业务层使用 await-to-js 处理
|
||||
return Promise.reject(new Error(msg || '请求失败'))
|
||||
return Promise.reject(new Error(msg || '请求失败'));
|
||||
}
|
||||
|
||||
// 非 2xx 按错误分支处理(通常会进入 error 拦截器)
|
||||
return Promise.reject(new Error('请求失败'))
|
||||
return Promise.reject(new Error('请求失败'));
|
||||
},
|
||||
(error) => {
|
||||
console.error('请求错误:', error)
|
||||
console.error('请求错误:', error);
|
||||
|
||||
if (error.response) {
|
||||
// 服务器响应错误
|
||||
const status = error.response.status
|
||||
const message = error.response.data?.message || error.response.data?.msg || '请求失败'
|
||||
const status = error.response.status;
|
||||
const message = error.response.data?.message || error.response.data?.msg || '请求失败';
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
ElMessage.error(`无效的请求参数:${message}`)
|
||||
break
|
||||
ElMessage.error(`无效的请求参数:${message}`);
|
||||
break;
|
||||
case 401:
|
||||
// 清除持久化的 Authorization,避免后续使用失效的头部
|
||||
localStorage.removeItem('Authorization')
|
||||
ElMessage.error('未授权访问或登录已过期,请重新登录')
|
||||
break
|
||||
localStorage.removeItem('Authorization');
|
||||
ElMessage.error('未授权访问或登录已过期,请重新登录');
|
||||
break;
|
||||
case 403:
|
||||
ElMessage.error('访问被拒绝')
|
||||
break
|
||||
ElMessage.error('访问被拒绝');
|
||||
break;
|
||||
case 404:
|
||||
ElMessage.error('资源未找到')
|
||||
break
|
||||
ElMessage.error('资源未找到');
|
||||
break;
|
||||
case 500:
|
||||
ElMessage.error('服务器内部错误')
|
||||
break
|
||||
ElMessage.error('服务器内部错误');
|
||||
break;
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
ElMessage.error('服务暂时不可用,请稍后重试')
|
||||
break
|
||||
ElMessage.error('服务暂时不可用,请稍后重试');
|
||||
break;
|
||||
default:
|
||||
ElMessage.error(`请求失败: ${message}`)
|
||||
ElMessage.error(`请求失败: ${message}`);
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 网络错误
|
||||
ElMessage.error('网络连接失败,请检查网络连接')
|
||||
ElMessage.error('网络连接失败,请检查网络连接');
|
||||
} else {
|
||||
// 其他错误
|
||||
ElMessage.error('请求发送失败')
|
||||
ElMessage.error('请求发送失败');
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default axiosInstance
|
||||
export default axiosInstance;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import request from './index.js'
|
||||
import request from './index.js';
|
||||
|
||||
export function getAction(url, params) {
|
||||
return request({ url, method: 'GET', params })
|
||||
return request({ url, method: 'GET', params });
|
||||
}
|
||||
|
||||
export function postAction(url, data, headers = {}) {
|
||||
return request({ url, method: 'POST', data, headers })
|
||||
export function postAction(url, data, headers = {}, baseURL) {
|
||||
return request({ url, method: 'POST', data, headers, ...(baseURL ? { baseURL } : {}) });
|
||||
}
|
||||
|
||||
export function putAction(url, data) {
|
||||
return request({ url, method: 'PUT', data })
|
||||
return request({ url, method: 'PUT', data });
|
||||
}
|
||||
|
||||
export function deleteAction(url, params) {
|
||||
return request({ url, method: 'DELETE', params })
|
||||
return request({ url, method: 'DELETE', params });
|
||||
}
|
||||
|
||||
189
src/renderer/http/sse.js
Normal file
189
src/renderer/http/sse.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createEventSource } from './api.js';
|
||||
|
||||
/**
|
||||
* 全局 SSE 管理器
|
||||
* 负责管理 EventSource 连接,并将事件分发到注册的监听器
|
||||
*/
|
||||
class SSEManager {
|
||||
constructor() {
|
||||
this.eventSource = null;
|
||||
this.listeners = new Map();
|
||||
this.isConnected = false;
|
||||
this.eventBuffer = []; // 用于存储连接前产生的事件
|
||||
this.maxBufferSize = 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 SSE 连接
|
||||
*/
|
||||
connect() {
|
||||
if (this.eventSource) {
|
||||
console.log('[SSEManager] 连接已存在,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SSEManager] 建立 SSE 连接...');
|
||||
this.eventSource = createEventSource();
|
||||
this.isConnected = true;
|
||||
|
||||
this.eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log('[SSEManager] 收到事件:', data.type, data);
|
||||
this._dispatchEvent(data);
|
||||
} catch (err) {
|
||||
console.error('[SSEManager] 解析事件数据失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (err) => {
|
||||
console.error('[SSEManager] SSE 连接错误:', err);
|
||||
this.isConnected = false;
|
||||
// 触发错误监听器
|
||||
this._dispatchEvent({ type: 'sse.error', error: err });
|
||||
};
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
console.log('[SSEManager] SSE 连接已打开');
|
||||
this.isConnected = true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 SSE 连接
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.eventSource) {
|
||||
console.log('[SSEManager] 关闭 SSE 连接');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新连接 SSE
|
||||
*/
|
||||
reconnect() {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册事件监听器
|
||||
* @param {string} eventType - 事件类型,如 'message.part.updated'
|
||||
* @param {Function} callback - 回调函数
|
||||
* @returns {Function} 取消监听的函数
|
||||
*/
|
||||
on(eventType, callback) {
|
||||
if (!this.listeners.has(eventType)) {
|
||||
this.listeners.set(eventType, new Set());
|
||||
}
|
||||
this.listeners.get(eventType).add(callback);
|
||||
|
||||
// 返回取消监听的函数
|
||||
return () => {
|
||||
this.off(eventType, callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
* @param {string} eventType - 事件类型
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
off(eventType, callback) {
|
||||
if (this.listeners.has(eventType)) {
|
||||
this.listeners.get(eventType).delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发事件到所有注册的监听器
|
||||
* @param {Object} data - 事件数据
|
||||
*/
|
||||
_dispatchEvent(data) {
|
||||
const eventType = data.type;
|
||||
|
||||
// 存储到缓冲区
|
||||
this._bufferEvent(data);
|
||||
|
||||
// 分发到特定类型的监听器
|
||||
if (this.listeners.has(eventType)) {
|
||||
this.listeners.get(eventType).forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (err) {
|
||||
console.error(`[SSEManager] 监听器执行失败 (${eventType}):`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 分发到通配符监听器 (*)
|
||||
if (this.listeners.has('*')) {
|
||||
this.listeners.get('*').forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (err) {
|
||||
console.error('[SSEManager] 通配符监听器执行失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将事件存储到缓冲区
|
||||
* @param {Object} data - 事件数据
|
||||
*/
|
||||
_bufferEvent(data) {
|
||||
this.eventBuffer.push({
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// 限制缓冲区大小
|
||||
if (this.eventBuffer.length > this.maxBufferSize) {
|
||||
this.eventBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓冲区中的事件
|
||||
* @param {string} eventType - 可选的事件类型过滤
|
||||
* @param {number} since - 可选的时间戳,获取此时间之后的事件
|
||||
* @returns {Array} 事件列表
|
||||
*/
|
||||
getBufferedEvents(eventType, since) {
|
||||
let events = this.eventBuffer;
|
||||
|
||||
if (since) {
|
||||
events = events.filter((e) => e.timestamp > since);
|
||||
}
|
||||
|
||||
if (eventType) {
|
||||
events = events.filter((e) => e.data.type === eventType);
|
||||
}
|
||||
|
||||
return events.map((e) => e.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空事件缓冲区
|
||||
*/
|
||||
clearBuffer() {
|
||||
this.eventBuffer = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getConnectionStatus() {
|
||||
return this.isConnected;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const sseManager = new SSEManager();
|
||||
|
||||
export default sseManager;
|
||||
@@ -1,6 +1,6 @@
|
||||
// OpenCode 服务地址由主进程动态分配端口,通过 getBaseUrl() 获取
|
||||
export function getBaseUrl() {
|
||||
return window.__opencodeBaseUrl || 'http://127.0.0.1:4096'
|
||||
return window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
|
||||
}
|
||||
|
||||
const url = {
|
||||
@@ -18,11 +18,19 @@ const url = {
|
||||
// 消息
|
||||
message: {
|
||||
send: (sessionId) => `/session/${sessionId}/message`,
|
||||
promptAsync: (sessionId) => `/session/${sessionId}/prompt_async`,
|
||||
list: (sessionId) => `/session/${sessionId}/message`,
|
||||
},
|
||||
|
||||
// 用户
|
||||
user: {
|
||||
login: '/v1/user/login',
|
||||
// 获取用户信息接口
|
||||
getUserInfo: '/v1/user/info',
|
||||
},
|
||||
|
||||
// SSE 事件流
|
||||
event: '/event',
|
||||
}
|
||||
};
|
||||
|
||||
export default url
|
||||
export default url;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-screen w-screen overflow-hidden bg-gray-50">
|
||||
<!-- 侧边栏 -->
|
||||
<aside
|
||||
:class="[
|
||||
'flex flex-col bg-white border-r border-gray-200 transition-all duration-300',
|
||||
appStore.collapsed ? 'w-16' : 'w-56',
|
||||
]"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center h-14 px-4 border-b border-gray-200 shrink-0">
|
||||
<el-icon class="text-blue-500 text-xl shrink-0"><Monitor /></el-icon>
|
||||
<span v-if="!appStore.collapsed" class="ml-2 font-semibold text-gray-800 truncate">
|
||||
{{ appStore.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
:collapse="appStore.collapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="flex-1 border-none"
|
||||
>
|
||||
<el-menu-item index="/">
|
||||
<el-icon><House /></el-icon>
|
||||
<template #title>首页</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/chat">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<template #title>OpenCode 对话</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 折叠按钮 -->
|
||||
<div class="p-3 border-t border-gray-200">
|
||||
<el-button
|
||||
:icon="appStore.collapsed ? Expand : Fold"
|
||||
circle
|
||||
size="small"
|
||||
@click="appStore.toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex flex-col flex-1 overflow-hidden">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="flex items-center justify-between h-14 px-6 bg-white border-b border-gray-200 shrink-0">
|
||||
<h1 class="text-base font-medium text-gray-700">{{ currentTitle }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-avatar :size="32" class="bg-blue-500">U</el-avatar>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="flex-1 overflow-hidden p-6">
|
||||
<div class="h-full overflow-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { House, Monitor, Expand, Fold, Edit, ChatDotRound } from '@element-plus/icons-vue';
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const currentTitle = computed(() => route.meta?.title || appStore.title);
|
||||
</script>
|
||||
@@ -5,16 +5,23 @@ import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
||||
import 'element-plus/dist/index.css';
|
||||
import router from './router';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
import './styles/index.css';
|
||||
import AppIcon from './components/base/AppIcon.vue';
|
||||
import LucideIcon from './components/base/LucideIcon.vue';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// 注册所有 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component);
|
||||
}
|
||||
|
||||
app.use(createPinia());
|
||||
// 注册自定义图标组件
|
||||
app.component('AppIcon', AppIcon);
|
||||
app.component('LucideIcon', LucideIcon);
|
||||
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(ElementPlus);
|
||||
|
||||
|
||||
@@ -3,21 +3,27 @@ import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/HomeView.vue'),
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/chat/ChatView.vue'),
|
||||
meta: { title: 'OpenCode 对话' },
|
||||
},
|
||||
],
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/HomeView.vue'),
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
{
|
||||
path: '/chat/:id?',
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/chat/ChatView.vue'),
|
||||
meta: { title: 'OpenCode 对话' },
|
||||
},
|
||||
{
|
||||
path: '/bonjour',
|
||||
name: 'Bonjour',
|
||||
component: () => import('@/views/bonjour/BonjourView.vue'),
|
||||
meta: { title: '发现设备' },
|
||||
},
|
||||
{
|
||||
path: '/test',
|
||||
name: 'Test',
|
||||
component: () => import('@/views/test/TestView.vue'),
|
||||
meta: { title: '测试页' },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,13 +1,157 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { sseManager } from '@/http/sse.js';
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const title = ref('My App');
|
||||
// 服务运行状态常量
|
||||
const SERVICE_STATUS = {
|
||||
IDLE: 'idle', // 未启动
|
||||
CONNECTING: 'connecting', // 连接中
|
||||
RUNNING: 'running', // 服务运行中
|
||||
FAILED: 'failed', // 连接失败
|
||||
};
|
||||
|
||||
const title = ref('智聚超脑');
|
||||
const collapsed = ref(false);
|
||||
const serviceStatus = ref(SERVICE_STATUS.IDLE);
|
||||
const startServiceFlag = ref(0); // 用于触发外部启动服务的标记
|
||||
|
||||
function triggerStartService() {
|
||||
startServiceFlag.value++;
|
||||
}
|
||||
|
||||
// SSE 相关状态
|
||||
const sseConnected = ref(false);
|
||||
const currentSessionEvents = ref([]);
|
||||
const assistantMessageIds = ref(new Set());
|
||||
|
||||
function toggleSidebar() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
return { title, collapsed, toggleSidebar };
|
||||
/**
|
||||
* 初始化 SSE 连接
|
||||
* 在连接 OpenCode 成功后调用
|
||||
*/
|
||||
function initSSE() {
|
||||
if (sseConnected.value) {
|
||||
console.log('[AppStore] SSE 已连接,跳过初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[AppStore] 初始化 SSE 连接...');
|
||||
|
||||
// 监听连接状态
|
||||
sseManager.on('sse.error', () => {
|
||||
sseConnected.value = false;
|
||||
});
|
||||
|
||||
// 监听消息部分更新事件
|
||||
sseManager.on('message.part.updated', (data) => {
|
||||
const props = data.properties || {};
|
||||
const part = props.part;
|
||||
if (!part || part.type !== 'text') return;
|
||||
|
||||
currentSessionEvents.value.push({
|
||||
type: 'message.part.updated',
|
||||
data: props,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// 监听消息更新事件
|
||||
sseManager.on('message.updated', (data) => {
|
||||
const props = data.properties || {};
|
||||
const info = props.info;
|
||||
if (info && info.role === 'assistant') {
|
||||
assistantMessageIds.value.add(info.id);
|
||||
}
|
||||
|
||||
currentSessionEvents.value.push({
|
||||
type: 'message.updated',
|
||||
data: props,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// 监听会话空闲事件
|
||||
sseManager.on('session.idle', (data) => {
|
||||
const props = data.properties || {};
|
||||
currentSessionEvents.value.push({
|
||||
type: 'session.idle',
|
||||
data: props,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// 建立连接
|
||||
sseManager.connect();
|
||||
sseConnected.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 SSE 连接
|
||||
*/
|
||||
function closeSSE() {
|
||||
sseManager.disconnect();
|
||||
sseConnected.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新连接 SSE
|
||||
*/
|
||||
function reconnectSSE() {
|
||||
sseManager.reconnect();
|
||||
sseConnected.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空当前会话的事件
|
||||
*/
|
||||
function clearSessionEvents() {
|
||||
currentSessionEvents.value = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查消息 ID 是否是助手消息
|
||||
*/
|
||||
function isAssistantMessage(messageId) {
|
||||
return assistantMessageIds.value.has(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加助手消息 ID
|
||||
*/
|
||||
function addAssistantMessageId(messageId) {
|
||||
assistantMessageIds.value.add(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空助手消息 ID 集合
|
||||
*/
|
||||
function clearAssistantMessageIds() {
|
||||
assistantMessageIds.value.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
collapsed,
|
||||
toggleSidebar,
|
||||
// SSE
|
||||
sseConnected,
|
||||
currentSessionEvents,
|
||||
assistantMessageIds,
|
||||
initSSE,
|
||||
closeSSE,
|
||||
reconnectSSE,
|
||||
clearSessionEvents,
|
||||
isAssistantMessage,
|
||||
addAssistantMessageId,
|
||||
clearAssistantMessageIds,
|
||||
// 服务运行状态
|
||||
serviceStatus,
|
||||
SERVICE_STATUS,
|
||||
startServiceFlag,
|
||||
triggerStartService,
|
||||
};
|
||||
});
|
||||
|
||||
66
src/renderer/stores/draft.js
Normal file
66
src/renderer/stores/draft.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 草稿消息 Store
|
||||
* 用于在页面间传递待发送的消息内容(文本和文件)
|
||||
*/
|
||||
export const useDraftStore = defineStore('draft', () => {
|
||||
// 待发送的文本内容
|
||||
const text = ref('');
|
||||
|
||||
// 待发送的文件列表
|
||||
// 格式: [{ id, filename, mime, type: File, url }]
|
||||
// url: 图片为 base64 (data:image/...),文本文件为 data:text/plain;base64,... 等
|
||||
const files = ref([]);
|
||||
|
||||
/**
|
||||
* 设置草稿内容
|
||||
* @param {string} content - 文本内容
|
||||
* @param {Array} fileList - 文件列表
|
||||
*/
|
||||
function setDraft(content, fileList = []) {
|
||||
text.value = content;
|
||||
files.value = fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿内容并清空
|
||||
* @returns {{text: string, files: Array}}
|
||||
*/
|
||||
function takeDraft() {
|
||||
const result = {
|
||||
text: text.value,
|
||||
files: [...files.value],
|
||||
};
|
||||
// 清空草稿
|
||||
text.value = '';
|
||||
files.value = [];
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空草稿
|
||||
*/
|
||||
function clearDraft() {
|
||||
text.value = '';
|
||||
files.value = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有草稿内容
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasDraft() {
|
||||
return text.value.trim().length > 0 || files.value.length > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
files,
|
||||
setDraft,
|
||||
takeDraft,
|
||||
clearDraft,
|
||||
hasDraft,
|
||||
};
|
||||
});
|
||||
104
src/renderer/stores/history.js
Normal file
104
src/renderer/stores/history.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
export const useHistoryStore = defineStore('history', () => {
|
||||
// State
|
||||
const historyItems = ref([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Getters
|
||||
const getHistoryItems = () => historyItems.value;
|
||||
const getHistoryById = (id) => historyItems.value.find((item) => item.id === id);
|
||||
|
||||
// Actions
|
||||
/**
|
||||
* 加载历史会话列表
|
||||
*/
|
||||
async function loadHistorySessions() {
|
||||
console.log('[historyStore] 开始加载历史会话...');
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
|
||||
console.log('[historyStore] baseUrl:', baseUrl);
|
||||
const response = await axios.get(`${baseUrl}/session`);
|
||||
console.log('[historyStore] 响应数据:', response.data);
|
||||
const sessions = response.data || [];
|
||||
console.log('[historyStore] 会话数量:', sessions.length);
|
||||
|
||||
// 将会话列表转换为历史记录格式
|
||||
historyItems.value = sessions.map((session) => ({
|
||||
id: session.id,
|
||||
name: session.title || `会话 ${session.slug || session.id.slice(0, 8)}...`,
|
||||
slug: session.slug,
|
||||
created: session.time?.created,
|
||||
updated: session.time?.updated,
|
||||
}));
|
||||
console.log('[historyStore] 转换后的历史记录:', historyItems.value);
|
||||
} catch (err) {
|
||||
console.error('[historyStore] 加载历史会话失败:', err);
|
||||
historyItems.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空历史记录
|
||||
*/
|
||||
function clearHistory() {
|
||||
historyItems.value = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单个历史记录项(用于新创建的会话)
|
||||
*/
|
||||
function addHistoryItem(item) {
|
||||
// 检查是否已存在
|
||||
const exists = historyItems.value.find((h) => h.id === item.id);
|
||||
if (!exists) {
|
||||
historyItems.value.unshift({
|
||||
id: item.id,
|
||||
name: item.title || `会话 ${item.slug || item.id.slice(0, 8)}...`,
|
||||
slug: item.slug,
|
||||
created: item.time?.created,
|
||||
updated: item.time?.updated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
* @param {string} title - 会话标题
|
||||
* @returns {Promise<Object>} 创建的会话对象
|
||||
*/
|
||||
async function createSession(title) {
|
||||
console.log('[historyStore] 创建新会话,title:', title);
|
||||
try {
|
||||
const session = await window.opencode.createSession({ title });
|
||||
console.log('[historyStore] 创建会话成功:', session);
|
||||
|
||||
// 添加到历史记录
|
||||
addHistoryItem(session);
|
||||
|
||||
return session;
|
||||
} catch (err) {
|
||||
console.error('[historyStore] 创建会话失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
historyItems,
|
||||
isLoading,
|
||||
// Getters
|
||||
getHistoryItems,
|
||||
getHistoryById,
|
||||
// Actions
|
||||
loadHistorySessions,
|
||||
clearHistory,
|
||||
addHistoryItem,
|
||||
createSession,
|
||||
};
|
||||
});
|
||||
55
src/renderer/stores/spark.js
Normal file
55
src/renderer/stores/spark.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const STORAGE_KEY = 'selected_spark_device';
|
||||
|
||||
export const useSparkStore = defineStore('spark', () => {
|
||||
// 运行时发现的设备列表(动态,不持久化)
|
||||
const devices = ref([]);
|
||||
|
||||
// 当前选中的设备,启动时从 localStorage 恢复
|
||||
const selectedDevice = ref(JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'));
|
||||
|
||||
// 选中设备的完整 URL,优先取 IPv4 地址
|
||||
const selectedDeviceUrl = computed(() => {
|
||||
if (!selectedDevice.value) return null;
|
||||
const { addresses, port, referer } = selectedDevice.value;
|
||||
// 优先找 IPv4(不含冒号的地址)
|
||||
const ipv4 = addresses?.find((a) => !a.includes(':'));
|
||||
// 兜底用 referer.address(mDNS 响应来源 IP)
|
||||
const ip = ipv4 || referer?.address;
|
||||
return ip ? `http://${ip}:${port}` : null;
|
||||
});
|
||||
|
||||
// 更新设备列表(由 BonjourView 调用)
|
||||
function setDevices(list) {
|
||||
devices.value = list;
|
||||
|
||||
// 如果之前选中的设备还在列表里,用最新数据刷新它
|
||||
if (selectedDevice.value) {
|
||||
const fresh = list.find((d) => d.name === selectedDevice.value.name);
|
||||
if (fresh) selectDevice(fresh);
|
||||
}
|
||||
}
|
||||
|
||||
// 选中某台设备,并持久化到 localStorage
|
||||
function selectDevice(device) {
|
||||
selectedDevice.value = device;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(device));
|
||||
}
|
||||
|
||||
// 清除选中
|
||||
function clearSelectedDevice() {
|
||||
selectedDevice.value = null;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
return {
|
||||
devices,
|
||||
selectedDevice,
|
||||
selectedDeviceUrl,
|
||||
setDevices,
|
||||
selectDevice,
|
||||
clearSelectedDevice,
|
||||
};
|
||||
});
|
||||
25
src/renderer/stores/user.js
Normal file
25
src/renderer/stores/user.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const STORAGE_KEY = 'user_info';
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const userInfo = ref(JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'));
|
||||
|
||||
const nickname = computed(() => userInfo.value?.nickname || '');
|
||||
const email = computed(() => userInfo.value?.email || '');
|
||||
const isLoggedIn = computed(() => !!userInfo.value);
|
||||
|
||||
function setUserInfo(info) {
|
||||
userInfo.value = info;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(info));
|
||||
}
|
||||
|
||||
function clearUserInfo() {
|
||||
userInfo.value = null;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem('Authorization');
|
||||
}
|
||||
|
||||
return { userInfo, nickname, email, isLoggedIn, setUserInfo, clearUserInfo };
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* =====================
|
||||
shadcn CSS 变量 (light)
|
||||
===================== */
|
||||
@@ -111,6 +109,15 @@ body,
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
a,
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
3
src/renderer/styles/index.css
Normal file
3
src/renderer/styles/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
25
src/renderer/utils/crypto.js
Normal file
25
src/renderer/utils/crypto.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
import JSEncrypt from 'jsencrypt';
|
||||
|
||||
// RSA 公钥,替换为实际公钥内容
|
||||
const RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArq9XTUSeYr2+N1h3Afl/
|
||||
z8Dse/2yD0ZGrKwx+EEEcdsBLca9Ynmx3nIB5obmLlSfmskLpBo0UACBmB5rEjBp
|
||||
2Q2f3AG3Hjd4B+gNCG6BDaawuDlgANIhGnaTLrIqWrrcm4EMzJOnAOI1fgzJRsOO
|
||||
UEfaS318Eq9OVO3apEyCCt0lOQK6PuksduOjVxtltDav+guVAA068NrPYmRNabVK
|
||||
RNLJpL8w4D44sfth5RvZ3q9t+6RTArpEtc5sh5ChzvqPOzKGMXW83C95TxmXqpbK
|
||||
6olN4RevSfVjEAgCydH6HN6OhtOQEcnrU97r9H0iZOWwbw3pVrZiUkuRD1R56Wzs
|
||||
2wIDAQAB
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
/**
|
||||
* 对密码进行加密:Base64 编码 → RSA 加密
|
||||
* @param {string} password 原始密码
|
||||
* @returns {string|false} 加密后的密文,失败返回 false
|
||||
*/
|
||||
export function encryptPassword(password) {
|
||||
const base64Pwd = Base64.encode(password);
|
||||
const encrypt = new JSEncrypt();
|
||||
encrypt.setPublicKey(RSA_PUBLIC_KEY);
|
||||
return encrypt.encrypt(base64Pwd);
|
||||
}
|
||||
143
src/renderer/views/bonjour/BonjourView.vue
Normal file
143
src/renderer/views/bonjour/BonjourView.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { Search, Refresh } from '@element-plus/icons-vue';
|
||||
import { useSparkStore } from '@/stores/spark';
|
||||
|
||||
const sparkStore = useSparkStore();
|
||||
const services = ref([]);
|
||||
const loading = ref(false);
|
||||
let unsubscribe = null;
|
||||
|
||||
const filterServices = (list) => list.filter((s) => s.type === 'polygence-spark');
|
||||
|
||||
const fetchServices = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const all = await window.bonjour.getServices();
|
||||
const filtered = filterServices(all);
|
||||
services.value = filtered;
|
||||
sparkStore.setDevices(filtered);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bonjour services:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchServices();
|
||||
unsubscribe = window.bonjour.onServicesUpdated((updatedServices) => {
|
||||
const filtered = filterServices(updatedServices);
|
||||
services.value = filtered;
|
||||
sparkStore.setDevices(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bonjour-container">
|
||||
<div class="header">
|
||||
<div class="title-section">
|
||||
<h2 class="title">发现设备</h2>
|
||||
<p class="subtitle">局域网内已发现的 mDNS / Bonjour 设备</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="fetchServices" circle />
|
||||
</div>
|
||||
|
||||
<el-table :data="services" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="100" />
|
||||
<el-table-column prop="protocol" label="协议" width="80" />
|
||||
<el-table-column prop="port" label="端口" width="80" />
|
||||
<el-table-column label="地址" min-width="180">
|
||||
<template #default="scope">
|
||||
<div v-for="addr in scope.row.addresses" :key="addr" class="address-tag">
|
||||
{{ addr }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="TXT 记录" min-width="200">
|
||||
<template #default="scope">
|
||||
<div v-if="Object.keys(scope.row.txt || {}).length > 0">
|
||||
<div v-for="(val, key) in scope.row.txt" :key="key" class="txt-record">
|
||||
<span class="txt-key">{{ key }}:</span> {{ val }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="empty-txt">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="services.length === 0 && !loading" class="empty-state">
|
||||
<el-empty description="暂未发现设备" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bonjour-container {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.address-tag {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.txt-record {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.txt-key {
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.empty-txt {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -1,197 +1,459 @@
|
||||
<template>
|
||||
<div class="chat-page">
|
||||
<!-- 服务状态栏 -->
|
||||
<div class="status-bar">
|
||||
<div class="status-indicator">
|
||||
<span class="dot" :class="serviceStatus" />
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
</div>
|
||||
<div class="status-actions">
|
||||
<el-button v-if="!isRunning" size="small" type="primary" :loading="isStarting" @click="startService">
|
||||
启动服务
|
||||
</el-button>
|
||||
<el-button v-else size="small" type="danger" plain @click="stopService">
|
||||
停止服务
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div ref="messagesRef" class="messages">
|
||||
<div v-if="messages.length === 0" class="empty-hint">
|
||||
<el-icon :size="40" color="#c0c4cc"><ChatDotRound /></el-icon>
|
||||
<p>启动服务后开始对话</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="bubble-wrap"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div v-for="msg in messages" :key="msg.id" class="bubble-wrap" :class="msg.role">
|
||||
<div class="bubble">
|
||||
<pre class="bubble-text">{{ msg.text }}</pre>
|
||||
<!-- 用户消息中的文件附件 -->
|
||||
<div v-if="msg.files && msg.files.length > 0" class="msg-files">
|
||||
<div v-for="f in msg.files" :key="f.id" class="msg-file-item">
|
||||
<img v-if="f.mime && f.mime.startsWith('image/')" :src="f.url" class="msg-file-img" :alt="f.filename" />
|
||||
<div v-else class="msg-file-doc">
|
||||
<el-icon :size="16"><Document /></el-icon>
|
||||
<span class="msg-file-name">{{ f.filename }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 推理过程(仅助手消息显示) -->
|
||||
<div v-if="msg.parts?.filter((p) => p.type === 'reasoning').length > 0" class="reasoning-section">
|
||||
<div class="reasoning-header" @click="msg.showReasoning = !msg.showReasoning">
|
||||
<el-icon :size="14"><ArrowRight v-if="!msg.showReasoning" /><ArrowDown v-else /></el-icon>
|
||||
<span>思考过程</span>
|
||||
</div>
|
||||
<div v-show="msg.showReasoning" class="reasoning-content">
|
||||
<div v-for="(r, idx) in msg.parts.filter((p) => p.type === 'reasoning')" :key="idx" class="reasoning-item">
|
||||
{{ r.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 文本内容 -->
|
||||
<MarkdownRender v-if="msg.text" :content="msg.text"></MarkdownRender>
|
||||
<!-- tool 类型 part 展示 -->
|
||||
<template v-if="msg.parts">
|
||||
<template v-for="(p, idx) in msg.parts.filter((p) => p.type === 'tool')" :key="'tool-' + idx">
|
||||
<!-- question 工具:展示问答卡片(历史加载,状态为 running 时仍可回复) -->
|
||||
<div v-if="p.tool === 'question'" class="question-part">
|
||||
<div v-for="(q, qi) in p.state?.input?.questions || []" :key="qi" class="question-item">
|
||||
<div class="question-header">{{ q.header }}</div>
|
||||
<div class="question-text">{{ q.question }}</div>
|
||||
<div class="question-options">
|
||||
<div
|
||||
v-for="(opt, oi) in q.options"
|
||||
:key="oi"
|
||||
class="question-option"
|
||||
:class="{ disabled: p.state?.status !== 'running' }"
|
||||
@click="p.state?.status === 'running' && p._questionId && sendAnswer(p._questionId, opt.label)"
|
||||
>
|
||||
<span class="option-radio"></span>
|
||||
<span class="option-content">
|
||||
<span class="option-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 普通工具:展示终端卡片 -->
|
||||
<div v-else class="tool-part">
|
||||
<div class="tool-header" @click="p._expanded = !p._expanded">
|
||||
<el-icon :size="13"><ArrowRight v-if="!p._expanded" /><ArrowDown v-else /></el-icon>
|
||||
<span class="tool-name">{{ p.tool }}</span>
|
||||
<span class="tool-desc">{{ p.state?.input?.description || p.state?.title || '' }}</span>
|
||||
<span class="tool-status" :class="'status-' + (p.state?.status || 'unknown')">{{ p.state?.status || '' }}</span>
|
||||
</div>
|
||||
<div v-show="p._expanded" class="tool-body">
|
||||
<div v-if="p.state?.input?.command" class="tool-command">
|
||||
<span class="tool-label">$ </span><code>{{ p.state.input.command }}</code>
|
||||
</div>
|
||||
<div v-if="p.state?.input?.filePath" class="tool-command">
|
||||
<span class="tool-label">path: </span><code>{{ p.state.input.filePath }}</code>
|
||||
</div>
|
||||
<pre v-if="p.state?.output" class="tool-output">{{ p.state.output }}</pre>
|
||||
<div v-if="p.state?.error" class="tool-error">{{ p.state.error }}</div>
|
||||
<div v-if="p.state?.time" class="tool-time">耗时 {{ p.state.time.end - p.state.time.start }} ms</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 未知类型 part 原始展示 -->
|
||||
<template v-if="msg.parts">
|
||||
<div
|
||||
v-for="(p, idx) in msg.parts.filter(
|
||||
(p) =>
|
||||
p.type !== 'text' &&
|
||||
p.type !== 'reasoning' &&
|
||||
p.type !== 'file' &&
|
||||
p.type !== 'tool' &&
|
||||
p.type !== 'question' &&
|
||||
p.type !== 'step-start' &&
|
||||
p.type !== 'step-finish'
|
||||
)"
|
||||
:key="'raw-' + idx"
|
||||
class="raw-part"
|
||||
>
|
||||
<span class="raw-part-type">[{{ p.type }}]</span>
|
||||
<pre class="raw-part-content">{{ JSON.stringify(p, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<div class="input-area">
|
||||
<div v-if="!hasActiveQuestion" class="input-area">
|
||||
<el-input
|
||||
v-model="inputText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
placeholder="输入消息,Ctrl+Enter 发送"
|
||||
:disabled="!isRunning || isSending"
|
||||
:disabled="isSending"
|
||||
resize="none"
|
||||
@keydown.ctrl.enter.prevent="send"
|
||||
@keydown.enter="send"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!isRunning || isSending || !inputText.trim()"
|
||||
:loading="isSending"
|
||||
@click="send"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="isSending || !inputText.trim()" :loading="isSending" @click="send"> 发送 </el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ChatDotRound } from '@element-plus/icons-vue'
|
||||
import { createEventSource } from '@/http/api.js'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch, reactive } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ChatDotRound, ArrowRight, ArrowDown, Document } from '@element-plus/icons-vue';
|
||||
import { useAppStore } from '@/stores/app.js';
|
||||
import { useDraftStore } from '@/stores/draft.js';
|
||||
import { sseManager } from '@/http/sse.js';
|
||||
import axios from 'axios';
|
||||
import MarkdownRender from '@/components/MarkdownRender/index.vue';
|
||||
|
||||
const isRunning = ref(false)
|
||||
const isStarting = ref(false)
|
||||
const isSending = ref(false)
|
||||
const inputText = ref('')
|
||||
const messages = ref([])
|
||||
const messagesRef = ref(null)
|
||||
const currentSessionId = ref(null)
|
||||
let eventSource = null
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const draftStore = useDraftStore();
|
||||
const isSending = ref(false);
|
||||
const inputText = ref('');
|
||||
const messages = ref([]);
|
||||
const messagesRef = ref(null);
|
||||
const currentSessionId = ref(null);
|
||||
const localAssistantMessageIds = new Set();
|
||||
const pendingFiles = ref([]); // 待发送的文件列表
|
||||
|
||||
const serviceStatus = computed(() => {
|
||||
if (isStarting.value) return 'starting'
|
||||
return isRunning.value ? 'running' : 'stopped'
|
||||
})
|
||||
// 生成唯一 ID
|
||||
function generateId() {
|
||||
return 'prt_' + Date.now() + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isStarting.value) return '正在启动...'
|
||||
return isRunning.value ? '服务运行中' : '服务未启动'
|
||||
})
|
||||
// 是否有正在等待回答的 question
|
||||
const hasActiveQuestion = computed(() => {
|
||||
for (const msg of messages.value) {
|
||||
if (!msg.parts) continue;
|
||||
for (const p of msg.parts) {
|
||||
if (p.type === 'tool' && p.tool === 'question' && p.state?.status === 'running' && p._questionId) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
let unsubscribeCallbacks = [];
|
||||
|
||||
// 从路由参数中获取 sessionId
|
||||
const routeSessionId = computed(() => route.params.id || route.query.sessionId);
|
||||
|
||||
// 加载历史消息
|
||||
async function loadHistoryMessages(sessionId) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
|
||||
const requestUrl = `${baseUrl}/session/${sessionId}/message`;
|
||||
console.log('[loadHistoryMessages] ========================================');
|
||||
console.log('[loadHistoryMessages] 开始获取消息,sessionId:', sessionId);
|
||||
console.log('[loadHistoryMessages] 请求URL:', requestUrl);
|
||||
|
||||
const response = await axios.get(requestUrl);
|
||||
const messagesData = response.data || [];
|
||||
|
||||
console.log('[loadHistoryMessages] 获取成功!消息数量:', messagesData.length);
|
||||
console.log('[loadHistoryMessages] 原始消息数据 (JSON):', JSON.stringify(messagesData, null, 2));
|
||||
console.log('[loadHistoryMessages] ========================================');
|
||||
|
||||
// 清空当前消息
|
||||
messages.value = [];
|
||||
localAssistantMessageIds.clear();
|
||||
appStore.clearAssistantMessageIds();
|
||||
|
||||
// 处理历史消息
|
||||
messagesData.forEach((item) => {
|
||||
const { info, parts } = item;
|
||||
if (!info || !parts) return;
|
||||
|
||||
// 过滤掉 synthetic 为 true 的 part
|
||||
const filteredParts = parts.filter((p) => !p.synthetic);
|
||||
|
||||
// 提取文本内容(用于展示)
|
||||
const text = filteredParts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('');
|
||||
|
||||
// 提取文件列表
|
||||
const files = filteredParts.filter((p) => p.type === 'file');
|
||||
|
||||
// 为每个 part 初始化响应式字段
|
||||
const normalizedParts = filteredParts.map((p) => (p.type === 'tool' ? { ...p, _expanded: true, _freeInput: p.tool === 'question' ? '' : undefined } : p));
|
||||
|
||||
if (text || files.length > 0 || info.role === 'assistant') {
|
||||
messages.value.push({
|
||||
id: info.id,
|
||||
role: info.role,
|
||||
text: text,
|
||||
files: files,
|
||||
parts: normalizedParts,
|
||||
showReasoning: true, // 默认展开推理过程
|
||||
});
|
||||
|
||||
// 记录 assistant 消息 ID
|
||||
if (info.role === 'assistant') {
|
||||
localAssistantMessageIds.add(info.id);
|
||||
appStore.addAssistantMessageId(info.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[loadHistoryMessages] 处理后的消息列表:', messages.value);
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
console.error('加载历史消息失败:', err);
|
||||
ElMessage.error(`加载历史消息失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由参数变化,加载对应会话
|
||||
watch(
|
||||
routeSessionId,
|
||||
async (newSessionId) => {
|
||||
if (newSessionId) {
|
||||
currentSessionId.value = newSessionId;
|
||||
// 等待历史消息加载完毕,避免在 send 时 messages 被清空
|
||||
await loadHistoryMessages(newSessionId);
|
||||
|
||||
// 处理从首页带过来的初始消息(从 draft store 获取)
|
||||
if (draftStore.hasDraft()) {
|
||||
const draft = draftStore.takeDraft();
|
||||
inputText.value = draft.text;
|
||||
pendingFiles.value = draft.files;
|
||||
|
||||
// 触发发送逻辑
|
||||
if (inputText.value.trim()) {
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 SSE 连接已建立
|
||||
if (!appStore.sseConnected) {
|
||||
appStore.initSSE();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function upsertAssistantBubble(msgId, text) {
|
||||
const existing = messages.value.find(m => m.id === msgId)
|
||||
if (existing) {
|
||||
existing.text = text
|
||||
function upsertAssistantPart(msgId, part) {
|
||||
let existing = messages.value.find((m) => m.id === msgId);
|
||||
if (!existing) {
|
||||
existing = { id: msgId, role: 'assistant', text: '', parts: [], showReasoning: true };
|
||||
messages.value.push(existing);
|
||||
}
|
||||
if (!existing.parts) existing.parts = [];
|
||||
|
||||
// 找到同类型同 partID 的 part,有则更新,无则追加
|
||||
const partId = part.id || part.partID;
|
||||
const idx = partId ? existing.parts.findIndex((p) => (p.id || p.partID) === partId) : -1;
|
||||
if (idx >= 0) {
|
||||
const preserved = { _expanded: existing.parts[idx]._expanded, _freeInput: existing.parts[idx]._freeInput, _questionId: existing.parts[idx]._questionId };
|
||||
existing.parts[idx] = { ...existing.parts[idx], ...part, ...preserved };
|
||||
} else {
|
||||
messages.value.push({ id: msgId, role: 'assistant', text })
|
||||
const newPart = { ...part };
|
||||
if (newPart.type === 'tool' && newPart._expanded === undefined) newPart._expanded = true;
|
||||
if (newPart.type === 'tool' && newPart.tool === 'question' && newPart._freeInput === undefined) newPart._freeInput = '';
|
||||
existing.parts.push(newPart);
|
||||
}
|
||||
scrollToBottom()
|
||||
|
||||
// 同步 text 字段(取所有 text 类型 part 拼接)
|
||||
existing.text = existing.parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text || '')
|
||||
.join('');
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close()
|
||||
eventSource = createEventSource()
|
||||
/**
|
||||
* 注册 SSE 事件监听器
|
||||
*/
|
||||
function registerSSEListeners() {
|
||||
// 监听消息部分更新事件
|
||||
const unsubscribePartUpdated = sseManager.on('message.part.updated', (data) => {
|
||||
const props = data.properties || {};
|
||||
const part = props.part;
|
||||
if (!part) return;
|
||||
if (part.sessionID !== currentSessionId.value) return;
|
||||
// 通过 messageID 前缀区分用户/助手消息:只渲染 assistant 消息的 part
|
||||
// assistant 消息的 messageID 会在 message.updated 事件中记录,用 localAssistantMessageIds 集合过滤
|
||||
if (!localAssistantMessageIds.has(part.messageID) && !appStore.isAssistantMessage(part.messageID)) return;
|
||||
upsertAssistantPart(part.messageID, part);
|
||||
});
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
const props = data.properties || {}
|
||||
// 监听消息更新事件
|
||||
const unsubscribeMessageUpdated = sseManager.on('message.updated', (data) => {
|
||||
const props = data.properties || {};
|
||||
const info = props.info;
|
||||
// 记录 assistant 消息的 ID,供 message.part.updated 过滤使用
|
||||
if (info && info.role === 'assistant' && info.sessionID === currentSessionId.value) {
|
||||
localAssistantMessageIds.add(info.id);
|
||||
appStore.addAssistantMessageId(info.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (data.type === 'message.part.updated') {
|
||||
const part = props.part
|
||||
if (!part || part.type !== 'text') return
|
||||
if (part.sessionID !== currentSessionId.value) return
|
||||
upsertAssistantBubble(part.messageID, part.text || '')
|
||||
// 监听会话空闲事件
|
||||
const unsubscribeSessionIdle = sseManager.on('session.idle', (data) => {
|
||||
const props = data.properties || {};
|
||||
// session.idle 表示 AI 响应已全部完成,重置发送状态
|
||||
if (props.sessionID === currentSessionId.value) {
|
||||
isSending.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 question.asked 事件
|
||||
const unsubscribeQuestionAsked = sseManager.on('question.asked', (data) => {
|
||||
const props = data.properties || {};
|
||||
if (props.sessionID !== currentSessionId.value) return;
|
||||
const msgId = props.tool?.messageID;
|
||||
const callId = props.tool?.callID;
|
||||
if (!msgId) return;
|
||||
// 将 question id 关联到对应的 tool part(通过 callID 匹配)
|
||||
if (callId) {
|
||||
const msg = messages.value.find((m) => m.id === msgId);
|
||||
if (msg && msg.parts) {
|
||||
const toolPart = msg.parts.find((p) => p.type === 'tool' && p.callID === callId);
|
||||
if (toolPart) toolPart._questionId = props.id;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (data.type === 'message.completed') {
|
||||
isSending.value = false
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
isSending.value = false
|
||||
}
|
||||
// 保存取消订阅函数
|
||||
unsubscribeCallbacks.push(unsubscribePartUpdated, unsubscribeMessageUpdated, unsubscribeSessionIdle, unsubscribeQuestionAsked);
|
||||
}
|
||||
|
||||
async function startService() {
|
||||
isStarting.value = true
|
||||
/**
|
||||
* 注销 SSE 事件监听器
|
||||
*/
|
||||
function unregisterSSEListeners() {
|
||||
unsubscribeCallbacks.forEach((unsubscribe) => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
unsubscribeCallbacks = [];
|
||||
}
|
||||
|
||||
async function sendAnswer(questionId, label) {
|
||||
if (!currentSessionId.value) return;
|
||||
isSending.value = true;
|
||||
// 将对应 tool part 标记为已回答,使输入框在等待响应期间可见
|
||||
for (const msg of messages.value) {
|
||||
if (!msg.parts) continue;
|
||||
for (const p of msg.parts) {
|
||||
if (p.type === 'tool' && p.tool === 'question' && p._questionId === questionId) {
|
||||
if (p.state) p.state.status = 'answered';
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const info = await window.opencode.start()
|
||||
isRunning.value = info.running
|
||||
// 更新 baseUrl 供 http 层使用
|
||||
if (info.url) window.__opencodeBaseUrl = info.url
|
||||
connectSSE()
|
||||
ElMessage.success('服务已启动')
|
||||
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
|
||||
await axios.post(`${baseUrl}/question/${questionId}/reply`, { answers: [[label]] });
|
||||
} catch (err) {
|
||||
ElMessage.error(`启动失败: ${err.message}`)
|
||||
} finally {
|
||||
isStarting.value = false
|
||||
console.error('发送答案失败:', err);
|
||||
ElMessage.error(`发送失败: ${err.message}`);
|
||||
isSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopService() {
|
||||
await window.opencode.stop()
|
||||
isRunning.value = false
|
||||
currentSessionId.value = null
|
||||
messages.value = []
|
||||
if (eventSource) { eventSource.close(); eventSource = null }
|
||||
ElMessage.info('服务已停止')
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || isSending.value) return
|
||||
const text = inputText.value.trim();
|
||||
if (!text || isSending.value) return;
|
||||
|
||||
// 首次发送时创建会话
|
||||
// 首次发送时创建会话,使用用户输入的第一条消息作为 title
|
||||
if (!currentSessionId.value) {
|
||||
try {
|
||||
const session = await window.opencode.createSession()
|
||||
currentSessionId.value = session.id
|
||||
const session = await window.opencode.createSession({ title: text });
|
||||
currentSessionId.value = session.id;
|
||||
} catch (err) {
|
||||
ElMessage.error(`创建会话失败: ${err.message}`)
|
||||
return
|
||||
ElMessage.error(`创建会话失败: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
messages.value.push({ id: Date.now(), role: 'user', text })
|
||||
inputText.value = ''
|
||||
isSending.value = true
|
||||
scrollToBottom()
|
||||
messages.value.push({ id: Date.now(), role: 'user', text });
|
||||
inputText.value = '';
|
||||
isSending.value = true;
|
||||
scrollToBottom();
|
||||
|
||||
// 构建 parts 数组
|
||||
const parts = [{ id: generateId(), type: 'text', text }];
|
||||
|
||||
// 如果有待发送的文件,追加到 parts 中
|
||||
if (pendingFiles.value.length > 0) {
|
||||
pendingFiles.value.forEach((file) => {
|
||||
parts.push({
|
||||
id: file.id,
|
||||
type: 'file',
|
||||
mime: file.mime,
|
||||
url: file.url,
|
||||
filename: file.filename,
|
||||
});
|
||||
});
|
||||
pendingFiles.value = [];
|
||||
}
|
||||
|
||||
try {
|
||||
await window.opencode.sendMessage(currentSessionId.value, text)
|
||||
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
|
||||
await axios.post(`${baseUrl}/session/${currentSessionId.value}/prompt_async`, { parts });
|
||||
// 发送成功后等待 SSE 事件流推送 AI 响应,isSending 由 session.idle 事件重置
|
||||
} catch (err) {
|
||||
ElMessage.error(`发送失败: ${err.message}`)
|
||||
isSending.value = false
|
||||
console.error('发送指令失败:', err);
|
||||
// 如果是服务未运行,尝试启动服务
|
||||
if (appStore.serviceStatus !== appStore.SERVICE_STATUS.RUNNING) {
|
||||
ElMessage.info('服务未运行,正在尝试启动...');
|
||||
appStore.triggerStartService();
|
||||
}
|
||||
ElMessage.error(`发送失败: ${err.message}`);
|
||||
isSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时同步服务状态
|
||||
window.opencode?.info().then((info) => {
|
||||
isRunning.value = info.running
|
||||
if (info.running) {
|
||||
if (info.url) window.__opencodeBaseUrl = info.url
|
||||
connectSSE()
|
||||
onMounted(() => {
|
||||
// 组件挂载时注册 SSE 监听器
|
||||
registerSSEListeners();
|
||||
// 确保全局 SSE 连接已建立
|
||||
if (!appStore.sseConnected) {
|
||||
appStore.initSSE();
|
||||
}
|
||||
}).catch(() => {})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) eventSource.close()
|
||||
})
|
||||
// 组件卸载时注销 SSE 监听器
|
||||
unregisterSSEListeners();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -203,40 +465,6 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.stopped { background: #c0c4cc; }
|
||||
.dot.starting { background: #e6a23c; animation: pulse 1s infinite; }
|
||||
.dot.running { background: #67c23a; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -263,24 +491,32 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bubble-wrap.user { justify-content: flex-end; }
|
||||
.bubble-wrap.assistant { justify-content: flex-start; }
|
||||
.bubble-wrap.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.bubble-wrap.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 75%;
|
||||
min-width: 48px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bubble-wrap.user .bubble {
|
||||
width: fit-content;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bubble-wrap.assistant .bubble {
|
||||
width: 75%;
|
||||
background: #f0f2f5;
|
||||
color: #303133;
|
||||
border-bottom-left-radius: 4px;
|
||||
@@ -293,6 +529,333 @@ onUnmounted(() => {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* 消息中的文件附件 */
|
||||
.msg-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.msg-file-item {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.msg-file-img {
|
||||
max-width: 200px;
|
||||
max-height: 150px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.msg-file-doc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.msg-file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bubble-wrap.assistant .msg-file-doc {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.reasoning-section {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reasoning-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.reasoning-header:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.reasoning-content {
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.reasoning-item {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
padding: 4px 0;
|
||||
border-left: 2px solid #ddd;
|
||||
padding-left: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.tool-part {
|
||||
margin-top: 6px;
|
||||
border-radius: 6px;
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.tool-header:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-weight: bold;
|
||||
color: #89b4fa;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
flex: 1;
|
||||
color: #a6adc8;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-status {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tool-status.status-completed {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.tool-status.status-running {
|
||||
color: #f9e2af;
|
||||
}
|
||||
|
||||
.tool-status.status-error {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.tool-body {
|
||||
padding: 0 10px 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.tool-command {
|
||||
padding: 6px 0 4px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: #cba6f7;
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
color: #a6e3a1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tool-output {
|
||||
margin: 4px 0 0;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #cdd6f4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-time {
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tool-error {
|
||||
margin: 4px 0 0;
|
||||
padding: 6px 8px;
|
||||
background: rgba(243, 139, 168, 0.12);
|
||||
border-left: 3px solid #f38ba8;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #f38ba8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.question-part {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.question-item {
|
||||
border: 1px solid #e0e4ea;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.question-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.question-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dcdfe6;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.option-radio {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: 2px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #c0c4cc;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.question-option:hover .option-radio {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.question-option.disabled .option-radio {
|
||||
border-color: #dcdfe6;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.question-option:hover {
|
||||
background: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.question-option.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.question-option.disabled:hover {
|
||||
background: transparent;
|
||||
border-color: #dcdfe6;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.question-free-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.question-free-input .el-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-free-input .el-button {
|
||||
height: 60px;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.raw-part {
|
||||
margin-top: 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
padding: 6px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.raw-part-type {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.raw-part-content {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1,425 +1,520 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<!-- 顶部欢迎区 -->
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-content">
|
||||
<h1 class="welcome-title">
|
||||
<span class="greeting">你好,开发者</span>
|
||||
<span class="emoji">👋</span>
|
||||
</h1>
|
||||
<p class="welcome-subtitle">准备好开始今天的创作了吗?</p>
|
||||
<!-- 中间内容区:标题、副标题、卡片 -->
|
||||
<div class="center-content">
|
||||
<!-- 第一行:标题 -->
|
||||
<div class="title-section">
|
||||
<span class="title-highlight">玄</span>
|
||||
<span class="title-normal mr-4">鉴万物</span>
|
||||
<span class="title-highlight">鉴</span>
|
||||
<span class="title-normal">知明理 </span>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:副标题 -->
|
||||
<div class="subtitle-section">本地运行、自主规划、安全可控的AI工作搭子</div>
|
||||
|
||||
<!-- 第三行:卡片 -->
|
||||
<div class="card-section flex gap-8 items-center">
|
||||
<div class="feature-card">
|
||||
<div class="card-icon">
|
||||
<img src="@/assets/icons/document-organization.svg" width="42" height="42" />
|
||||
</div>
|
||||
<div class="card-title">智能文档处理</div>
|
||||
<div class="card-desc">支持多种格式文档的智能解析与处理</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="card-icon">
|
||||
<img src="@/assets/icons/official-document.svg" width="42" height="42" />
|
||||
</div>
|
||||
<div class="card-title">智能文档处理</div>
|
||||
<div class="card-desc">支持多种格式文档的智能解析与处理</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="card-icon">
|
||||
<img src="@/assets/icons/pdf.svg" width="42" height="42" />
|
||||
</div>
|
||||
<div class="card-title">智能文档处理</div>
|
||||
<div class="card-desc">支持多种格式文档的智能解析与处理</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片区 -->
|
||||
<el-row :gutter="16" class="stats-row">
|
||||
<el-col :span="8" v-for="(stat, index) in stats" :key="index">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon" :style="{ background: stat.color + '20' }">
|
||||
<el-icon :size="24" :color="stat.color">
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
<!-- 底部:输入框 -->
|
||||
<div class="input-section">
|
||||
<div class="input-wrapper">
|
||||
<!-- 文件预览区域 -->
|
||||
<div v-if="selectedFiles.length > 0" class="file-preview-area">
|
||||
<div v-for="file in selectedFiles" :key="file.id" class="file-preview-item">
|
||||
<!-- 图片预览 -->
|
||||
<img v-if="file.mime.startsWith('image/')" :src="file.url" class="preview-img" :alt="file.filename" />
|
||||
<!-- 文本文件预览 -->
|
||||
<div v-else-if="file.mime.startsWith('text/')" class="file-icon-preview">
|
||||
<LucideIcon name="file-text" size="32"></LucideIcon>
|
||||
<span class="file-name">{{ file.filename }}</span>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stat.value }}</p>
|
||||
<p class="stat-label">{{ stat.label }}</p>
|
||||
<!-- 其他文件类型显示图标和文件名 -->
|
||||
<div v-else class="file-icon-preview">
|
||||
<LucideIcon name="file-text" size="32"></LucideIcon>
|
||||
<span class="file-name">{{ file.filename }}</span>
|
||||
</div>
|
||||
<button class="remove-file-btn" @click="removeFile(file.id)">
|
||||
<LucideIcon name="x" size="12"></LucideIcon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'">
|
||||
<el-icon><component :is="stat.trend > 0 ? Top : Bottom" /></el-icon>
|
||||
<span>{{ Math.abs(stat.trend) }}% 较上周</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
class="input-textarea"
|
||||
placeholder="输入 / 调用技能,输入 @ 调用知识库"
|
||||
:disabled="isCreating"
|
||||
@keydown="handleKeydown"
|
||||
@input="autoResize"
|
||||
ref="textareaRef"
|
||||
></textarea>
|
||||
<div class="input-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="dir-btn">
|
||||
<LucideIcon name="folder-input" size="16"></LucideIcon>
|
||||
<span>选择工作目录</span>
|
||||
</button>
|
||||
<el-tooltip content="添加文件或者文件夹作为上下文" placement="top" :show-arrow="false">
|
||||
<label class="toolbar-btn icon-btn" style="cursor: pointer">
|
||||
<input type="file" multiple style="display: none" @change="handleFilesSelected" />
|
||||
<LucideIcon name="paperclip" size="16"></LucideIcon>
|
||||
</label>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="使用 / 调用技能" placement="top" :show-arrow="false">
|
||||
<button class="toolbar-btn icon-btn">/</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="使用 @ 调用知识库" placement="top" :show-arrow="false">
|
||||
<button class="toolbar-btn icon-btn">@</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 主要内容区 -->
|
||||
<el-row :gutter="16" class="main-content">
|
||||
<!-- 快捷操作区 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="action-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>快捷操作</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="action-grid">
|
||||
<div
|
||||
v-for="action in actions"
|
||||
:key="action.label"
|
||||
class="action-item"
|
||||
@click="action.onClick"
|
||||
>
|
||||
<div class="action-icon" :style="{ background: action.color + '20' }">
|
||||
<el-icon :size="28" :color="action.color">
|
||||
<component :is="action.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<span class="action-label">{{ action.label }}</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="send-btn" :disabled="!inputText.trim() || isCreating" @click="handleSend">
|
||||
<LucideIcon name="arrow-up"></LucideIcon>
|
||||
</button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 最近文件区 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="recent-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Clock /></el-icon>
|
||||
<span>最近访问</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-scrollbar height="280px">
|
||||
<div class="recent-list">
|
||||
<div
|
||||
v-for="(item, index) in recents"
|
||||
:key="index"
|
||||
class="recent-item"
|
||||
@click="handleFileClick(item)"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<el-icon :size="20"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<p class="file-name">{{ item.name }}</p>
|
||||
<p class="file-path">{{ item.path }}</p>
|
||||
</div>
|
||||
<span class="file-time">{{ item.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Document,
|
||||
Plus,
|
||||
FolderOpened,
|
||||
Setting,
|
||||
Upload,
|
||||
Top,
|
||||
Bottom,
|
||||
Grid,
|
||||
Clock,
|
||||
Timer,
|
||||
VideoCamera
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useHistoryStore } from '@/stores/history';
|
||||
import { useDraftStore } from '@/stores/draft';
|
||||
import { Document, Plus, Promotion, FolderOpened, Paperclip } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import LucideIcon from '@/components/base/LucideIcon.vue';
|
||||
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const draftStore = useDraftStore();
|
||||
const inputText = ref('');
|
||||
const isCreating = ref(false);
|
||||
const textareaRef = ref(null);
|
||||
const selectedFiles = ref([]);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: '文件总数',
|
||||
value: '128',
|
||||
trend: 12,
|
||||
icon: Document,
|
||||
color: '#409EFF'
|
||||
},
|
||||
{
|
||||
label: '今日编辑',
|
||||
value: '24',
|
||||
trend: -3,
|
||||
icon: Timer,
|
||||
color: '#67C23A'
|
||||
},
|
||||
{
|
||||
label: '运行次数',
|
||||
value: '56',
|
||||
trend: 8,
|
||||
icon: VideoCamera,
|
||||
color: '#E6A23C'
|
||||
},
|
||||
]
|
||||
// 生成唯一 ID
|
||||
function generateId() {
|
||||
return 'prt' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: '新建文件',
|
||||
icon: Plus,
|
||||
color: '#409EFF',
|
||||
onClick: () => router.push('/editor')
|
||||
},
|
||||
{
|
||||
label: '打开文件',
|
||||
icon: FolderOpened,
|
||||
color: '#67C23A',
|
||||
onClick: () => {}
|
||||
},
|
||||
{
|
||||
label: '导入项目',
|
||||
icon: Upload,
|
||||
color: '#E6A23C',
|
||||
onClick: () => {}
|
||||
},
|
||||
{
|
||||
label: '系统设置',
|
||||
icon: Setting,
|
||||
color: '#F56C6C',
|
||||
onClick: () => {}
|
||||
},
|
||||
]
|
||||
function autoResize() {
|
||||
const el = textareaRef.value;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 323) + 'px';
|
||||
}
|
||||
|
||||
const recents = [
|
||||
{ name: 'main.js', path: '/src/main', time: '2 分钟前' },
|
||||
{ name: 'App.vue', path: '/src/renderer', time: '1 小时前' },
|
||||
{ name: 'index.css', path: '/src/renderer', time: '昨天' },
|
||||
{ name: 'forge.config.js', path: '/', time: '3 天前' },
|
||||
{ name: 'package.json', path: '/', time: '5 天前' },
|
||||
]
|
||||
// 处理发送消息
|
||||
async function handleSend() {
|
||||
// 检查 opencode 服务是否已启动
|
||||
if (appStore.serviceStatus !== appStore.SERVICE_STATUS.RUNNING) {
|
||||
ElMessage.warning('暂时没有运行的智能体');
|
||||
return;
|
||||
}
|
||||
const text = inputText.value.trim();
|
||||
if (!text || isCreating.value) return;
|
||||
|
||||
const handleFileClick = (item) => {
|
||||
console.log('点击文件:', item)
|
||||
isCreating.value = true;
|
||||
try {
|
||||
// 创建会话,title 使用用户输入的文本
|
||||
const session = await historyStore.createSession(text);
|
||||
console.log('创建会话成功:', session);
|
||||
|
||||
// 将文本和文件保存到 draft store
|
||||
draftStore.setDraft(text, [...selectedFiles.value]);
|
||||
|
||||
// 清空输入框和文件
|
||||
inputText.value = '';
|
||||
selectedFiles.value = [];
|
||||
|
||||
// 跳转到对话页面
|
||||
router.push({
|
||||
name: 'Chat',
|
||||
params: { id: session.id },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('创建会话失败:', err);
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理键盘事件
|
||||
function handleKeydown(e) {
|
||||
// Enter 发送,Shift+Enter 换行
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
function handleFilesSelected(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
Array.from(files).forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const fileItem = {
|
||||
id: generateId(),
|
||||
filename: file.name,
|
||||
mime: file.type || 'application/octet-stream',
|
||||
url: e.target.result,
|
||||
type: file,
|
||||
};
|
||||
// 如果是文本文件,额外读取文本内容用于预览
|
||||
if (file.type.startsWith('text/')) {
|
||||
const textReader = new FileReader();
|
||||
textReader.onload = (te) => {
|
||||
fileItem.textContent = te.target.result;
|
||||
selectedFiles.value.push(fileItem);
|
||||
};
|
||||
textReader.readAsText(file);
|
||||
} else {
|
||||
selectedFiles.value.push(fileItem);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
// 清空 input 值,允许重复选择相同文件
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
// 移除已选择的文件
|
||||
function removeFile(id) {
|
||||
const index = selectedFiles.value.findIndex((f) => f.id === id);
|
||||
if (index > -1) {
|
||||
selectedFiles.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
}
|
||||
|
||||
/* 欢迎区样式 */
|
||||
.welcome-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #ffffff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
background: linear-gradient(90deg, #409EFF 0%, #67C23A 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
/* 统计卡片区 */
|
||||
.stats-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
/* 中间内容区 */
|
||||
.center-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin: 6px 0 0 0;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stat-trend.up {
|
||||
color: #67C23A;
|
||||
}
|
||||
|
||||
.stat-trend.down {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
/* 主要内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.action-card,
|
||||
.recent-card {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* 快捷操作区 */
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: #f5f7fa;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
/* 第一行:标题 */
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.action-item:hover .action-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
.title-highlight {
|
||||
font-weight: 900;
|
||||
font-size: 42px;
|
||||
line-height: 48px;
|
||||
letter-spacing: -0.39px;
|
||||
text-align: center;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 最近文件区 */
|
||||
.recent-list {
|
||||
padding: 8px 0;
|
||||
.title-normal {
|
||||
font-weight: 900;
|
||||
font-size: 42px;
|
||||
line-height: 48px;
|
||||
letter-spacing: -0.39px;
|
||||
text-align: center;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
/* 第二行:副标题 */
|
||||
.subtitle-section {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 22.75px;
|
||||
letter-spacing: -0.15px;
|
||||
text-align: center;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 第三行:卡片 */
|
||||
.card-section {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
width: 220px;
|
||||
height: 158px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-top: 9px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.recent-item:hover {
|
||||
background: #f5f7fa;
|
||||
.card-icon img {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #ecf5ff;
|
||||
.card-icon img:hover {
|
||||
transform: rotate(-15deg);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.55px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #8a9097;
|
||||
}
|
||||
|
||||
/* 底部:输入框 */
|
||||
.input-section {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 760px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #dee0e4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
box-shadow: 0px 1px 20px 0px #00000008;
|
||||
}
|
||||
|
||||
.input-textarea {
|
||||
width: 758px;
|
||||
min-height: 60px;
|
||||
max-height: 323px;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #303133;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input-textarea::placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
/* 文件预览区域 */
|
||||
.file-preview-area {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 0;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-preview-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #f5f6f7;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-icon-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: #606266;
|
||||
max-width: 70px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.remove-file-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.remove-file-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.input-toolbar {
|
||||
height: 54px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #dcdfe6;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.dir-btn {
|
||||
height: 28px;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
background: #f5f6f7;
|
||||
border-radius: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #606266;
|
||||
}
|
||||
.dir-btn:hover {
|
||||
background: #eeeff2;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 7px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 4px 0 0 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-time {
|
||||
font-size: 12px;
|
||||
color: #C0C4CC;
|
||||
flex-shrink: 0;
|
||||
.send-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: #1a1a1a;
|
||||
border-radius: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
background: #333333;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
background: #8c8c8c;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
17
src/renderer/views/test/TestView.vue
Normal file
17
src/renderer/views/test/TestView.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<el-button type="primary" size="large" @click="showLogin = true">打开登录弹窗</el-button>
|
||||
<LoginDialog v-model="showLogin" @login-success="onLoginSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import LoginDialog from '@/components/LoginDialog.vue';
|
||||
|
||||
const showLogin = ref(false);
|
||||
|
||||
function onLoginSuccess(user) {
|
||||
console.log('登录成功:', user);
|
||||
}
|
||||
</script>
|
||||
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,17 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src/renderer'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user