Compare commits

...

18 Commits

Author SHA1 Message Date
b4ab2f5ab7 feat: 对话功能,上传附件功能开发 2026-04-13 09:30:08 +08:00
c1e934f2b2 feat: 对话功能,上传附件功能开发 2026-04-13 00:25:50 +08:00
8972dfe8e7 feat(用户信息): 在侧边栏显示用户昵称和邮箱使用 userStore 中的用户数据替换硬编码的用户信息 2026-04-12 22:14:09 +08:00
houakang
1879f5ce32 feat(用户): 实现用户信息获取及配置写入功能
添加获取用户信息的API接口和Pinia存储
在登录流程中增加用户信息获取和配置写入操作
新增opencode配置写入的IPC通信功能
2026-04-12 17:18:19 +08:00
fa013e597e feat: 对话功能开发 2026-04-12 17:06:32 +08:00
houakang
eb65197e23 Merge branch 'fix-error' of https://gitea.cirry.cn/cirry/electron-opencode into fix-error 2026-04-12 15:59:27 +08:00
houakang
9bca7a1dff feat(首页): 替换功能卡片图标并优化样式
添加新的SVG图标资源并替换原有的Element UI图标
调整卡片样式包括间距、颜色和悬停效果
2026-04-12 15:59:19 +08:00
c2a4d60edd feat: 对话功能开发 2026-04-12 15:36:36 +08:00
ca389824a1 feat: 对话功能开发 2026-04-12 15:01:06 +08:00
houakang
d907a37c2d refactor(http): 重构登录和请求基础URL处理逻辑
移除loginAction的sparkBaseUrl参数,改为通过请求拦截器统一处理
优化设备地址显示逻辑,优先使用非IPv6地址
2026-04-12 14:58:12 +08:00
houakang
fd6098df40 fix: 放宽CSP策略中的connect-src限制以支持所有WebSocket连接 2026-04-12 14:57:56 +08:00
f5b2bea15a Merge remote-tracking branch 'origin/fix-error' into fix-error 2026-04-12 13:06:02 +08:00
7a5b6680f2 feat: 对话功能开发 2026-04-12 13:05:52 +08:00
houakang
4bae331d4f feat(security): 添加密码加密功能
在用户登录时对密码进行加密处理,使用 Base64 编码和 RSA 加密增强安全性
新增 crypto.js 工具文件并添加相关依赖
2026-04-12 13:01:19 +08:00
houakang
4708af1e93 Merge branch 'fix-error' of https://gitea.cirry.cn/cirry/electron-opencode into fix-error 2026-04-12 12:59:09 +08:00
houakang
84680a9639 build: 添加 js-base64 和 jsencrypt 依赖 2026-04-12 12:52:05 +08:00
houakang
9710da8b3d feat(设备管理): 添加Spark设备发现与状态管理功能
实现Bonjour服务发现过滤和Spark设备状态管理,包括:
- 在BonjourView中添加对polygence-spark类型设备的过滤
- 创建spark store用于管理设备列表和选中设备状态
- 支持设备URL自动生成和持久化存储
2026-04-12 12:51:53 +08:00
houakang
8b145d79d3 feat(登录): 添加设备选择功能并实现登录接口集成
在登录弹窗中增加设备选择下拉框,并修改postAction以支持baseURL参数
实现用户登录接口调用,根据选择的设备进行登录请求
2026-04-12 12:51:40 +08:00
33 changed files with 5287 additions and 147 deletions

133
.junie/AGENTS.md Normal file
View 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 |

View File

@@ -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>

2849
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,9 @@
"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": "zhiju.com.cn",
@@ -38,7 +40,8 @@
"postcss": "^8.5.9",
"prettier": "3.8.1",
"tailwindcss": "^3.4.19",
"vite": "^5.4.21"
"vite": "^5.4.21",
"vitest": "^4.1.4"
},
"dependencies": {
"await-to-js": "^3.0.0",
@@ -46,8 +49,21 @@
"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"
},

View File

@@ -1,6 +1,7 @@
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';
@@ -225,6 +226,48 @@ function registerIpcHandlers() {
// 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);

View File

@@ -26,3 +26,7 @@ contextBridge.exposeInMainWorld('bonjour', {
return () => ipcRenderer.removeListener('bonjour:services-updated', listener);
},
});
contextBridge.exposeInMainWorld('opencodeConfig', {
write: (params) => ipcRenderer.invoke('opencode:write-config', params),
});

View File

@@ -36,7 +36,10 @@
<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 text-gray-500 hover:bg-gray-50 hover:text-gray-700 cursor-pointer transition-all duration-200"
: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"
>
@@ -77,8 +80,8 @@
<div class="flex items-center">
<el-avatar :size="32" />
<div v-show="!appStore.collapsed" class="ml-3">
<div class="text-sm font-medium">123</div>
<div class="text-xs text-gray-500">12321</div>
<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>
@@ -108,6 +111,7 @@ 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';
@@ -115,6 +119,7 @@ import axios from 'axios';
const route = useRoute();
const appStore = useAppStore();
const historyStore = useHistoryStore();
const userStore = useUserStore();
const statusLabel = computed(() => {
switch (appStore.serviceStatus) {
@@ -133,7 +138,7 @@ const statusLabel = computed(() => {
const menus = ref([
{ name: '新对话', index: '/', icon: 'plus' },
{ name: '知识空间', index: '/knowledge', icon: 'book' },
{ name: 'opencode对话', index: '/chat', icon: 'bot' },
// { name: 'opencode对话', index: '/chat', icon: 'bot' },
{ name: '发现设备', index: '/bonjour', icon: 'server' },
{ name: '测试页', index: '/test', icon: 'flask-conical' },
]);
@@ -158,18 +163,10 @@ async function loadHistorySessions() {
// 点击历史会话,跳转到对话页面并加载该会话
async function onHistoryClick(item) {
try {
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
const detailUrl = `${baseUrl}/session/${item.id}/message`;
const response = await axios.get(detailUrl);
console.log('[onHistoryClick] 会话详情数据:', response.data);
} catch (err) {
console.error('[onHistoryClick] 获取会话详情失败:', err);
}
// 跳转到对话页面
router.push({
path: '/chat',
query: { sessionId: item.id },
name: 'Chat',
params: { id: item.id },
});
}

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -11,6 +11,16 @@
<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>
@@ -56,6 +66,12 @@
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: {
@@ -78,11 +94,13 @@ 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' }],
};
@@ -96,10 +114,41 @@ async function handleLogin() {
if (!valid) return;
loading.value = true;
try {
// TODO: 替换为真实登录接口
await new Promise((r) => setTimeout(r, 800));
ElMessage.success('登录成功');
emit('login-success', { username: form.value.username });
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('登录失败,请重试');

View 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>

View 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>

View 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>

View 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>

View 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;

View 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;

View 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>

View 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;
}

View File

@@ -1,9 +1,16 @@
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 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));

View File

@@ -1,6 +1,7 @@
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({
@@ -12,9 +13,10 @@ const axiosInstance = axios.create({
responseType: 'json',
});
// 每次请求前动态更新 baseURL,确保服务启动后端口变更能被感知
// 每次请求前动态更新 baseURL:优先用选中的 spark 设备地址,否则回退到 opencode 地址
axiosInstance.interceptors.request.use((config) => {
config.baseURL = getBaseUrl();
const sparkStore = useSparkStore();
config.baseURL = sparkStore.selectedDeviceUrl || getBaseUrl();
return config;
});

View File

@@ -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 });
}

View File

@@ -22,6 +22,13 @@ const url = {
list: (sessionId) => `/session/${sessionId}/message`,
},
// 用户
user: {
login: '/v1/user/login',
// 获取用户信息接口
getUserInfo: '/v1/user/info',
},
// SSE 事件流
event: '/event',
};

View File

@@ -8,6 +8,7 @@ import App from './App.vue';
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 图标

View File

@@ -8,7 +8,7 @@ const routes = [
meta: { title: '首页' },
},
{
path: '/chat',
path: '/chat/:id?',
name: 'Chat',
component: () => import('@/views/chat/ChatView.vue'),
meta: { title: 'OpenCode 对话' },

View 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,
};
});

View 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.addressmDNS 响应来源 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,
};
});

View 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 };
});

View 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);
}

View File

@@ -1,15 +1,22 @@
<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 {
services.value = await window.bonjour.getServices();
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 {
@@ -20,7 +27,9 @@ const fetchServices = async () => {
onMounted(() => {
fetchServices();
unsubscribe = window.bonjour.onServicesUpdated((updatedServices) => {
services.value = updatedServices;
const filtered = filterServices(updatedServices);
services.value = filtered;
sparkStore.setDevices(filtered);
});
});

View File

@@ -8,26 +8,103 @@
</div>
<div v-for="msg in messages" :key="msg.id" class="bubble-wrap" :class="msg.role">
<div class="bubble">
<!-- 用户消息中的文件附件 -->
<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?.reasoning?.length > 0" class="reasoning-section">
<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.reasoning" :key="idx" class="reasoning-item">
<div v-for="(r, idx) in msg.parts.filter((p) => p.type === 'reasoning')" :key="idx" class="reasoning-item">
{{ r.text }}
</div>
</div>
</div>
<!-- 文本内容 -->
<pre class="bubble-text">{{ msg.text }}</pre>
<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"
@@ -35,7 +112,7 @@
placeholder="输入消息Ctrl+Enter 发送"
:disabled="isSending"
resize="none"
@keydown.ctrl.enter.prevent="send"
@keydown.enter="send"
/>
<el-button type="primary" :disabled="isSending || !inputText.trim()" :loading="isSending" @click="send"> 发送 </el-button>
</div>
@@ -43,27 +120,47 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
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 } from '@element-plus/icons-vue';
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 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([]); // 待发送的文件列表
// 生成唯一 ID
function generateId() {
return 'prt_' + Date.now() + Math.random().toString(36).substr(2, 9);
}
// 是否有正在等待回答的 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.query.sessionId);
const routeSessionId = computed(() => route.params.id || route.query.sessionId);
// 加载历史消息
async function loadHistoryMessages(sessionId) {
@@ -92,38 +189,29 @@ async function loadHistoryMessages(sessionId) {
const { info, parts } = item;
if (!info || !parts) return;
// 按 type 分类存储 parts
const partsByType = {
text: [],
reasoning: [],
'step-start': [],
'step-finish': [],
// 可以在这里添加更多 type
};
parts.forEach((part) => {
if (partsByType.hasOwnProperty(part.type)) {
partsByType[part.type].push(part);
} else {
// 未识别的 type 统一放到 others
if (!partsByType.others) partsByType.others = [];
partsByType.others.push(part);
}
});
console.log(`[loadHistoryMessages] 消息 ${info.id} 的 parts 分类:`, partsByType);
// 过滤掉 synthetic 为 true 的 part
const filteredParts = parts.filter((p) => !p.synthetic);
// 提取文本内容(用于展示)
const text = partsByType.text.map((part) => part.text).join('');
const text = filteredParts
.filter((p) => p.type === 'text')
.map((part) => part.text)
.join('');
if (text || info.role === 'assistant') {
// 提取文件列表
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,
parts: partsByType, // 存储分类后的 parts方便后续按 type 渲染
rawParts: parts, // 保留原始 parts
showReasoning: false, // 默认折叠推理过程
files: files,
parts: normalizedParts,
showReasoning: true, // 默认展开推理过程
});
// 记录 assistant 消息 ID
@@ -151,17 +239,16 @@ watch(
// 等待历史消息加载完毕,避免在 send 时 messages 被清空
await loadHistoryMessages(newSessionId);
// 处理从首页带过来的初始消息
const text = route.query.text;
if (text) {
inputText.value = text;
// 清除 query 中的 text防止刷新页面时重复发送
const query = { ...route.query };
delete query.text;
router.replace({ query });
// 处理从首页带过来的初始消息(从 draft store 获取)
if (draftStore.hasDraft()) {
const draft = draftStore.takeDraft();
inputText.value = draft.text;
pendingFiles.value = draft.files;
// 触发发送逻辑
send();
if (inputText.value.trim()) {
send();
}
}
// 确保 SSE 连接已建立
@@ -181,13 +268,32 @@ function scrollToBottom() {
});
}
function upsertAssistantBubble(msgId, text) {
const existing = messages.value.find((m) => m.id === msgId);
if (existing) {
existing.text = text;
} else {
messages.value.push({ id: msgId, role: 'assistant', 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 {
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);
}
// 同步 text 字段(取所有 text 类型 part 拼接)
existing.text = existing.parts
.filter((p) => p.type === 'text')
.map((p) => p.text || '')
.join('');
scrollToBottom();
}
@@ -199,12 +305,12 @@ function registerSSEListeners() {
const unsubscribePartUpdated = sseManager.on('message.part.updated', (data) => {
const props = data.properties || {};
const part = props.part;
if (!part || part.type !== 'text') return;
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;
upsertAssistantBubble(part.messageID, part.text || '');
upsertAssistantPart(part.messageID, part);
});
// 监听消息更新事件
@@ -227,8 +333,25 @@ function registerSSEListeners() {
}
});
// 监听 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;
}
}
});
// 保存取消订阅函数
unsubscribeCallbacks.push(unsubscribePartUpdated, unsubscribeMessageUpdated, unsubscribeSessionIdle);
unsubscribeCallbacks.push(unsubscribePartUpdated, unsubscribeMessageUpdated, unsubscribeSessionIdle, unsubscribeQuestionAsked);
}
/**
@@ -243,6 +366,28 @@ function unregisterSSEListeners() {
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 baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
await axios.post(`${baseUrl}/question/${questionId}/reply`, { answers: [[label]] });
} catch (err) {
console.error('发送答案失败:', err);
ElMessage.error(`发送失败: ${err.message}`);
isSending.value = false;
}
}
async function send() {
const text = inputText.value.trim();
if (!text || isSending.value) return;
@@ -263,8 +408,26 @@ async function send() {
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.promptAsync(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) {
console.error('发送指令失败:', err);
@@ -337,19 +500,23 @@ onUnmounted(() => {
.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;
@@ -362,6 +529,47 @@ 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;
@@ -399,6 +607,255 @@ onUnmounted(() => {
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;

View File

@@ -17,21 +17,21 @@
<div class="card-section flex gap-8 items-center">
<div class="feature-card">
<div class="card-icon">
<el-icon :size="42"><Document /></el-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">
<el-icon :size="42"><Document /></el-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">
<el-icon :size="42"><Document /></el-icon>
<img src="@/assets/icons/pdf.svg" width="42" height="42" />
</div>
<div class="card-title">智能文档处理</div>
<div class="card-desc">支持多种格式文档的智能解析与处理</div>
@@ -42,25 +42,57 @@
<!-- 底部输入框 -->
<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 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>
<textarea
v-model="inputText"
class="input-textarea"
placeholder="描述任务,/ 调用技能与工具,@调用知识库"
placeholder="输入 / 调用技能,输入 @ 调用知识库"
:disabled="isCreating"
@keydown="handleKeydown"
@input="autoResize"
ref="textareaRef"
></textarea>
<div class="input-toolbar">
<div class="toolbar-left">
<button class="toolbar-btn file-btn">
<el-icon><Plus /></el-icon>
<span>添加文件</span>
<button class="dir-btn">
<LucideIcon name="folder-input" size="16"></LucideIcon>
<span>选择工作目录</span>
</button>
<button class="toolbar-btn symbol-btn">/</button>
<button class="toolbar-btn symbol-btn">@</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>
<div class="toolbar-right">
<button class="send-btn" :disabled="!inputText.trim() || isCreating" @click="handleSend">
<el-icon><Promotion /></el-icon>
<LucideIcon name="arrow-up"></LucideIcon>
</button>
</div>
</div>
@@ -74,16 +106,39 @@ import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAppStore } from '@/stores/app';
import { useHistoryStore } from '@/stores/history';
import { Document, Plus, Promotion } from '@element-plus/icons-vue';
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 appStore = useAppStore();
const historyStore = useHistoryStore();
const draftStore = useDraftStore();
const inputText = ref('');
const isCreating = ref(false);
const textareaRef = ref(null);
const selectedFiles = ref([]);
// 生成唯一 ID
function generateId() {
return 'prt' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function autoResize() {
const el = textareaRef.value;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 323) + 'px';
}
// 处理发送消息
async function handleSend() {
// 检查 opencode 服务是否已启动
if (appStore.serviceStatus !== appStore.SERVICE_STATUS.RUNNING) {
ElMessage.warning('暂时没有运行的智能体');
return;
}
const text = inputText.value.trim();
if (!text || isCreating.value) return;
@@ -93,16 +148,17 @@ async function handleSend() {
const session = await historyStore.createSession(text);
console.log('创建会话成功:', session);
// 清空输入框
inputText.value = '';
// 将文本和文件保存到 draft store
draftStore.setDraft(text, [...selectedFiles.value]);
// 跳转到对话页面,并将消息文本带入 query
// 清空输入框和文件
inputText.value = '';
selectedFiles.value = [];
// 跳转到对话页面
router.push({
path: '/chat',
query: {
sessionId: session.id,
text: text,
},
name: 'Chat',
params: { id: session.id },
});
} catch (err) {
console.error('创建会话失败:', err);
@@ -119,6 +175,47 @@ function handleKeydown(e) {
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>
@@ -190,7 +287,7 @@ function handleKeydown(e) {
border-radius: 8px;
display: flex;
flex-direction: column;
padding: 25px 16px;
padding: 16px;
gap: 8px;
background: #ffffff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
@@ -199,52 +296,59 @@ function handleKeydown(e) {
.card-icon {
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
color: #409eff;
margin-top: 9px;
margin-bottom: 4px;
}
.card-icon img {
transition: transform 0.3s ease;
}
.card-icon img:hover {
transform: rotate(-15deg);
}
.card-title {
font-weight: 600;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.55px;
color: #303133;
color: #1a1a1a;
}
.card-desc {
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: #909399;
color: #8a9097;
}
/* 底部:输入框 */
.input-section {
margin-top: auto;
padding-top: 24px;
padding-bottom: 32px;
}
.input-wrapper {
width: 760px;
height: 114px;
border-radius: 12px;
border: 1px solid #dcdfe6;
border: 1px solid #dee0e4;
display: flex;
flex-direction: column;
background: #ffffff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
box-shadow: 0px 1px 20px 0px #00000008;
}
.input-textarea {
width: 758px;
height: 60px;
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;
@@ -256,13 +360,81 @@ function handleKeydown(e) {
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;
gap: 4px;
color: #606266;
}
.file-name {
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;
border-top: 1px solid #ebeef5;
}
.toolbar-left {
@@ -287,20 +459,33 @@ function handleKeydown(e) {
color: #409eff;
}
.file-btn {
width: 84px;
.dir-btn {
height: 28px;
gap: 4px;
padding: 0 8px;
border-radius: 6px;
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;
}
.symbol-btn {
.icon-btn {
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
padding: 0 7px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
@@ -314,8 +499,8 @@ function handleKeydown(e) {
width: 32px;
height: 32px;
border: none;
background: #409eff;
border-radius: 8px;
background: #1a1a1a;
border-radius: 32px;
display: flex;
align-items: center;
justify-content: center;
@@ -325,6 +510,11 @@ function handleKeydown(e) {
}
.send-btn:hover {
background: #66b1ff;
background: #333333;
}
.send-btn:disabled {
background: #8c8c8c;
cursor: not-allowed;
}
</style>