Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e3431b36 | ||
|
|
84a6c2da59 | ||
|
|
5b9ff3053b | ||
|
|
8340922263 | ||
|
|
a93cab6b43 | ||
|
|
9a81c400ab | ||
|
|
808a22d5c6 | ||
|
|
10e512f32e | ||
|
|
4d75515bd6 | ||
|
|
3d6c84de6d | ||
|
|
3dd393b840 | ||
|
|
8f86c53941 | ||
|
|
a7b78c547a | ||
|
|
bcc1046cdf | ||
|
|
c05c06b7a1 | ||
|
|
446ebae175 | ||
|
|
ba742b7b1f | ||
|
|
7c6db809bb | ||
|
|
855499681f | ||
|
|
92be3c0f56 | ||
|
|
2a72f391b7 | ||
|
|
db642f0837 | ||
|
|
fca93b6c51 | ||
|
|
7e672d86e7 | ||
|
|
e9112cad0f | ||
|
|
ffbd6445df | ||
|
|
dff44f2721 | ||
|
|
3afa81eb5d | ||
|
|
3350c3e2e5 | ||
|
|
f85f46c248 | ||
|
|
05f3b88f30 | ||
|
|
f8c6b5c05f | ||
|
|
97dbfe492e | ||
|
|
186f0ed06f | ||
|
|
daf134f331 | ||
|
|
3f7f78da15 | ||
|
|
1d289621fc | ||
|
|
d7002cda11 | ||
|
|
559fcecf77 | ||
|
|
1d854c232e | ||
|
|
8c6684cbdf | ||
|
|
c7ab71f01f | ||
|
|
9b57351d1e | ||
|
|
f9e88fb6ee | ||
|
|
074ba0ae05 | ||
|
|
4a8a5e8428 | ||
|
|
f7fa665f3a | ||
|
|
e273ddcfb0 | ||
|
|
41d3a1fd55 | ||
|
|
7237ba34db | ||
|
|
fbf89b3f0a |
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -77,9 +77,10 @@ jobs:
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -93,10 +94,11 @@ jobs:
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -105,9 +107,10 @@ jobs:
|
||||
yarn build:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"]
|
||||
}
|
||||
|
||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -4,6 +4,7 @@
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"search.exclude": {
|
||||
"**/dist/**": true,
|
||||
".yarn/releases/**": true
|
||||
|
||||
@@ -117,9 +117,8 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
服务商:新增 NewAPI 服务商支持
|
||||
绘图:新增 NewAPI 绘图服务商支持
|
||||
备份:支持 s3 兼容存储备份
|
||||
服务商:支持多个密钥管理,支持配置自定义请求头
|
||||
设置:支持禁用硬件加速
|
||||
其他:性能优化和错误改进
|
||||
• [新增] MCP 工具调用自动审批流程
|
||||
• [优化] 输入框快捷弹窗多选交互支持
|
||||
• [新增] 网页内容生成实时预览功能
|
||||
• [支持] Grok-4 大语言模型接入
|
||||
• [修复] Anthropic 模型输出截断缺陷
|
||||
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -27,12 +27,12 @@
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64",
|
||||
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
|
||||
"build:linux": "dotenv electron-vite build && electron-builder --linux --x64 --arm64",
|
||||
"build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
|
||||
"build:mac": "dotenv npm run build && electron-builder --mac --arm64 --x64",
|
||||
"build:mac:arm64": "dotenv npm run build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv npm run build && electron-builder --mac --x64",
|
||||
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
|
||||
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
|
||||
"build:npm": "node scripts/build-npm.js",
|
||||
"release": "node scripts/version.js",
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
@@ -71,7 +71,7 @@
|
||||
"notion-helper": "^1.3.22",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"selection-hook": "^1.0.5",
|
||||
"selection-hook": "^1.0.6",
|
||||
"turndown": "7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -92,6 +92,7 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
@@ -141,6 +142,8 @@
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"@vitest/ui": "^3.1.4",
|
||||
"@vitest/web-worker": "^3.1.4",
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch",
|
||||
"archiver": "^7.0.1",
|
||||
@@ -225,6 +228,7 @@
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^1.1.0",
|
||||
"typescript": "^5.6.2",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "6.2.6",
|
||||
"vitest": "^3.1.4",
|
||||
|
||||
@@ -147,6 +147,7 @@ export enum IpcChannel {
|
||||
File_Base64File = 'file:base64File',
|
||||
File_GetPdfInfo = 'file:getPdfInfo',
|
||||
Fs_Read = 'fs:read',
|
||||
File_OpenWithRelativePath = 'file:openWithRelativePath',
|
||||
|
||||
// file service
|
||||
FileService_Upload = 'file-service:upload',
|
||||
|
||||
@@ -399,6 +399,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
|
||||
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath)
|
||||
|
||||
// file service
|
||||
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||
|
||||
@@ -5,26 +5,19 @@ import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-op
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import { SUPPORTED_DIM_MODELS as VOYAGE_SUPPORTED_DIM_MODELS, VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
import { VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
|
||||
const batchSize = 10
|
||||
if (provider === 'voyageai') {
|
||||
if (VOYAGE_SUPPORTED_DIM_MODELS.includes(model)) {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: dimensions,
|
||||
batchSize: 8
|
||||
})
|
||||
} else {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: VOYAGE_SUPPORTED_DIM_MODELS.includes(model) ? dimensions : undefined,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
if (provider === 'ollama') {
|
||||
if (baseURL.includes('v1/')) {
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
|
||||
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
|
||||
/**
|
||||
* 支持设置嵌入维度的模型
|
||||
*/
|
||||
export const SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
export class VoyageEmbeddings extends BaseEmbeddings {
|
||||
private model: _VoyageEmbeddings
|
||||
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
|
||||
super()
|
||||
if (!this.configuration) this.configuration = {}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
if (!SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
throw new Error(`VoyageEmbeddings only supports ${SUPPORTED_DIM_MODELS.join(', ')}`)
|
||||
if (!this.configuration) {
|
||||
throw new Error('Pass in a configuration.')
|
||||
}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
if (!VOYAGE_SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
console.error(`VoyageEmbeddings only supports ${VOYAGE_SUPPORTED_DIM_MODELS.join(', ')} to set outputDimension.`)
|
||||
this.model = new _VoyageEmbeddings({ ...this.configuration, outputDimension: undefined })
|
||||
} else {
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
}
|
||||
}
|
||||
override async getDimensions(): Promise<number> {
|
||||
if (!this.configuration?.outputDimension) {
|
||||
throw new Error('You need to pass in the optional dimensions parameter for this model')
|
||||
}
|
||||
return this.configuration?.outputDimension
|
||||
return this.configuration?.outputDimension ?? (this.configuration?.modelName === 'voyage-code-2' ? 1536 : 1024)
|
||||
}
|
||||
|
||||
override async embedDocuments(texts: string[]): Promise<number[][]> {
|
||||
|
||||
45
src/main/knowledage/embeddings/utils.ts
Normal file
45
src/main/knowledage/embeddings/utils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const VOYAGE_SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
|
||||
// NOTE: 下面的暂时没用上,但先留着吧
|
||||
export const OPENAI_SUPPORTED_DIM_MODELS = ['text-embedding-3-small', 'text-embedding-3-large']
|
||||
|
||||
export const DASHSCOPE_SUPPORTED_DIM_MODELS = ['text-embedding-v3', 'text-embedding-v4']
|
||||
|
||||
export const OPENSOURCE_SUPPORTED_DIM_MODELS = ['qwen3-embedding-0.6B', 'qwen3-embedding-4B', 'qwen3-embedding-8B']
|
||||
|
||||
export const GOOGLE_SUPPORTED_DIM_MODELS = ['gemini-embedding-exp-03-07', 'gemini-embedding-exp']
|
||||
|
||||
export const SUPPORTED_DIM_MODELS = [
|
||||
...VOYAGE_SUPPORTED_DIM_MODELS,
|
||||
...OPENAI_SUPPORTED_DIM_MODELS,
|
||||
...DASHSCOPE_SUPPORTED_DIM_MODELS,
|
||||
...OPENSOURCE_SUPPORTED_DIM_MODELS,
|
||||
...GOOGLE_SUPPORTED_DIM_MODELS
|
||||
]
|
||||
|
||||
/**
|
||||
* 从模型 ID 中提取基础名称。
|
||||
* 例如:
|
||||
* - 'deepseek/deepseek-r1' => 'deepseek-r1'
|
||||
* - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1'
|
||||
* @param {string} id 模型 ID
|
||||
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
|
||||
* @returns {string} 基础名称
|
||||
*/
|
||||
export const getBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||
const parts = id.split(delimiter)
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模型 ID 中提取基础名称并转换为小写。
|
||||
* 例如:
|
||||
* - 'deepseek/DeepSeek-R1' => 'deepseek-r1'
|
||||
* - 'deepseek-ai/deepseek/DeepSeek-R1' => 'deepseek-r1'
|
||||
* @param {string} id 模型 ID
|
||||
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
|
||||
* @returns {string} 小写的基础名称
|
||||
*/
|
||||
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||
return getBaseModelName(id, delimiter).toLowerCase()
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export async function addFileLoader(
|
||||
// HTML类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: readTextFileWithAutoEncoding(file.path),
|
||||
urlOrContent: await readTextFileWithAutoEncoding(file.path),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
@@ -124,7 +124,7 @@ export async function addFileLoader(
|
||||
|
||||
case 'json':
|
||||
try {
|
||||
jsonObject = JSON.parse(readTextFileWithAutoEncoding(file.path))
|
||||
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
|
||||
} catch (error) {
|
||||
jsonParsed = false
|
||||
Logger.warn('[KnowledgeBase] failed parsing json file, falling back to text processing:', file.path, error)
|
||||
@@ -140,7 +140,7 @@ export async function addFileLoader(
|
||||
// 如果是其他文本类型且尚未读取文件,则读取文件
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new TextLoader({
|
||||
text: readTextFileWithAutoEncoding(file.path),
|
||||
text: await readTextFileWithAutoEncoding(file.path),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
|
||||
@@ -217,7 +217,7 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
private async convertFile(uid: string, filePath: string): Promise<void> {
|
||||
const fileName = path.basename(filePath).split('.')[0]
|
||||
const fileName = path.parse(filePath).name
|
||||
const config = {
|
||||
...this.createAuthConfig(),
|
||||
headers: {
|
||||
|
||||
@@ -231,7 +231,11 @@ class FileStorage {
|
||||
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
|
||||
}
|
||||
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||
public readFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
id: string,
|
||||
detectEncoding: boolean = false
|
||||
): Promise<string> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
|
||||
const fileExtension = path.extname(filePath)
|
||||
@@ -259,8 +263,11 @@ class FileStorage {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = readTextFileWithAutoEncoding(filePath)
|
||||
return result
|
||||
if (detectEncoding) {
|
||||
return readTextFileWithAutoEncoding(filePath)
|
||||
} else {
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return 'failed to read file'
|
||||
@@ -417,6 +424,19 @@ class FileStorage {
|
||||
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过相对路径打开文件,跨设备时使用
|
||||
* @param file
|
||||
*/
|
||||
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
|
||||
const filePath = path.join(this.storageDir, file.name)
|
||||
if (fs.existsSync(filePath)) {
|
||||
shell.openPath(filePath).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
} else {
|
||||
logger.warn('[IPC - Warning] File does not exist:', filePath)
|
||||
}
|
||||
}
|
||||
|
||||
public save = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { isDev, isMac, isWin } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
@@ -509,54 +509,55 @@ export class SelectionService {
|
||||
//should set every time the window is shown
|
||||
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
|
||||
|
||||
// [macOS] a series of hacky ways only for macOS
|
||||
if (isMac) {
|
||||
// [macOS] a hacky way
|
||||
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
|
||||
// so we just don't set `skipTransformProcessType: true` when in self app
|
||||
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
|
||||
|
||||
if (!isSelf) {
|
||||
// [macOS] an ugly hacky way
|
||||
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
|
||||
// so we set `focusable: true` before showing, and then set false after showing
|
||||
this.toolbarWindow!.setFocusable(false)
|
||||
|
||||
// [macOS]
|
||||
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
|
||||
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
|
||||
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
}
|
||||
|
||||
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
|
||||
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
|
||||
this.toolbarWindow!.showInactive()
|
||||
|
||||
// [macOS] restore the focusable status
|
||||
this.toolbarWindow!.setFocusable(true)
|
||||
|
||||
if (!isMac) {
|
||||
this.toolbarWindow!.show()
|
||||
/**
|
||||
* [Windows]
|
||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||
* It's a strange behavior, so we don't use it for compatibility
|
||||
*/
|
||||
// this.toolbarWindow!.setOpacity(1)
|
||||
this.startHideByMouseKeyListener()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* The following is for Windows
|
||||
*/
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
|
||||
*************************************************/
|
||||
|
||||
this.toolbarWindow!.show()
|
||||
// [macOS] a hacky way
|
||||
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
|
||||
// so we just don't set `skipTransformProcessType: true` when in self app
|
||||
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
|
||||
|
||||
/**
|
||||
* [Windows]
|
||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||
* It's a strange behavior, so we don't use it for compatibility
|
||||
*/
|
||||
// this.toolbarWindow!.setOpacity(1)
|
||||
if (!isSelf) {
|
||||
// [macOS] an ugly hacky way
|
||||
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
|
||||
// so we set `focusable: true` before showing, and then set false after showing
|
||||
this.toolbarWindow!.setFocusable(false)
|
||||
|
||||
// [macOS]
|
||||
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
|
||||
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
|
||||
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
}
|
||||
|
||||
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
|
||||
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
|
||||
this.toolbarWindow!.showInactive()
|
||||
|
||||
// [macOS] restore the focusable status
|
||||
this.toolbarWindow!.setFocusable(true)
|
||||
|
||||
this.startHideByMouseKeyListener()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -911,6 +912,7 @@ export class SelectionService {
|
||||
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
|
||||
}
|
||||
|
||||
// [macOS] isFullscreen is only available on macOS
|
||||
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
|
||||
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
|
||||
}
|
||||
@@ -1218,20 +1220,26 @@ export class SelectionService {
|
||||
return actionWindow
|
||||
}
|
||||
|
||||
public processAction(actionItem: ActionItem): void {
|
||||
/**
|
||||
* Process action item
|
||||
* @param actionItem Action item to process
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void {
|
||||
const actionWindow = this.popActionWindow()
|
||||
|
||||
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
|
||||
|
||||
this.showActionWindow(actionWindow)
|
||||
this.showActionWindow(actionWindow, isFullScreen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show action window with proper positioning relative to toolbar
|
||||
* Ensures window stays within screen boundaries
|
||||
* @param actionWindow Window to position and show
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
private showActionWindow(actionWindow: BrowserWindow): void {
|
||||
private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void {
|
||||
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
|
||||
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
|
||||
|
||||
@@ -1241,11 +1249,14 @@ export class SelectionService {
|
||||
actionWindowHeight = this.lastActionWindowSize.height
|
||||
}
|
||||
|
||||
//center way
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
/********************************************
|
||||
* Setting the position of the action window
|
||||
********************************************/
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
|
||||
// Center of the screen
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
|
||||
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
|
||||
|
||||
@@ -1255,54 +1266,107 @@ export class SelectionService {
|
||||
x: Math.round(centerX),
|
||||
y: Math.round(centerY)
|
||||
})
|
||||
} else {
|
||||
// Follow toolbar position
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
const GAP = 6 // 6px gap from screen edges
|
||||
|
||||
//make sure action window is inside screen
|
||||
if (actionWindowWidth > workArea.width - 2 * GAP) {
|
||||
actionWindowWidth = workArea.width - 2 * GAP
|
||||
}
|
||||
|
||||
if (actionWindowHeight > workArea.height - 2 * GAP) {
|
||||
actionWindowHeight = workArea.height - 2 * GAP
|
||||
}
|
||||
|
||||
// Calculate initial position to center action window horizontally below toolbar
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small gap
|
||||
if (posX + actionWindowWidth > workArea.x + workArea.width) {
|
||||
posX = workArea.x + workArea.width - actionWindowWidth - GAP
|
||||
} else if (posX < workArea.x) {
|
||||
posX = workArea.x + GAP
|
||||
}
|
||||
if (posY + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
})
|
||||
}
|
||||
|
||||
if (!isMac) {
|
||||
actionWindow.show()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//follow toolbar
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
const GAP = 6 // 6px gap from screen edges
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
|
||||
*************************************************/
|
||||
|
||||
//make sure action window is inside screen
|
||||
if (actionWindowWidth > workArea.width - 2 * GAP) {
|
||||
actionWindowWidth = workArea.width - 2 * GAP
|
||||
// act normally when the app is not in fullscreen mode
|
||||
if (!isFullScreen) {
|
||||
actionWindow.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (actionWindowHeight > workArea.height - 2 * GAP) {
|
||||
actionWindowHeight = workArea.height - 2 * GAP
|
||||
}
|
||||
// [macOS] an UGLY HACKY way for fullscreen override settings
|
||||
|
||||
// Calculate initial position to center action window horizontally below toolbar
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
// FIXME sometimes the dock will be shown when the action window is shown
|
||||
// FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown
|
||||
// FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app
|
||||
// use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small gap
|
||||
if (posX + actionWindowWidth > workArea.x + workArea.width) {
|
||||
posX = workArea.x + workArea.width - actionWindowWidth - GAP
|
||||
} else if (posX < workArea.x) {
|
||||
posX = workArea.x + GAP
|
||||
}
|
||||
if (posY + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
// setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled)
|
||||
actionWindow.setFocusable(false)
|
||||
actionWindow.setAlwaysOnTop(true, 'floating')
|
||||
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
// `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared
|
||||
// just store the dock icon status, and show it again
|
||||
const isDockShown = app.dock?.isVisible()
|
||||
|
||||
// DO NOT set `skipTransformProcessType: true`,
|
||||
// it will cause the action window to be shown on other space
|
||||
actionWindow.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true
|
||||
})
|
||||
|
||||
actionWindow.show()
|
||||
actionWindow.showInactive()
|
||||
|
||||
// show the dock again if last time it was shown
|
||||
// do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled
|
||||
if (!app.dock?.isVisible() && isDockShown) {
|
||||
app.dock?.show()
|
||||
}
|
||||
|
||||
// unset everything
|
||||
setTimeout(() => {
|
||||
actionWindow.setVisibleOnAllWorkspaces(false, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
actionWindow.setAlwaysOnTop(false)
|
||||
|
||||
actionWindow.setFocusable(true)
|
||||
|
||||
// regain the focus when all the works done
|
||||
actionWindow.focus()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
public closeActionWindow(actionWindow: BrowserWindow): void {
|
||||
@@ -1408,8 +1472,9 @@ export class SelectionService {
|
||||
configManager.setSelectionAssistantFilterList(filterList)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
|
||||
selectionService?.processAction(actionItem)
|
||||
// [macOS] only macOS has the available isFullscreen mode
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
|
||||
selectionService?.processAction(actionItem, isFullScreen)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {
|
||||
|
||||
@@ -44,7 +44,9 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
// }
|
||||
// }
|
||||
// cherrystudio://mcp/install?servers={base64Encode(JSON.stringify(jsonConfig))}
|
||||
|
||||
const data = params.get('servers')
|
||||
|
||||
if (data) {
|
||||
const stringify = Buffer.from(data, 'base64').toString('utf8')
|
||||
Logger.info('install MCP servers from urlschema: ', stringify)
|
||||
@@ -63,10 +65,8 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
}
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.executeJavaScript("window.navigate('/settings/mcp')")
|
||||
}
|
||||
windowService.getMainWindow()?.show()
|
||||
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileTypes } from '@types'
|
||||
import iconv from 'iconv-lite'
|
||||
import { detectAll as detectEncodingAll } from 'jschardet'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { detectEncoding, readTextFileWithAutoEncoding } from '../file'
|
||||
import { readTextFileWithAutoEncoding } from '../file'
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs')
|
||||
vi.mock('node:fs/promises')
|
||||
vi.mock('node:os')
|
||||
vi.mock('node:path')
|
||||
vi.mock('uuid', () => ({
|
||||
@@ -244,102 +247,52 @@ describe('file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 在 describe('file') 块内部添加新的 describe 块
|
||||
describe('detectEncoding', () => {
|
||||
const mockFilePath = '/path/to/mock/file.txt'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.openSync).mockReturnValue(123)
|
||||
vi.mocked(fs.closeSync).mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('should correctly detect UTF-8 encoding', () => {
|
||||
// 准备UTF-8编码的Buffer
|
||||
const content = '这是UTF-8测试内容'
|
||||
const buffer = Buffer.from(content, 'utf-8')
|
||||
|
||||
// 模拟文件读取
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return 1024
|
||||
})
|
||||
|
||||
const encoding = detectEncoding(mockFilePath)
|
||||
expect(encoding).toBe('UTF-8')
|
||||
})
|
||||
|
||||
it('should correctly detect GB2312 encoding', () => {
|
||||
// 使用iconv创建GB2312编码内容
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const gb2312Buffer = iconv.encode(content, 'GB2312')
|
||||
|
||||
// 模拟文件读取
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(gb2312Buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return gb2312Buffer.length
|
||||
})
|
||||
|
||||
const encoding = detectEncoding(mockFilePath)
|
||||
expect(encoding).toMatch(/GB2312|GB18030/i)
|
||||
})
|
||||
|
||||
it('should correctly detect ASCII encoding', () => {
|
||||
// 准备ASCII编码内容
|
||||
const content = 'ASCII content'
|
||||
const buffer = Buffer.from(content, 'ascii')
|
||||
|
||||
// 模拟文件读取
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return buffer.length
|
||||
})
|
||||
|
||||
const encoding = detectEncoding(mockFilePath)
|
||||
expect(encoding.toLowerCase()).toBe('ascii')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextFileWithAutoEncoding', () => {
|
||||
const mockFilePath = '/path/to/mock/file.txt'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.openSync).mockReturnValue(123)
|
||||
vi.mocked(fs.closeSync).mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('should read file with auto encoding', () => {
|
||||
it('should read file with auto encoding', async () => {
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const buffer = iconv.encode(content, 'GB2312')
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return buffer.length
|
||||
})
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(buffer)
|
||||
|
||||
const result = readTextFileWithAutoEncoding(mockFilePath)
|
||||
// 创建模拟的 FileHandle 对象
|
||||
const mockFileHandle = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
bytesRead: buffer.byteLength,
|
||||
buffer: buffer
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
// 模拟 open 方法
|
||||
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
|
||||
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
|
||||
|
||||
const result = await readTextFileWithAutoEncoding(mockFilePath)
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
|
||||
it('should try to fix bad detected encoding', () => {
|
||||
it('should try to fix bad detected encoding', async () => {
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const buffer = iconv.encode(content, 'GB2312')
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return buffer.length
|
||||
})
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(buffer)
|
||||
vi.mocked(vi.fn(detectEncoding)).mockReturnValue('UTF-8')
|
||||
const result = readTextFileWithAutoEncoding(mockFilePath)
|
||||
|
||||
// 创建模拟的 FileHandle 对象
|
||||
const mockFileHandle = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
bytesRead: buffer.byteLength,
|
||||
buffer: buffer
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
// 模拟 fs.open 方法
|
||||
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
|
||||
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
|
||||
vi.mocked(vi.fn(detectEncodingAll)).mockReturnValue([
|
||||
{ encoding: 'UTF-8', confidence: 0.9 },
|
||||
{ encoding: 'GB2312', confidence: 0.8 }
|
||||
])
|
||||
|
||||
const result = await readTextFileWithAutoEncoding(mockFilePath)
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import * as fs from 'node:fs'
|
||||
import { open, readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isLinux, isPortable } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileMetadata, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import iconv from 'iconv-lite'
|
||||
import { detect as detectEncoding_, detectAll as detectEncodingAll } from 'jschardet'
|
||||
import * as jschardet from 'jschardet'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function initAppDataDir() {
|
||||
@@ -206,56 +207,48 @@ export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 jschardet 库检测文件编码格式
|
||||
* @param filePath - 文件路径
|
||||
* @returns 返回文件的编码格式,如 UTF-8, ascii, GB2312 等
|
||||
*/
|
||||
export function detectEncoding(filePath: string): string {
|
||||
// 读取文件前1KB来检测编码
|
||||
const buffer = Buffer.alloc(1024)
|
||||
const fd = fs.openSync(filePath, 'r')
|
||||
fs.readSync(fd, buffer, 0, 1024, 0)
|
||||
fs.closeSync(fd)
|
||||
const { encoding } = detectEncoding_(buffer)
|
||||
return encoding
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容并自动检测编码格式进行解码
|
||||
* @param filePath - 文件路径
|
||||
* @returns 解码后的文件内容
|
||||
*/
|
||||
export function readTextFileWithAutoEncoding(filePath: string) {
|
||||
const encoding = detectEncoding(filePath)
|
||||
const data = fs.readFileSync(filePath)
|
||||
const content = iconv.decode(data, encoding)
|
||||
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
|
||||
// 读取前1MB以检测编码
|
||||
const buffer = Buffer.alloc(1 * MB)
|
||||
const fh = await open(filePath, 'r')
|
||||
const { buffer: bufferRead } = await fh.read(buffer, 0, 1 * MB, 0)
|
||||
await fh.close()
|
||||
|
||||
if (content.includes('\uFFFD') && encoding !== 'UTF-8') {
|
||||
Logger.error(`文件 ${filePath} 自动识别编码为 ${encoding},但包含错误字符。尝试其他编码`)
|
||||
const buffer = Buffer.alloc(1024)
|
||||
const fd = fs.openSync(filePath, 'r')
|
||||
fs.readSync(fd, buffer, 0, 1024, 0)
|
||||
fs.closeSync(fd)
|
||||
const encodings = detectEncodingAll(buffer)
|
||||
if (encodings.length > 0) {
|
||||
for (const item of encodings) {
|
||||
if (item.encoding === encoding) {
|
||||
continue
|
||||
}
|
||||
Logger.log(`尝试使用 ${item.encoding} 解码文件 ${filePath}`)
|
||||
const content = iconv.decode(buffer, item.encoding)
|
||||
if (!content.includes('\uFFFD')) {
|
||||
Logger.log(`文件 ${filePath} 解码成功,编码为 ${item.encoding}`)
|
||||
return content
|
||||
} else {
|
||||
Logger.error(`文件 ${filePath} 使用 ${item.encoding} 解码失败,尝试下一个编码`)
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.error(`文件 ${filePath} 所有可能的编码均解码失败,尝试使用 UTF-8 解码`)
|
||||
return iconv.decode(buffer, 'UTF-8')
|
||||
// 获取文件编码格式,最多取前两个可能的编码
|
||||
const encodings = jschardet
|
||||
.detectAll(bufferRead)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
encoding: item.encoding === 'ascii' ? 'UTF-8' : item.encoding
|
||||
}))
|
||||
.filter((item, index, array) => array.findIndex((prevItem) => prevItem.encoding === item.encoding) === index)
|
||||
.slice(0, 2)
|
||||
|
||||
if (encodings.length === 0) {
|
||||
Logger.error('Failed to detect encoding. Use utf-8 to decode.')
|
||||
const data = await readFile(filePath)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
return content
|
||||
const data = await readFile(filePath)
|
||||
|
||||
for (const item of encodings) {
|
||||
const encoding = item.encoding
|
||||
const content = iconv.decode(data, encoding)
|
||||
if (content.includes('\uFFFD')) {
|
||||
Logger.error(
|
||||
`File ${filePath} was auto-detected as ${encoding} encoding, but contains invalid characters. Trying other encodings`
|
||||
)
|
||||
} else {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@ const api = {
|
||||
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
|
||||
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
|
||||
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
|
||||
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
||||
read: (fileId: string, detectEncoding?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
|
||||
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
|
||||
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
/**
|
||||
@@ -146,7 +147,8 @@ const api = {
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
|
||||
},
|
||||
fs: {
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||
@@ -309,7 +311,8 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
|
||||
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
|
||||
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
|
||||
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
|
||||
processAction: (actionItem: ActionItem, isFullScreen: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
|
||||
@@ -254,7 +254,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
for (const fileBlock of textFileBlocks) {
|
||||
const file = fileBlock.file
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
|
||||
text = text + fileNameRow + fileContent + divider
|
||||
}
|
||||
|
||||
@@ -49,10 +49,10 @@ import {
|
||||
LLMWebSearchCompleteChunk,
|
||||
LLMWebSearchInProgressChunk,
|
||||
MCPToolCreatedChunk,
|
||||
TextCompleteChunk,
|
||||
TextDeltaChunk,
|
||||
ThinkingCompleteChunk,
|
||||
ThinkingDeltaChunk
|
||||
TextStartChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { type Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
@@ -231,7 +231,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -519,7 +519,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return () => {
|
||||
let accumulatedJson = ''
|
||||
const toolCalls: Record<number, ToolUseBlock> = {}
|
||||
const ChunkIdTypeMap: Record<number, ChunkType> = {}
|
||||
return {
|
||||
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
switch (rawChunk.type) {
|
||||
@@ -615,16 +614,16 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'text': {
|
||||
if (!ChunkIdTypeMap[rawChunk.index]) {
|
||||
ChunkIdTypeMap[rawChunk.index] = ChunkType.TEXT_DELTA // 用textdelta代表文本块
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
break
|
||||
}
|
||||
case 'thinking':
|
||||
case 'redacted_thinking': {
|
||||
if (!ChunkIdTypeMap[rawChunk.index]) {
|
||||
ChunkIdTypeMap[rawChunk.index] = ChunkType.THINKING_DELTA // 用thinkingdelta代表思考块
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -661,15 +660,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'content_block_stop': {
|
||||
if (ChunkIdTypeMap[rawChunk.index] === ChunkType.TEXT_DELTA) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_COMPLETE
|
||||
} as TextCompleteChunk)
|
||||
} else if (ChunkIdTypeMap[rawChunk.index] === ChunkType.THINKING_DELTA) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_COMPLETE
|
||||
} as ThinkingCompleteChunk)
|
||||
}
|
||||
const toolCall = toolCalls[rawChunk.index]
|
||||
if (toolCall) {
|
||||
try {
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk'
|
||||
import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
GeminiOptions,
|
||||
@@ -288,7 +288,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
continue
|
||||
}
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
})
|
||||
@@ -547,20 +547,34 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
let isFirstTextChunk = true
|
||||
let isFirstThinkingChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
for (const candidate of chunk.candidates) {
|
||||
if (candidate.content) {
|
||||
candidate.content.parts?.forEach((part) => {
|
||||
const text = part.text || ''
|
||||
if (part.thought) {
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: text
|
||||
})
|
||||
} else if (part.text) {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: text
|
||||
@@ -593,6 +607,13 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
} as LLMWebSearchCompleteChunk)
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: [...toolCalls]
|
||||
})
|
||||
toolCalls.length = 0
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
OpenAISdkMessageParam,
|
||||
@@ -307,7 +307,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -659,6 +659,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 持续更新usage信息
|
||||
@@ -699,6 +701,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
|
||||
if (reasoningText) {
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: reasoningText
|
||||
@@ -707,6 +715,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// 处理文本内容
|
||||
if (contentSource.content) {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
|
||||
@@ -89,7 +89,7 @@ export abstract class OpenAIBaseClient<
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
encoding_format: this.provider.id === 'voyageai' ? undefined : 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { isEmpty } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
@@ -66,6 +66,9 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
*/
|
||||
public getClient(model: Model) {
|
||||
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiVersion: 'preview' }
|
||||
}
|
||||
return this
|
||||
} else {
|
||||
return this.client
|
||||
@@ -77,15 +80,25 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiHost: `${this.provider.apiHost}/openai/v1` }
|
||||
return new AzureOpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
apiVersion: this.provider.apiVersion,
|
||||
baseURL: this.provider.apiHost
|
||||
})
|
||||
} else {
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override async createCompletions(
|
||||
@@ -173,7 +186,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'input_text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -354,16 +367,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
(m) => (m as OpenAI.Responses.EasyInputMessage).role === 'assistant'
|
||||
) as OpenAI.Responses.EasyInputMessage
|
||||
const finalUserMessage = userMessage.pop() as OpenAI.Responses.EasyInputMessage
|
||||
if (
|
||||
finalAssistantMessage &&
|
||||
Array.isArray(finalAssistantMessage.content) &&
|
||||
finalUserMessage &&
|
||||
Array.isArray(finalUserMessage.content)
|
||||
) {
|
||||
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
|
||||
if (finalUserMessage && Array.isArray(finalUserMessage.content)) {
|
||||
if (finalAssistantMessage && Array.isArray(finalAssistantMessage.content)) {
|
||||
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
|
||||
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
|
||||
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
|
||||
} else {
|
||||
userMessage.push(finalUserMessage)
|
||||
}
|
||||
}
|
||||
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
|
||||
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
|
||||
}
|
||||
|
||||
// 4. 最终请求消息
|
||||
@@ -424,6 +436,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
|
||||
let hasBeenCollectedToolCalls = false
|
||||
let hasReasoningSummary = false
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 处理chunk
|
||||
@@ -435,6 +449,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
switch (output.type) {
|
||||
case 'message':
|
||||
if (output.content[0].type === 'output_text') {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: output.content[0].text
|
||||
@@ -451,6 +471,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
break
|
||||
case 'reasoning':
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
})
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: output.summary.map((s) => s.text).join('\n')
|
||||
@@ -510,6 +536,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
hasReasoningSummary = true
|
||||
break
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
})
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: chunk.delta
|
||||
@@ -535,6 +567,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
break
|
||||
case 'response.output_text.delta': {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: chunk.delta
|
||||
|
||||
@@ -75,11 +75,12 @@ export default class AiProvider {
|
||||
} else {
|
||||
// Existing logic for other models
|
||||
if (!params.enableReasoning) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
// 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降
|
||||
// builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
builder.remove(ThinkChunkMiddlewareName)
|
||||
}
|
||||
// 注意:用client判断会导致typescript类型收窄
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient)) {
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
}
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
|
||||
@@ -252,7 +252,9 @@ async function executeToolCalls(
|
||||
('name' in toolCall &&
|
||||
(toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) ||
|
||||
confirmed.tool.name === toolCall.id ||
|
||||
confirmed.tool.id === toolCall.id
|
||||
confirmed.tool.id === toolCall.id ||
|
||||
('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) ||
|
||||
('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase()))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { ChunkType, TextCompleteChunk, TextDeltaChunk } from '@renderer/types/chunk'
|
||||
import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk'
|
||||
|
||||
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
|
||||
import { CompletionsContext, CompletionsMiddleware } from '../types'
|
||||
@@ -38,7 +38,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 用于跨chunk的状态管理
|
||||
let accumulatedTextContent = ''
|
||||
let hasTextCompleteEventEnqueue = false
|
||||
const enhancedTextStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -53,18 +52,7 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 创建新的chunk,包含处理后的文本
|
||||
controller.enqueue(chunk)
|
||||
} else if (chunk.type === ChunkType.TEXT_COMPLETE) {
|
||||
const textChunk = chunk as TextCompleteChunk
|
||||
controller.enqueue({
|
||||
...textChunk,
|
||||
text: accumulatedTextContent
|
||||
})
|
||||
if (params.onResponse) {
|
||||
params.onResponse(accumulatedTextContent, true)
|
||||
}
|
||||
hasTextCompleteEventEnqueue = true
|
||||
accumulatedTextContent = ''
|
||||
} else if (accumulatedTextContent && !hasTextCompleteEventEnqueue) {
|
||||
} else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) {
|
||||
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
const finalText = accumulatedTextContent
|
||||
ctx._internal.customState!.accumulatedText = finalText
|
||||
@@ -89,7 +77,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
})
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
hasTextCompleteEventEnqueue = true
|
||||
accumulatedTextContent = ''
|
||||
} else {
|
||||
// 其他类型的chunk直接传递
|
||||
|
||||
@@ -65,17 +65,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware =
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
}
|
||||
controller.enqueue(enhancedChunk)
|
||||
} else if (chunk.type === ChunkType.THINKING_COMPLETE) {
|
||||
const thinkingCompleteChunk = chunk as ThinkingCompleteChunk
|
||||
controller.enqueue({
|
||||
...thinkingCompleteChunk,
|
||||
text: accumulatedThinkingContent,
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
})
|
||||
hasThinkingContent = false
|
||||
accumulatedThinkingContent = ''
|
||||
thinkingStartTime = 0
|
||||
} else if (hasThinkingContent && thinkingStartTime > 0) {
|
||||
} else if (hasThinkingContent && thinkingStartTime > 0 && chunk.type !== ChunkType.THINKING_START) {
|
||||
// 收到任何非THINKING_DELTA的chunk时,如果有累积的思考内容,生成THINKING_COMPLETE
|
||||
const thinkingCompleteChunk: ThinkingCompleteChunk = {
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import { ChunkType, TextDeltaChunk, ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk'
|
||||
import {
|
||||
ChunkType,
|
||||
TextDeltaChunk,
|
||||
ThinkingCompleteChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
|
||||
import Logger from 'electron-log/renderer'
|
||||
|
||||
@@ -59,6 +65,8 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
let hasThinkingContent = false
|
||||
let thinkingStartTime = 0
|
||||
|
||||
let isFirstTextChunk = true
|
||||
|
||||
const processedStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -87,6 +95,9 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
if (!hasThinkingContent) {
|
||||
hasThinkingContent = true
|
||||
thinkingStartTime = Date.now()
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
}
|
||||
|
||||
if (extractionResult.content?.trim()) {
|
||||
@@ -98,6 +109,12 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
controller.enqueue(thinkingDeltaChunk)
|
||||
}
|
||||
} else {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
// 发送清理后的文本内容
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
...textChunk,
|
||||
@@ -107,7 +124,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (chunk.type !== ChunkType.TEXT_START) {
|
||||
// 其他类型的chunk直接传递(包括 THINKING_DELTA, THINKING_COMPLETE 等)
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ function createToolUseExtractionTransform(
|
||||
toolCounter += toolUseResponses.length
|
||||
|
||||
if (toolUseResponses.length > 0) {
|
||||
controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' })
|
||||
// 生成 MCP_TOOL_CREATED chunk
|
||||
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--color-list-item: #222;
|
||||
--color-list-item: #252525;
|
||||
--color-list-item-hover: #1e1e1e;
|
||||
|
||||
--modal-background: #111111;
|
||||
|
||||
@@ -139,7 +139,7 @@ ul {
|
||||
}
|
||||
}
|
||||
.message-content-container {
|
||||
border-radius: 10px 0 10px 10px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px 10px 16px;
|
||||
background-color: var(--chat-background-user);
|
||||
align-self: self-end;
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 1em 0;
|
||||
margin: 1.5em 0 1em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: bold;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 2em;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
padding-bottom: 0.3em;
|
||||
@@ -53,8 +55,9 @@
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
margin: 1.3em 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 5px;
|
||||
@@ -82,7 +85,7 @@
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
pre {
|
||||
margin: 1.5em 0;
|
||||
margin: 1.5em 0 !important;
|
||||
}
|
||||
&::marker {
|
||||
color: var(--color-text-3);
|
||||
@@ -108,6 +111,7 @@
|
||||
li code {
|
||||
background: var(--color-background-mute);
|
||||
padding: 3px 5px;
|
||||
margin: 0 2px;
|
||||
border-radius: 5px;
|
||||
word-break: keep-all;
|
||||
white-space: pre;
|
||||
@@ -122,9 +126,7 @@
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
&:has(.mermaid),
|
||||
&:has(.plantuml-preview),
|
||||
&:has(.svg-preview) {
|
||||
&:has(.special-preview) {
|
||||
background-color: transparent;
|
||||
}
|
||||
&:not(pre pre) {
|
||||
@@ -148,16 +150,19 @@
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
color: var(--color-text-light);
|
||||
border-left: 4px solid var(--color-border);
|
||||
font-family: var(--font-family);
|
||||
margin: 1.5em 0;
|
||||
padding: 1em 1.5em;
|
||||
background-color: var(--color-background-soft);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
table {
|
||||
--table-border-radius: 8px;
|
||||
margin: 1em 0;
|
||||
margin: 2em 0;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
border-radius: var(--table-border-radius);
|
||||
overflow: hidden;
|
||||
@@ -182,13 +187,19 @@
|
||||
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
a,
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Alert } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LOCALSTORAGE_KEY = 'openai_alert_closed'
|
||||
|
||||
const OpenAIAlert = () => {
|
||||
const { t } = useTranslation()
|
||||
interface Props {
|
||||
message?: string
|
||||
key?: string
|
||||
}
|
||||
|
||||
const OpenAIAlert = ({ message = t('settings.provider.openai.alert'), key = LOCALSTORAGE_KEY }: Props) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const closed = localStorage.getItem(LOCALSTORAGE_KEY)
|
||||
const closed = localStorage.getItem(key)
|
||||
setVisible(!closed)
|
||||
}, [])
|
||||
}, [key])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
|
||||
message={t('settings.provider.openai.alert')}
|
||||
message={message}
|
||||
closable
|
||||
afterClose={() => {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, '1')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -12,10 +12,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CodePreviewProps {
|
||||
children: string
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
interface CodePreviewProps extends BasicPreviewProps {
|
||||
language: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const MAX_COLLAPSE_HEIGHT = 350
|
||||
@@ -164,19 +164,11 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
/*
|
||||
* FIXME: @tanstack/react-virtual 使用绝对定位,但是会导致
|
||||
* 有气泡样式 `self-end` 并且气泡中只有代码块时整个代码块收缩
|
||||
* 到最小宽度(目前应该是工具栏的宽度)。改为相对定位可以保证宽
|
||||
* 度足够,目前没有发现其他副作用。
|
||||
* 如果发现破坏虚拟列表功能,或者将来有更好的解决方案,再调整。
|
||||
*/
|
||||
position: 'relative',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
||||
willChange: 'transform'
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement}>
|
||||
|
||||
102
src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx
Normal file
102
src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
|
||||
import { Flex, Spin } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
// 管理 viz 实例
|
||||
const vizInitializer = new AsyncInitializer(async () => {
|
||||
const module = await import('@viz-js/viz')
|
||||
return await module.instance()
|
||||
})
|
||||
|
||||
/** 预览 Graphviz 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
*/
|
||||
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const graphvizRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
|
||||
imgSelector: 'svg',
|
||||
prefix: 'graphviz',
|
||||
enableWheelZoom: true
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
// 实际的渲染函数
|
||||
const renderGraphviz = useCallback(async (content: string) => {
|
||||
if (!content || !graphvizRef.current) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const viz = await vizInitializer.get()
|
||||
const svgElement = viz.renderSVGElement(content)
|
||||
|
||||
// 清空容器并添加新的 SVG
|
||||
graphvizRef.current.innerHTML = ''
|
||||
graphvizRef.current.appendChild(svgElement)
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message || 'DOT syntax error or rendering failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderGraphviz(content))
|
||||
}, 300),
|
||||
[renderGraphviz]
|
||||
)
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (children) {
|
||||
setIsLoading(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, debouncedRender])
|
||||
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{error && <PreviewError>{error}</PreviewError>}
|
||||
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledGraphviz = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default memo(GraphvizPreview)
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
/**
|
||||
* 在应用内打开
|
||||
*/
|
||||
const handleOpenInApp = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
openMinapp({
|
||||
id: 'artifacts-preview',
|
||||
name: title,
|
||||
logo: AppLogo,
|
||||
url: filePath
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
|
||||
export default Artifacts
|
||||
404
src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
Normal file
404
src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { Code, Download, Globe, Sparkles } from 'lucide-react'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClipLoader } from 'react-spinners'
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
|
||||
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'HTML Artifacts'
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
const htmlContent = html || ''
|
||||
const hasContent = htmlContent.trim().length > 0
|
||||
|
||||
// 判断是否正在流式生成的逻辑
|
||||
const isStreaming = useMemo(() => {
|
||||
if (!hasContent) return false
|
||||
|
||||
const trimmedHtml = htmlContent.trim()
|
||||
|
||||
// 提前检查:如果包含关键的结束标签,直接判断为完整文档
|
||||
if (/<\/html\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果同时包含 DOCTYPE 和 </body>,通常也是完整文档
|
||||
if (/<!DOCTYPE\s+html/i.test(trimmedHtml) && /<\/body\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 HTML 是否看起来是完整的
|
||||
const indicators = {
|
||||
// 1. 检查常见的 HTML 结构完整性
|
||||
hasHtmlTag: /<html[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
|
||||
|
||||
// 2. 检查 body 标签完整性
|
||||
hasBodyTag: /<body[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml),
|
||||
|
||||
// 3. 检查是否以未闭合的标签结尾
|
||||
endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml),
|
||||
|
||||
// 4. 检查是否有未配对的标签
|
||||
hasUnmatchedTags: checkUnmatchedTags(trimmedHtml),
|
||||
|
||||
// 5. 检查是否以常见的"流式结束"模式结尾
|
||||
endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml)
|
||||
}
|
||||
|
||||
// 如果有明显的未完成标志,则认为正在生成
|
||||
if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 HTML 结构但不完整
|
||||
if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 body 结构但不完整
|
||||
if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 对于简单的 HTML 片段,检查是否看起来是完整的
|
||||
if (!indicators.hasHtmlTag && !indicators.hasBodyTag) {
|
||||
// 如果是简单片段且没有明显的结束标志,可能还在生成
|
||||
return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500
|
||||
}
|
||||
|
||||
return false
|
||||
}, [htmlContent, hasContent])
|
||||
|
||||
// 检查未配对标签的辅助函数
|
||||
function checkUnmatchedTags(html: string): boolean {
|
||||
const stack: string[] = []
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
|
||||
// HTML5 void 元素(自闭合元素)的完整列表
|
||||
const voidElements = [
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
]
|
||||
|
||||
let match
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const [fullTag, tagName] = match
|
||||
const isClosing = fullTag.startsWith('</')
|
||||
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
|
||||
|
||||
if (isSelfClosing) continue
|
||||
|
||||
if (isClosing) {
|
||||
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
||||
return true // 找到不匹配的闭合标签
|
||||
}
|
||||
} else {
|
||||
stack.push(tagName.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length > 0 // 还有未闭合的标签
|
||||
}
|
||||
|
||||
// 获取格式化的代码预览
|
||||
function getFormattedCodePreview(html: string): string {
|
||||
const trimmed = html.trim()
|
||||
const lines = trimmed.split('\n')
|
||||
const lastFewLines = lines.slice(-3) // 显示最后3行
|
||||
return lastFewLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 在编辑器中打开
|
||||
*/
|
||||
const handleOpenInEditor = () => {
|
||||
setIsPopupOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
const handleClosePopup = () => {
|
||||
setIsPopupOpen(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, htmlContent)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载到本地
|
||||
*/
|
||||
const handleDownload = async () => {
|
||||
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
||||
await window.api.file.save(fileName, htmlContent)
|
||||
window.message.success({ content: t('message.download.success'), key: 'download' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container $isStreaming={isStreaming}>
|
||||
<Header>
|
||||
<IconWrapper $isStreaming={isStreaming}>
|
||||
{isStreaming ? <Sparkles size={20} color="white" /> : <Globe size={20} color="white" />}
|
||||
</IconWrapper>
|
||||
<TitleSection>
|
||||
<Title>{title}</Title>
|
||||
<TypeBadge>
|
||||
<Code size={12} />
|
||||
<span>HTML</span>
|
||||
</TypeBadge>
|
||||
</TitleSection>
|
||||
</Header>
|
||||
<Content>
|
||||
{isStreaming && !hasContent ? (
|
||||
<GeneratingContainer>
|
||||
<ClipLoader size={20} color="var(--color-primary)" />
|
||||
<GeneratingText>{t('html_artifacts.generating_content', 'Generating content...')}</GeneratingText>
|
||||
</GeneratingContainer>
|
||||
) : isStreaming && hasContent ? (
|
||||
<>
|
||||
<TerminalPreview $theme={theme}>
|
||||
<TerminalContent $theme={theme}>
|
||||
<TerminalLine>
|
||||
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
|
||||
<TerminalCodeLine $theme={theme}>
|
||||
{getFormattedCodePreview(htmlContent)}
|
||||
<TerminalCursor $theme={theme} />
|
||||
</TerminalCodeLine>
|
||||
</TerminalLine>
|
||||
</TerminalContent>
|
||||
</TerminalPreview>
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</>
|
||||
) : (
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
|
||||
{t('code_block.download')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)}
|
||||
</Content>
|
||||
</Container>
|
||||
|
||||
{/* 弹窗组件 */}
|
||||
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div<{ $isStreaming: boolean }>`
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 10px 0;
|
||||
`
|
||||
|
||||
const GeneratingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
min-height: 78px;
|
||||
`
|
||||
|
||||
const GeneratingText = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
|
||||
background-size: 200% 100%;
|
||||
animation: ${shimmer} 3s ease-in-out infinite;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
||||
transition: background 0.3s ease;
|
||||
|
||||
${(props) =>
|
||||
props.$isStreaming &&
|
||||
`
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */
|
||||
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
|
||||
`}
|
||||
`
|
||||
|
||||
const TitleSection = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const Title = styled.h3`
|
||||
margin: 0 !important;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
`
|
||||
|
||||
const TypeBadge = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px;
|
||||
background: var(--color-background-mute);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
width: fit-content;
|
||||
`
|
||||
|
||||
const Content = styled.div`
|
||||
padding: 0;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
margin: 16px !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||
margin: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
`
|
||||
|
||||
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
|
||||
padding: 12px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
min-height: 80px;
|
||||
`
|
||||
|
||||
const TerminalLine = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
background-color: transparent !important;
|
||||
`
|
||||
|
||||
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
animation: ${keyframes`
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
`} 1s infinite;
|
||||
margin-left: 2px;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsCard
|
||||
459
src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
Normal file
459
src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal } from 'antd'
|
||||
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface HtmlArtifactsPopupProps {
|
||||
open: boolean
|
||||
title: string
|
||||
html: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ViewMode = 'split' | 'code' | 'preview'
|
||||
|
||||
// 视图模式配置
|
||||
const VIEW_MODE_CONFIG = {
|
||||
split: {
|
||||
key: 'split' as const,
|
||||
icon: MonitorSpeaker,
|
||||
i18nKey: 'html_artifacts.split'
|
||||
},
|
||||
code: {
|
||||
key: 'code' as const,
|
||||
icon: Code,
|
||||
i18nKey: 'html_artifacts.code'
|
||||
},
|
||||
preview: {
|
||||
key: 'preview' as const,
|
||||
icon: Monitor,
|
||||
i18nKey: 'html_artifacts.preview'
|
||||
}
|
||||
} as const
|
||||
|
||||
// 抽取头部组件
|
||||
interface ModalHeaderProps {
|
||||
title: string
|
||||
isFullscreen: boolean
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onToggleFullscreen: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
|
||||
title,
|
||||
isFullscreen,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onToggleFullscreen,
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const viewButtons = useMemo(() => {
|
||||
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
|
||||
<ViewButton
|
||||
key={key}
|
||||
size="small"
|
||||
type={viewMode === key ? 'primary' : 'default'}
|
||||
icon={<Icon size={14} />}
|
||||
onClick={() => onViewModeChange(key)}>
|
||||
{t(i18nKey)}
|
||||
</ViewButton>
|
||||
))
|
||||
}, [viewMode, onViewModeChange, t])
|
||||
|
||||
return (
|
||||
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
<HeaderCenter>
|
||||
<ViewControls>{viewButtons}</ViewControls>
|
||||
</HeaderCenter>
|
||||
<HeaderRight>
|
||||
<Button
|
||||
onClick={onToggleFullscreen}
|
||||
type="text"
|
||||
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
className="nodrag"
|
||||
/>
|
||||
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
|
||||
</HeaderRight>
|
||||
</ModalHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取代码编辑器组件
|
||||
interface CodeSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
onCodeChange: (code: string) => void
|
||||
}
|
||||
|
||||
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<CodeSection $visible={visible}>
|
||||
<CodeEditorWrapper>
|
||||
<CodeEditor
|
||||
value={html}
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={onCodeChange}
|
||||
style={{ height: '100%' }}
|
||||
options={{
|
||||
stream: false,
|
||||
collapsible: false
|
||||
}}
|
||||
/>
|
||||
</CodeEditorWrapper>
|
||||
</CodeSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取预览组件
|
||||
interface PreviewSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
|
||||
const htmlContent = html || ''
|
||||
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const latestHtmlRef = useRef(htmlContent)
|
||||
const currentRenderedHtmlRef = useRef(htmlContent)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 更新最新的HTML内容引用
|
||||
useEffect(() => {
|
||||
latestHtmlRef.current = htmlContent
|
||||
}, [htmlContent])
|
||||
|
||||
// 固定频率渲染 HTML 内容,每2秒钟检查并更新一次
|
||||
useEffect(() => {
|
||||
// 立即设置初始内容
|
||||
setDebouncedHtml(htmlContent)
|
||||
currentRenderedHtmlRef.current = htmlContent
|
||||
|
||||
// 设置定时器,每2秒检查一次内容是否有变化
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
|
||||
setDebouncedHtml(latestHtmlRef.current)
|
||||
currentRenderedHtmlRef.current = latestHtmlRef.current
|
||||
}
|
||||
}, 2000) // 2秒固定频率
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, []) // 只在组件挂载时执行一次
|
||||
|
||||
if (!visible) return null
|
||||
const isHtmlEmpty = !debouncedHtml.trim()
|
||||
|
||||
return (
|
||||
<PreviewSection $visible={visible}>
|
||||
{isHtmlEmpty ? (
|
||||
<EmptyPreview>
|
||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||
</EmptyPreview>
|
||||
) : (
|
||||
<PreviewFrame
|
||||
key={debouncedHtml} // 强制重新创建iframe当内容变化时
|
||||
srcDoc={debouncedHtml}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
)}
|
||||
</PreviewSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 主弹窗组件
|
||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [currentHtml, setCurrentHtml] = useState(html)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// 当外部html更新时,同步更新内部状态
|
||||
useEffect(() => {
|
||||
setCurrentHtml(html)
|
||||
}, [html])
|
||||
|
||||
// 计算视图可见性
|
||||
const viewVisibility = useMemo(
|
||||
() => ({
|
||||
code: viewMode === 'split' || viewMode === 'code',
|
||||
preview: viewMode === 'split' || viewMode === 'preview'
|
||||
}),
|
||||
[viewMode]
|
||||
)
|
||||
|
||||
// 计算Modal属性
|
||||
const modalProps = useMemo(
|
||||
() => ({
|
||||
width: isFullscreen ? '100vw' : '90vw',
|
||||
height: isFullscreen ? '100vh' : 'auto',
|
||||
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
|
||||
}),
|
||||
[isFullscreen]
|
||||
)
|
||||
|
||||
const handleOk = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCodeChange = useCallback((newCode: string) => {
|
||||
setCurrentHtml(newCode)
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
$isFullscreen={isFullscreen}
|
||||
title={
|
||||
<ModalHeaderComponent
|
||||
title={title}
|
||||
isFullscreen={isFullscreen}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
afterClose={handleClose}
|
||||
centered
|
||||
destroyOnClose
|
||||
{...modalProps}
|
||||
footer={null}
|
||||
closable={false}>
|
||||
<Container>
|
||||
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
|
||||
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
|
||||
</Container>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件保持不变
|
||||
const commonModalBodyStyles = `
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
`
|
||||
|
||||
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
${(props) =>
|
||||
props.$isFullscreen
|
||||
? `
|
||||
.ant-modal-wrap {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
height: calc(100vh - 45px) !important;
|
||||
${commonModalBodyStyles}
|
||||
max-height: initial !important;
|
||||
}
|
||||
`
|
||||
: `
|
||||
.ant-modal-body {
|
||||
height: 80vh !important;
|
||||
${commonModalBodyStyles}
|
||||
min-height: 600px !important;
|
||||
}
|
||||
`}
|
||||
|
||||
.ant-modal-body {
|
||||
${commonModalBodyStyles}
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: ${(props) => (props.$isFullscreen ? '0px' : '12px')};
|
||||
overflow: hidden;
|
||||
height: ${(props) => (props.$isFullscreen ? '100vh' : 'auto')};
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 10px 12px !important;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
const ModalHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const HeaderLeft = styled.div<{ $isFullscreen?: boolean }>`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: ${(props) => (props.$isFullscreen && isMac ? '65px' : '12px')};
|
||||
`
|
||||
|
||||
const HeaderCenter = styled.div`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const HeaderRight = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TitleText = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
const ViewControls = styled.div`
|
||||
display: flex;
|
||||
width: auto;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
background: var(--color-background-mute);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
-webkit-app-region: no-drag;
|
||||
`
|
||||
|
||||
const ViewButton = styled(Button)`
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.ant-btn-default {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const CodeSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const CodeEditorWrapper = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.monaco-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
height: 100% !important;
|
||||
}
|
||||
`
|
||||
|
||||
const PreviewSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'block' : 'none')};
|
||||
`
|
||||
|
||||
const PreviewFrame = styled.iframe`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: white;
|
||||
`
|
||||
const EmptyPreview = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsPopup
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { Flex, Spin } from 'antd'
|
||||
@@ -7,16 +7,14 @@ import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/** 预览 Mermaid 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
||||
*/
|
||||
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
@@ -143,8 +141,8 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid special-preview" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
@@ -154,14 +152,4 @@ const StyledMermaid = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
const StyledError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(MermaidPreview)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { Spin } from 'antd'
|
||||
import pako from 'pako'
|
||||
import React, { memo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
|
||||
function encode64(data: Uint8Array) {
|
||||
let r = ''
|
||||
@@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
|
||||
)
|
||||
}
|
||||
|
||||
interface PlantUMLProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
14
src/renderer/src/components/CodeBlockView/PreviewError.tsx
Normal file
14
src/renderer/src/components/CodeBlockView/PreviewError.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { memo } from 'react'
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
const PreviewError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(PreviewError)
|
||||
@@ -1,15 +1,12 @@
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,7 +55,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return <div ref={svgContainerRef} className="svg-preview" />
|
||||
return <div ref={svgContainerRef} className="svg-preview special-preview" />
|
||||
}
|
||||
|
||||
export default memo(SvgPreview)
|
||||
|
||||
20
src/renderer/src/components/CodeBlockView/constants.ts
Normal file
20
src/renderer/src/components/CodeBlockView/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import GraphvizPreview from './GraphvizPreview'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
/**
|
||||
* 特殊视图语言列表
|
||||
*/
|
||||
export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz']
|
||||
|
||||
/**
|
||||
* 特殊视图组件映射表
|
||||
*/
|
||||
export const SPECIAL_VIEW_COMPONENTS = {
|
||||
mermaid: MermaidPreview,
|
||||
plantuml: PlantUmlPreview,
|
||||
svg: SvgPreview,
|
||||
dot: GraphvizPreview,
|
||||
graphviz: GraphvizPreview
|
||||
} as const
|
||||
2
src/renderer/src/components/CodeBlockView/index.ts
Normal file
2
src/renderer/src/components/CodeBlockView/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './view'
|
||||
14
src/renderer/src/components/CodeBlockView/types.ts
Normal file
14
src/renderer/src/components/CodeBlockView/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CodeTool } from '@renderer/components/CodeToolbar'
|
||||
|
||||
/**
|
||||
* 预览组件的基本 props
|
||||
*/
|
||||
export interface BasicPreviewProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图模式
|
||||
*/
|
||||
export type ViewMode = 'source' | 'special' | 'split'
|
||||
@@ -4,7 +4,7 @@ import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/compon
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { getExtensionByLanguage, isValidPlantUML } from '@renderer/utils/markdown'
|
||||
import { getExtensionByLanguage, isHtmlCode, isValidPlantUML } from '@renderer/utils/markdown'
|
||||
import dayjs from 'dayjs'
|
||||
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CodePreview from './CodePreview'
|
||||
import HtmlArtifacts from './HtmlArtifacts'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
||||
import HtmlArtifactsCard from './HtmlArtifactsCard'
|
||||
import StatusBar from './StatusBar'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
type ViewMode = 'source' | 'special' | 'split'
|
||||
import { ViewMode } from './types'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
@@ -42,9 +39,10 @@ interface Props {
|
||||
* - quick 工具
|
||||
* - core 工具
|
||||
*/
|
||||
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
||||
const { t } = useTranslation()
|
||||
const { codeEditor, codeExecution } = useSettings()
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('special')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [output, setOutput] = useState('')
|
||||
@@ -56,7 +54,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
return codeExecution.enabled && language === 'python'
|
||||
}, [codeExecution.enabled, language])
|
||||
|
||||
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
|
||||
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
|
||||
|
||||
const isInSpecialView = useMemo(() => {
|
||||
return hasSpecialView && viewMode === 'special'
|
||||
@@ -200,14 +198,16 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
|
||||
// 特殊视图组件映射
|
||||
const specialView = useMemo(() => {
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
|
||||
} else if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
|
||||
} else if (language === 'svg') {
|
||||
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
|
||||
const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS]
|
||||
|
||||
if (!SpecialView) return null
|
||||
|
||||
// PlantUML 语法验证
|
||||
if (language === 'plantuml' && !isValidPlantUML(children)) {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
|
||||
return <SpecialView setTools={setTools}>{children}</SpecialView>
|
||||
}, [children, language])
|
||||
|
||||
const renderHeader = useMemo(() => {
|
||||
@@ -228,27 +228,29 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
)
|
||||
}, [specialView, sourceView, viewMode])
|
||||
|
||||
const renderArtifacts = useMemo(() => {
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifacts html={children} />
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
|
||||
if (language === 'html' && isHtmlCode(children)) {
|
||||
return <HtmlArtifactsCard html={children} />
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
|
||||
{renderHeader}
|
||||
<CodeToolbar tools={tools} />
|
||||
{renderContent}
|
||||
{renderArtifacts}
|
||||
{isExecutable && output && <StatusBar>{output}</StatusBar>}
|
||||
</CodeBlockWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* FIXME: 最小宽度用于解决两个问题。
|
||||
* 一是 CodePreview 在气泡样式下的用户消息中无法撑开气泡,
|
||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||
*/
|
||||
min-width: 45ch;
|
||||
|
||||
.code-toolbar {
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
@@ -295,5 +297,3 @@ const SplitViewWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodeBlockView)
|
||||
@@ -12,45 +12,111 @@ const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 特殊语言加载器
|
||||
*/
|
||||
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
|
||||
dot: async () => {
|
||||
const mod = await import('@viz-js/lang-dot')
|
||||
return mod.dot()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言扩展
|
||||
*/
|
||||
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
// 尝试加载特殊语言
|
||||
const specialLoader = specialLanguageLoaders[normalizedLang]
|
||||
if (specialLoader) {
|
||||
try {
|
||||
return await specialLoader()
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load language ${normalizedLang}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 uiw/codemirror 包含的语言
|
||||
try {
|
||||
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
return extension || null
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load language ${normalizedLang}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 linter 扩展
|
||||
*/
|
||||
async function loadLinterExtension(language: string): Promise<Extension | null> {
|
||||
const loader = linterLoaders[language]
|
||||
if (!loader) return null
|
||||
|
||||
try {
|
||||
return await loader()
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load linter for ${language}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言相关扩展
|
||||
*/
|
||||
export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
const { languageMap } = useCodeStyle()
|
||||
const [extensions, setExtensions] = useState<Extension[]>([])
|
||||
|
||||
// 加载语言
|
||||
useEffect(() => {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
let cancelled = false
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
const loadAllExtensions = async () => {
|
||||
try {
|
||||
// 加载所有扩展
|
||||
const [languageResult, linterResult] = await Promise.allSettled([
|
||||
loadLanguageExtension(language, languageMap),
|
||||
lint ? loadLinterExtension(language) : Promise.resolve(null)
|
||||
])
|
||||
|
||||
import('@uiw/codemirror-extensions-langs')
|
||||
.then(({ loadLanguage }) => {
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
if (extension) {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
if (cancelled) return
|
||||
|
||||
const results: Extension[] = []
|
||||
|
||||
// 语言扩展
|
||||
if (languageResult.status === 'fulfilled' && languageResult.value) {
|
||||
results.push(languageResult.value)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(`Failed to load language: ${normalizedLang}`, error)
|
||||
})
|
||||
}, [language, languageMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lint) return
|
||||
// linter 扩展
|
||||
if (linterResult.status === 'fulfilled' && linterResult.value) {
|
||||
results.push(linterResult.value)
|
||||
}
|
||||
|
||||
const loader = linterLoaders[language]
|
||||
if (loader) {
|
||||
loader()
|
||||
.then((extension) => {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to load linter for ${language}`, error)
|
||||
})
|
||||
setExtensions(results)
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.debug('Failed to load language extensions:', error)
|
||||
setExtensions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [language, lint])
|
||||
|
||||
loadAllExtensions()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [language, lint, languageMap])
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ interface Props {
|
||||
extensions?: Extension[]
|
||||
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
||||
style?: React.CSSProperties
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,8 @@ const CodeEditor = ({
|
||||
maxHeight,
|
||||
options,
|
||||
extensions,
|
||||
style
|
||||
style,
|
||||
editable = true
|
||||
}: Props) => {
|
||||
const {
|
||||
fontSize,
|
||||
@@ -190,7 +192,7 @@ const CodeEditor = ({
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||
editable={true}
|
||||
editable={editable}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
theme={activeCmTheme}
|
||||
extensions={customExtensions}
|
||||
|
||||
@@ -140,7 +140,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||
const [isWholeWord, setIsWholeWord] = useState(false)
|
||||
const [allRanges, setAllRanges] = useState<Range[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const prevSearchText = useRef('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -182,15 +182,18 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
[allRanges, currentIndex]
|
||||
)
|
||||
|
||||
const search = useCallback(() => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
setSearchCompleted(SearchCompletedState.Searched)
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||
setAllRanges(ranges)
|
||||
setCurrentIndex(0)
|
||||
}
|
||||
}, [target, filter, isCaseSensitive, isWholeWord])
|
||||
const search = useCallback(
|
||||
(jump = false) => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
setSearchCompleted(SearchCompletedState.Searched)
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||
setAllRanges(ranges)
|
||||
setCurrentIndex(jump && ranges.length > 0 ? 0 : -1)
|
||||
}
|
||||
},
|
||||
[target, filter, isCaseSensitive, isWholeWord]
|
||||
)
|
||||
|
||||
const implementation = useMemo(
|
||||
() => ({
|
||||
@@ -207,7 +210,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
search()
|
||||
search(false)
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -231,11 +234,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
},
|
||||
search: () => {
|
||||
search()
|
||||
search(true)
|
||||
locateByIndex(true)
|
||||
},
|
||||
silentSearch: () => {
|
||||
search()
|
||||
search(false)
|
||||
locateByIndex(false)
|
||||
},
|
||||
focus: () => {
|
||||
@@ -302,7 +305,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
|
||||
useEffect(() => {
|
||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||
search()
|
||||
search(true)
|
||||
}
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
|
||||
|
||||
@@ -365,16 +368,12 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
</InputWrapper>
|
||||
<Separator></Separator>
|
||||
<SearchResults>
|
||||
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
||||
allRanges.length > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<NoResults>{t('common.no_results')}</NoResults>
|
||||
)
|
||||
{searchCompleted !== SearchCompletedState.NotSearched && allRanges.length > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
|
||||
)}
|
||||
@@ -477,10 +476,6 @@ const SearchResultsPlaceholder = styled.span`
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
const NoResults = styled.span`
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const SearchResultCount = styled.span`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
17
src/renderer/src/components/MaxContextCount.tsx
Normal file
17
src/renderer/src/components/MaxContextCount.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { MAX_CONTEXT_COUNT } from '@renderer/config/constant'
|
||||
import { Infinity as InfinityIcon } from 'lucide-react'
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
type Props = {
|
||||
maxContext: number
|
||||
style?: CSSProperties
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function MaxContextCount({ maxContext, style, size = 14 }: Props) {
|
||||
return maxContext === MAX_CONTEXT_COUNT ? (
|
||||
<InfinityIcon size={size} style={style} aria-label="infinity" />
|
||||
) : (
|
||||
<span style={style}>{maxContext.toString()}</span>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from
|
||||
import { Trash } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { isLlmProvider, useApiKeys } from './hook'
|
||||
import ApiKeyItem from './item'
|
||||
@@ -87,7 +88,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
: keys
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListContainer>
|
||||
{/* Keys 列表 */}
|
||||
<Card
|
||||
size="small"
|
||||
@@ -122,7 +123,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Flex align="center" justify="space-between" style={{ marginTop: '0.5rem' }}>
|
||||
<Flex dir="row" align="center" justify="space-between" style={{ marginTop: 15 }}>
|
||||
{/* 帮助文本 */}
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
|
||||
@@ -166,7 +167,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</>
|
||||
</ListContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,3 +223,8 @@ export const DocPreprocessApiKeyList: FC<SpecificApiKeyListProps> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ListContainer = styled.div`
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
`
|
||||
|
||||
@@ -54,7 +54,7 @@ const FloatingSidebar: FC<Props> = ({
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
maxHeight: maxHeight
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -82,6 +82,9 @@ const FloatingSidebar: FC<Props> = ({
|
||||
|
||||
const PopoverContent = styled.div<{ maxHeight: number }>`
|
||||
max-height: ${(props) => props.maxHeight}px;
|
||||
&.ant-popover-inner-content {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
`
|
||||
|
||||
export default FloatingSidebar
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Center } from '@renderer/components/Layout'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import App from '@renderer/pages/apps/App'
|
||||
import { Popover } from 'antd'
|
||||
import { Empty } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const MinAppsPopover: FC<Props> = ({ children }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { minapps } = useMinapps()
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 100)
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMaxHeight(window.innerHeight - 100)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const content = (
|
||||
<PopoverContent maxHeight={maxHeight}>
|
||||
<AppsContainer>
|
||||
{minapps.map((app) => (
|
||||
<App key={app.id} app={app} onClick={handleClose} size={50} />
|
||||
))}
|
||||
{isEmpty(minapps) && (
|
||||
<Center>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Center>
|
||||
)}
|
||||
</AppsContainer>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
content={content}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
styles={{ body: { padding: 25 } }}>
|
||||
{children}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
|
||||
max-height: ${(props) => props.maxHeight}px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const AppsContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(90px, 1fr));
|
||||
gap: 18px;
|
||||
`
|
||||
|
||||
export default MinAppsPopover
|
||||
353
src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
Normal file
353
src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
analyzeMessageContent,
|
||||
CONTENT_TYPES,
|
||||
ContentType,
|
||||
MessageContentStats,
|
||||
processMessageContent
|
||||
} from '@renderer/utils/knowledge'
|
||||
import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd'
|
||||
import { Check, CircleHelp } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
// 内容类型配置
|
||||
const CONTENT_TYPE_CONFIG = {
|
||||
[CONTENT_TYPES.TEXT]: {
|
||||
label: 'chat.save.knowledge.content.maintext.title',
|
||||
description: 'chat.save.knowledge.content.maintext.description'
|
||||
},
|
||||
[CONTENT_TYPES.CODE]: {
|
||||
label: 'chat.save.knowledge.content.code.title',
|
||||
description: 'chat.save.knowledge.content.code.description'
|
||||
},
|
||||
[CONTENT_TYPES.THINKING]: {
|
||||
label: 'chat.save.knowledge.content.thinking.title',
|
||||
description: 'chat.save.knowledge.content.thinking.description'
|
||||
},
|
||||
[CONTENT_TYPES.TOOL_USE]: {
|
||||
label: 'chat.save.knowledge.content.tool_use.title',
|
||||
description: 'chat.save.knowledge.content.tool_use.description'
|
||||
},
|
||||
[CONTENT_TYPES.CITATION]: {
|
||||
label: 'chat.save.knowledge.content.citation.title',
|
||||
description: 'chat.save.knowledge.content.citation.description'
|
||||
},
|
||||
[CONTENT_TYPES.TRANSLATION]: {
|
||||
label: 'chat.save.knowledge.content.translation.title',
|
||||
description: 'chat.save.knowledge.content.translation.description'
|
||||
},
|
||||
[CONTENT_TYPES.ERROR]: {
|
||||
label: 'chat.save.knowledge.content.error.title',
|
||||
description: 'chat.save.knowledge.content.error.description'
|
||||
},
|
||||
[CONTENT_TYPES.FILE]: {
|
||||
label: 'chat.save.knowledge.content.file.title',
|
||||
description: 'chat.save.knowledge.content.file.description'
|
||||
}
|
||||
} as const
|
||||
|
||||
// Tag 颜色常量
|
||||
const TAG_COLORS = {
|
||||
SELECTED: '#008001',
|
||||
UNSELECTED: '#8c8c8c'
|
||||
} as const
|
||||
|
||||
interface ContentTypeOption {
|
||||
type: ContentType
|
||||
label: string
|
||||
count: number
|
||||
enabled: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ShowParams {
|
||||
message: Message
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface SaveResult {
|
||||
success: boolean
|
||||
savedCount: number
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: SaveResult | null) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ message, title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedBaseId, setSelectedBaseId] = useState<string>()
|
||||
const [selectedTypes, setSelectedTypes] = useState<ContentType[]>([])
|
||||
const [hasInitialized, setHasInitialized] = useState(false)
|
||||
const { bases } = useKnowledgeBases()
|
||||
const { addNote, addFiles } = useKnowledge(selectedBaseId || '')
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 分析消息内容统计
|
||||
const contentStats = useMemo(() => analyzeMessageContent(message), [message])
|
||||
|
||||
// 生成内容类型选项(只显示有内容的类型)
|
||||
const contentTypeOptions: ContentTypeOption[] = useMemo(() => {
|
||||
return Object.entries(CONTENT_TYPE_CONFIG)
|
||||
.map(([type, config]) => {
|
||||
const contentType = type as ContentType
|
||||
const count = contentStats[contentType as keyof MessageContentStats] || 0
|
||||
return {
|
||||
type: contentType,
|
||||
count,
|
||||
enabled: count > 0,
|
||||
label: t(config.label),
|
||||
description: t(config.description)
|
||||
}
|
||||
})
|
||||
.filter((option) => option.enabled) // 只显示有内容的类型
|
||||
}, [contentStats, t])
|
||||
|
||||
// 知识库选项
|
||||
const knowledgeBaseOptions = useMemo(
|
||||
() =>
|
||||
bases.map((base) => ({
|
||||
label: base.name,
|
||||
value: base.id,
|
||||
disabled: !base.version // 如果知识库没有配置好就禁用
|
||||
})),
|
||||
[bases]
|
||||
)
|
||||
|
||||
// 合并状态计算
|
||||
const formState = useMemo(() => {
|
||||
const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version
|
||||
const hasContent = contentTypeOptions.length > 0
|
||||
const selectedCount = contentTypeOptions
|
||||
.filter((option) => selectedTypes.includes(option.type))
|
||||
.reduce((sum, option) => sum + option.count, 0)
|
||||
|
||||
return {
|
||||
hasValidBase,
|
||||
hasContent,
|
||||
canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent,
|
||||
selectedCount,
|
||||
hasNoSelection: selectedTypes.length === 0 && hasContent
|
||||
}
|
||||
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes])
|
||||
|
||||
// 默认选择第一个可用的知识库
|
||||
useEffect(() => {
|
||||
if (!selectedBaseId) {
|
||||
const firstAvailableBase = bases.find((base) => base.version)
|
||||
if (firstAvailableBase) {
|
||||
setSelectedBaseId(firstAvailableBase.id)
|
||||
}
|
||||
}
|
||||
}, [bases, selectedBaseId])
|
||||
|
||||
// 默认选择所有可用的内容类型(仅在初始化时)
|
||||
useEffect(() => {
|
||||
if (!hasInitialized && contentTypeOptions.length > 0) {
|
||||
const availableTypes = contentTypeOptions.map((option) => option.type)
|
||||
setSelectedTypes(availableTypes)
|
||||
setHasInitialized(true)
|
||||
}
|
||||
}, [contentTypeOptions, hasInitialized])
|
||||
|
||||
// 计算UI状态
|
||||
const uiState = useMemo(() => {
|
||||
if (!formState.hasContent) {
|
||||
return { type: 'empty', message: t('chat.save.knowledge.empty.no_content') }
|
||||
}
|
||||
if (bases.length === 0) {
|
||||
return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') }
|
||||
}
|
||||
return { type: 'form' }
|
||||
}, [formState.hasContent, bases.length, t])
|
||||
|
||||
// 处理内容类型选择切换
|
||||
const handleContentTypeToggle = (type: ContentType) => {
|
||||
setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]))
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
if (!formState.canSubmit) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
let savedCount = 0
|
||||
|
||||
try {
|
||||
const result = processMessageContent(message, selectedTypes)
|
||||
|
||||
// 保存文本内容
|
||||
if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) {
|
||||
await addNote(result.text)
|
||||
savedCount++
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) {
|
||||
addFiles(result.files)
|
||||
savedCount += result.files.length
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
resolve({ success: true, savedCount })
|
||||
} catch (error) {
|
||||
Logger.error('[SaveToKnowledgePopup] save failed:', error)
|
||||
window.message.error(t('chat.save.knowledge.error.save_failed'))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => (
|
||||
<EmptyContainer>
|
||||
<Text type="secondary">{uiState.message}</Text>
|
||||
</EmptyContainer>
|
||||
)
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label={t('chat.save.knowledge.select.base.title')}
|
||||
help={!formState.hasValidBase && selectedBaseId ? t('chat.save.knowledge.error.invalid_base') : undefined}
|
||||
validateStatus={!formState.hasValidBase && selectedBaseId ? 'error' : undefined}>
|
||||
<Select
|
||||
value={selectedBaseId}
|
||||
onChange={setSelectedBaseId}
|
||||
options={knowledgeBaseOptions}
|
||||
placeholder={t('chat.save.knowledge.select.base.placeholder')}
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('chat.save.knowledge.select.content.title')}>
|
||||
<Flex gap={8} style={{ flexDirection: 'column' }}>
|
||||
{contentTypeOptions.map((option) => (
|
||||
<ContentTypeItem
|
||||
key={option.type}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
onClick={() => handleContentTypeToggle(option.type)}>
|
||||
<Flex align="center" gap={8}>
|
||||
<CustomTag
|
||||
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
|
||||
size={12}>
|
||||
{option.count}
|
||||
</CustomTag>
|
||||
<span>{option.label}</span>
|
||||
<Tooltip title={option.description} mouseLeaveDelay={0}>
|
||||
<CircleHelp size={16} style={{ cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
|
||||
</ContentTypeItem>
|
||||
))}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{formState.selectedCount > 0 && (
|
||||
<InfoContainer>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('chat.save.knowledge.select.content.tip', { count: formState.selectedCount })}
|
||||
</Text>
|
||||
</InfoContainer>
|
||||
)}
|
||||
|
||||
{formState.hasNoSelection && (
|
||||
<InfoContainer>
|
||||
<Text type="warning" style={{ fontSize: '12px' }}>
|
||||
{t('chat.save.knowledge.error.no_content_selected')}
|
||||
</Text>
|
||||
</InfoContainer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title || t('chat.save.knowledge.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
destroyOnClose
|
||||
centered
|
||||
width={500}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{
|
||||
loading,
|
||||
disabled: !formState.canSubmit
|
||||
}}>
|
||||
{uiState.type === 'empty' ? renderEmptyState() : renderFormContent()}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'SaveToKnowledgePopup'
|
||||
|
||||
export default class SaveToKnowledgePopup {
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
static show(props: ShowParams): Promise<SaveResult | null> {
|
||||
return new Promise<SaveResult | null>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(result) => {
|
||||
resolve(result)
|
||||
this.hide()
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
`
|
||||
|
||||
const ContentTypeItem = styled(Flex)`
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const InfoContainer = styled.div`
|
||||
background: var(--color-background-soft);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 16px;
|
||||
`
|
||||
@@ -27,6 +27,11 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
|
||||
const clearTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 添加更新item选中状态的方法
|
||||
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
|
||||
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
|
||||
}, [])
|
||||
|
||||
const open = useCallback((options: QuickPanelOpenOptions) => {
|
||||
if (clearTimer.current) {
|
||||
clearTimeout(clearTimer.current)
|
||||
@@ -77,6 +82,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
updateItemSelection,
|
||||
|
||||
isVisible,
|
||||
symbol,
|
||||
@@ -90,7 +96,21 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
beforeAction,
|
||||
afterAction
|
||||
}),
|
||||
[open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction]
|
||||
[
|
||||
open,
|
||||
close,
|
||||
updateItemSelection,
|
||||
isVisible,
|
||||
symbol,
|
||||
list,
|
||||
title,
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
]
|
||||
)
|
||||
|
||||
return <QuickPanelContext value={value}>{children}</QuickPanelContext>
|
||||
|
||||
@@ -52,6 +52,7 @@ export type QuickPanelListItem = {
|
||||
export interface QuickPanelContextType {
|
||||
readonly open: (options: QuickPanelOpenOptions) => void
|
||||
readonly close: (action?: QuickPanelCloseAction) => void
|
||||
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
|
||||
readonly isVisible: boolean
|
||||
readonly symbol: string
|
||||
readonly list: QuickPanelListItem[]
|
||||
|
||||
@@ -50,7 +50,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const [isMouseOver, setIsMouseOver] = useState(false)
|
||||
|
||||
const scrollTriggerRef = useRef<QuickPanelScrollTrigger>('initial')
|
||||
const [_index, setIndex] = useState(ctx.defaultIndex)
|
||||
const [_index, setIndex] = useState(-1)
|
||||
const index = useDeferredValue(_index)
|
||||
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
|
||||
|
||||
@@ -62,6 +62,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
|
||||
const prevSearchTextRef = useRef('')
|
||||
const prevSymbolRef = useRef('')
|
||||
|
||||
// 处理搜索,过滤列表
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
@@ -104,7 +108,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}
|
||||
})
|
||||
|
||||
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
|
||||
// 只有在搜索文本变化或面板符号变化时才重置index
|
||||
const isSearchChanged = prevSearchTextRef.current !== searchText
|
||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||
|
||||
if (isSearchChanged || isSymbolChanged) {
|
||||
setIndex(-1) // 不默认高亮任何项,让用户主动选择
|
||||
} else {
|
||||
// 如果当前index超出范围,调整到有效范围内
|
||||
setIndex((prevIndex) => {
|
||||
if (prevIndex >= newList.length) {
|
||||
return newList.length > 0 ? newList.length - 1 : -1
|
||||
}
|
||||
return prevIndex
|
||||
})
|
||||
}
|
||||
|
||||
prevSearchTextRef.current = searchText
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
return newList
|
||||
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
|
||||
@@ -168,12 +189,33 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
|
||||
if (item.disabled) return
|
||||
|
||||
// 在多选模式下,先更新选中状态
|
||||
if (ctx.multiple && !item.isMenu) {
|
||||
const newSelectedState = !item.isSelected
|
||||
ctx.updateItemSelection(item, newSelectedState)
|
||||
|
||||
// 创建更新后的item对象用于回调
|
||||
const updatedItem = { ...item, isSelected: newSelectedState }
|
||||
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
|
||||
symbol: ctx.symbol,
|
||||
action,
|
||||
item: updatedItem,
|
||||
searchText: searchText,
|
||||
multiple: ctx.multiple
|
||||
}
|
||||
|
||||
ctx.beforeAction?.(quickPanelCallBackOptions)
|
||||
item?.action?.(quickPanelCallBackOptions)
|
||||
ctx.afterAction?.(quickPanelCallBackOptions)
|
||||
return
|
||||
}
|
||||
|
||||
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
|
||||
symbol: ctx.symbol,
|
||||
action,
|
||||
item,
|
||||
searchText: searchText,
|
||||
multiple: isAssistiveKeyPressed
|
||||
multiple: ctx.multiple
|
||||
}
|
||||
|
||||
ctx.beforeAction?.(quickPanelCallBackOptions)
|
||||
@@ -200,11 +242,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.multiple && isAssistiveKeyPressed) return
|
||||
// 多选模式下不关闭面板
|
||||
if (ctx.multiple) return
|
||||
|
||||
handleClose(action)
|
||||
},
|
||||
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index]
|
||||
[ctx, searchText, handleClose, clearSearchText, index]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -294,12 +337,16 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
if (isAssistiveKeyPressed) {
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
|
||||
const newIndex = prev - ctx.pageSize
|
||||
if (prev === 0) return list.length - 1
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
} else {
|
||||
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
|
||||
return prev > 0 ? prev - 1 : list.length - 1
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
@@ -307,18 +354,23 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
if (isAssistiveKeyPressed) {
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? 0 : -1
|
||||
const newIndex = prev + ctx.pageSize
|
||||
if (prev + 1 === list.length) return 0
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
} else {
|
||||
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? 0 : -1
|
||||
return prev < list.length - 1 ? prev + 1 : 0
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'PageUp':
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? Math.max(0, list.length - ctx.pageSize) : -1
|
||||
const newIndex = prev - ctx.pageSize
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
@@ -327,6 +379,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
case 'PageDown':
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? Math.min(ctx.pageSize - 1, list.length - 1) : -1
|
||||
const newIndex = prev + ctx.pageSize
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
@@ -421,10 +474,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
(): VirtualizedRowData => ({
|
||||
list,
|
||||
focusedIndex: index,
|
||||
handleItemAction,
|
||||
setIndex
|
||||
handleItemAction
|
||||
}),
|
||||
[list, index, handleItemAction, setIndex]
|
||||
[list, index, handleItemAction]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -487,15 +539,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
<Flex align="center" gap={4}>
|
||||
↩︎ {t('settings.quickPanel.confirm')}
|
||||
</Flex>
|
||||
|
||||
{ctx.multiple && (
|
||||
<Flex align="center" gap={4}>
|
||||
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
|
||||
{ASSISTIVE_KEY}
|
||||
</span>
|
||||
+ ↩︎ {t('settings.quickPanel.multiple')}
|
||||
</Flex>
|
||||
)}
|
||||
</QuickPanelFooterTips>
|
||||
</QuickPanelFooter>
|
||||
</QuickPanelBody>
|
||||
@@ -507,7 +550,6 @@ interface VirtualizedRowData {
|
||||
list: QuickPanelListItem[]
|
||||
focusedIndex: number
|
||||
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
|
||||
setIndex: (index: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,7 +557,7 @@ interface VirtualizedRowData {
|
||||
*/
|
||||
const VirtualizedRow = React.memo(
|
||||
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
|
||||
const { list, focusedIndex, handleItemAction, setIndex } = data
|
||||
const { list, focusedIndex, handleItemAction } = data
|
||||
const item = list[index]
|
||||
if (!item) return null
|
||||
|
||||
@@ -531,8 +573,7 @@ const VirtualizedRow = React.memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleItemAction(item, 'click')
|
||||
}}
|
||||
onMouseEnter={() => setIndex(index)}>
|
||||
}}>
|
||||
<QuickPanelItemLeft>
|
||||
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
|
||||
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
|
||||
@@ -651,11 +692,19 @@ const QuickPanelItem = styled.div`
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background-color: var(--focused-color);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
&.focused {
|
||||
background-color: var(--selected-color-dark);
|
||||
}
|
||||
&:hover:not(.disabled) {
|
||||
background-color: var(--selected-color-dark);
|
||||
}
|
||||
}
|
||||
&.focused {
|
||||
background-color: var(--focused-color);
|
||||
|
||||
@@ -77,6 +77,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={18} />}
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('QuickPanelView', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('should focus on the first item after panel open', () => {
|
||||
it('should not focus on any item after panel open by default', () => {
|
||||
const list = createList(100)
|
||||
|
||||
render(
|
||||
@@ -134,11 +134,16 @@ describe('QuickPanelView', () => {
|
||||
)
|
||||
)
|
||||
|
||||
// 检查第一个 item 是否有 focused
|
||||
// 检查是否没有任何 focused item
|
||||
const panel = screen.getByTestId('quick-panel')
|
||||
const focused = panel.querySelectorAll('.focused')
|
||||
expect(focused.length).toBe(0)
|
||||
|
||||
// 检查第一个 item 存在但没有 focused 类
|
||||
const item1 = screen.getByText('Item 1')
|
||||
const focused = item1.closest('.focused')
|
||||
expect(focused).not.toBeNull()
|
||||
expect(item1).toBeInTheDocument()
|
||||
const focusedItem1 = item1.closest('.focused')
|
||||
expect(focusedItem1).toBeNull()
|
||||
})
|
||||
|
||||
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
|
||||
@@ -154,10 +159,11 @@ describe('QuickPanelView', () => {
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'ArrowUp', expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', expected: 'Item 1' }, // 从未选中状态按 ArrowDown 会选中第一个
|
||||
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会循环到最后一个
|
||||
{ key: 'ArrowUp', expected: 'Item 99' },
|
||||
{ key: 'ArrowDown', expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', expected: 'Item 1' }
|
||||
{ key: 'ArrowDown', expected: 'Item 1' } // 从最后一个按 ArrowDown 会循环到第一个
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
@@ -176,11 +182,11 @@ describe('QuickPanelView', () => {
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
|
||||
{ key: 'ArrowUp', expected: 'Item 100' },
|
||||
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
|
||||
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
|
||||
{ key: 'PageDown', expected: 'Item 100' }
|
||||
{ key: 'PageDown', expected: `Item ${PAGE_SIZE}` }, // 从未选中状态按 PageDown 会选中第 pageSize 个项目
|
||||
{ key: 'PageUp', expected: 'Item 1' }, // PageUp 会选中第一个
|
||||
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会到最后一个
|
||||
{ key: 'PageDown', expected: 'Item 100' }, // 从最后一个按 PageDown 仍然是最后一个
|
||||
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` } // PageUp 会向上翻页,从索引99到92,对应Item 93
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
@@ -199,10 +205,11 @@ describe('QuickPanelView', () => {
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }, // 从未选中状态按 Ctrl+ArrowDown 会选中第一个
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, // Ctrl+ArrowDown 会跳转 pageSize 个位置
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, // Ctrl+ArrowUp 会跳转回去
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, // 从第一个位置再按 Ctrl+ArrowUp 会循环到最后
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } // 从最后位置按 Ctrl+ArrowDown 会循环到第一个
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
|
||||
@@ -33,3 +33,6 @@ export const THEME_COLOR_PRESETS = [
|
||||
'#0EA5E9', // Sky Blue
|
||||
'#0284C7' // Light Blue
|
||||
]
|
||||
|
||||
export const MAX_CONTEXT_COUNT = 100
|
||||
export const UNLIMITED_CONTEXT_COUNT = 100000
|
||||
|
||||
@@ -2487,7 +2487,7 @@ export function isGrokModel(model?: Model): boolean {
|
||||
return model.id.includes('grok')
|
||||
}
|
||||
|
||||
export function isGrokReasoningModel(model?: Model): boolean {
|
||||
export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
@@ -2499,7 +2499,16 @@ export function isGrokReasoningModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export const isSupportedReasoningEffortGrokModel = isGrokReasoningModel
|
||||
export function isGrokReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
if (isSupportedReasoningEffortGrokModel(model) || model.id.includes('grok-4')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isGeminiReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
|
||||
@@ -99,7 +99,8 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
bash: 'shell',
|
||||
'objective-c++': 'objective-cpp',
|
||||
svg: 'xml',
|
||||
vab: 'vb'
|
||||
vab: 'vb',
|
||||
graphviz: 'dot'
|
||||
} as Record<string, string>
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -145,7 +145,8 @@ export const useKnowledge = (baseId: string) => {
|
||||
}
|
||||
}
|
||||
if (item.type === 'file' && typeof item.content === 'object') {
|
||||
await window.api.file.deleteDir(item.content.id)
|
||||
// name: eg. text.pdf
|
||||
await window.api.file.delete(item.content.name)
|
||||
}
|
||||
}
|
||||
// 刷新项目
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
@@ -8,8 +9,11 @@ import { IpcChannel } from '@shared/IpcChannel'
|
||||
window.electron.ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
|
||||
store.dispatch(setMCPServers(servers))
|
||||
})
|
||||
|
||||
window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
|
||||
store.dispatch(addMCPServer(server))
|
||||
NavigationService.navigate?.('/settings/mcp')
|
||||
NavigationService.navigate?.('/settings/mcp/settings', { state: { server } })
|
||||
})
|
||||
|
||||
const selectMcpServers = (state) => state.mcp.servers
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -266,7 +266,7 @@ const AgentsGroupList = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
padding: 12px 0;
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: inherit;
|
||||
border-bottom-left-radius: inherit;
|
||||
|
||||
@@ -41,6 +41,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [showUndoButton, setShowUndoButton] = useState(false)
|
||||
const [originalPrompt, setOriginalPrompt] = useState('')
|
||||
const [tokenCount, setTokenCount] = useState(0)
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
const knowledgeState = useAppSelector((state) => state.knowledge)
|
||||
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
||||
const knowledgeOptions: SelectProps['options'] = []
|
||||
@@ -92,8 +93,21 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
const handleCancel = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
window.modal.confirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('agents.add.unsaved_changes_warning'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
setOpen(false)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
@@ -124,6 +138,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
form.setFieldsValue({ prompt: generatedText })
|
||||
setShowUndoButton(true)
|
||||
setOriginalPrompt(content)
|
||||
setHasUnsavedChanges(true)
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error)
|
||||
}
|
||||
@@ -146,7 +161,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
title={t('agents.add.title')}
|
||||
open={open}
|
||||
onOk={() => formRef.current?.submit()}
|
||||
onCancel={onCancel}
|
||||
onCancel={handleCancel}
|
||||
maskClosable={false}
|
||||
afterClose={onClose}
|
||||
okText={t('agents.add.title')}
|
||||
@@ -167,9 +182,21 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
setTokenCount(count)
|
||||
setShowUndoButton(false)
|
||||
}
|
||||
|
||||
const currentValues = form.getFieldsValue()
|
||||
setHasUnsavedChanges(currentValues.name?.trim() || currentValues.prompt?.trim() || emoji)
|
||||
}}>
|
||||
<Form.Item name="name" label="Emoji">
|
||||
<Popover content={<EmojiPicker onEmojiClick={setEmoji} />} arrow>
|
||||
<Popover
|
||||
content={
|
||||
<EmojiPicker
|
||||
onEmojiClick={(selectedEmoji) => {
|
||||
setEmoji(selectedEmoji)
|
||||
setHasUnsavedChanges(true)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
arrow>
|
||||
<Button icon={emoji && <span style={{ fontSize: 20 }}>{emoji}</span>}>{t('common.select')}</Button>
|
||||
</Popover>
|
||||
</Form.Item>
|
||||
|
||||
@@ -54,7 +54,11 @@ const AttachmentButton: FC<Props> = ({
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document')} arrow>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
|
||||
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -21,6 +21,7 @@ const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEna
|
||||
title={
|
||||
isGenerateImageModel(model) ? t('chat.input.generate_image') : t('chat.input.generate_image_not_supported')
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" disabled={!isGenerateImageModel(model)} onClick={onEnableGenerateImage}>
|
||||
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-link)' : 'var(--color-icon)'} />
|
||||
|
||||
@@ -240,7 +240,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setText('')
|
||||
setFiles([])
|
||||
setTimeout(() => setText(''), 500)
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
setTimeout(() => resizeTextArea(true), 0)
|
||||
setExpend(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
@@ -864,7 +864,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
onInput={onInput}
|
||||
disabled={searching}
|
||||
onPaste={(e) => onPaste(e.nativeEvent)}
|
||||
onClick={() => searching && dispatch(setSearching(false))}
|
||||
onClick={() => {
|
||||
searching && dispatch(setSearching(false))
|
||||
quickPanel.close()
|
||||
}}
|
||||
/>
|
||||
<DragHandle onMouseDown={handleDragStart}>
|
||||
<HolderOutlined />
|
||||
@@ -906,7 +909,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
/>
|
||||
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
||||
{loading && (
|
||||
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
|
||||
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
|
||||
<CirclePause style={{ color: 'var(--color-error)', fontSize: 20 }} />
|
||||
</ToolbarButton>
|
||||
@@ -952,14 +955,14 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 0 16px 16px 16px;
|
||||
padding: 0 24px 18px 24px;
|
||||
`
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
border-radius: 15px;
|
||||
border-radius: 20px;
|
||||
padding-top: 8px; // 为拖动手柄留出空间
|
||||
background-color: var(--color-background-opacity);
|
||||
|
||||
|
||||
@@ -290,7 +290,11 @@ const InputbarTools = ({
|
||||
key: 'new_topic',
|
||||
label: t('chat.input.new_topic', { Command: '' }),
|
||||
component: (
|
||||
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.new_topic', { Command: newTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={addNewTopic}>
|
||||
<MessageSquareDiff size={19} />
|
||||
</ToolbarButton>
|
||||
@@ -395,7 +399,11 @@ const InputbarTools = ({
|
||||
key: 'clear_topic',
|
||||
label: t('chat.input.clear', { Command: '' }),
|
||||
component: (
|
||||
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.clear', { Command: cleanTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={clearTopic}>
|
||||
<PaintbrushVertical size={18} />
|
||||
</ToolbarButton>
|
||||
@@ -406,7 +414,11 @@ const InputbarTools = ({
|
||||
key: 'toggle_expand',
|
||||
label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'),
|
||||
component: (
|
||||
<Tooltip placement="top" title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onToggleExpended}>
|
||||
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -65,7 +65,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
title: t('chat.input.knowledge_base'),
|
||||
list: baseItems,
|
||||
symbol: '#',
|
||||
multiple: true,
|
||||
multiple: false,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
}
|
||||
@@ -85,7 +85,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow>
|
||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel} disabled={disabled}>
|
||||
<FileSearch size={18} />
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -183,12 +183,15 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
label: t('common.close'),
|
||||
description: t('settings.mcp.disable.description'),
|
||||
icon: <CircleX />,
|
||||
isSelected: !(assistant.mcpServers && assistant.mcpServers.length > 0),
|
||||
action: () => updateMcpEnabled(false)
|
||||
isSelected: false,
|
||||
action: () => {
|
||||
updateMcpEnabled(false)
|
||||
quickPanel.close()
|
||||
}
|
||||
})
|
||||
|
||||
return newList
|
||||
}, [activedMcpServers, t, assistant.mcpServers, assistantMcpServers, navigate, updateMcpEnabled])
|
||||
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open({
|
||||
@@ -451,7 +454,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<SquareTerminal
|
||||
size={18}
|
||||
|
||||
@@ -162,7 +162,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('agents.edit.model.select.title')} arrow>
|
||||
<Tooltip placement="top" title={t('agents.edit.model.select.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<AtSign size={18} />
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -16,7 +16,11 @@ const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
|
||||
useShortcut('toggle_new_context', onNewContext)
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.new.context', { Command: newContextShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onNewContext}>
|
||||
<Eraser size={18} />
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -148,7 +148,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip placement="top" title={t('settings.quickPhrase.title')} arrow>
|
||||
<Tooltip placement="top" title={t('settings.quickPhrase.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<Zap size={18} />
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -190,7 +190,7 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('assistants.settings.reasoning_effort')} arrow>
|
||||
<Tooltip placement="top" title={t('assistants.settings.reasoning_effort')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
{getThinkingIcon()}
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowUpOutlined, MenuOutlined } from '@ant-design/icons'
|
||||
import { HStack, VStack } from '@renderer/components/Layout'
|
||||
import MaxContextCount from '@renderer/components/MaxContextCount'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { Divider, Popover } from 'antd'
|
||||
import { FC } from 'react'
|
||||
@@ -21,17 +22,17 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
|
||||
return null
|
||||
}
|
||||
|
||||
const formatMaxCount = (max: number) => {
|
||||
return max.toString()
|
||||
}
|
||||
|
||||
const PopoverContent = () => {
|
||||
return (
|
||||
<VStack w="185px" background="100%">
|
||||
<HStack justifyContent="space-between" w="100%">
|
||||
<Text>{t('chat.input.context_count.tip')}</Text>
|
||||
<Text>
|
||||
{contextCount.current} / {contextCount.max}
|
||||
<HStack style={{ alignItems: 'center' }}>
|
||||
{contextCount.current}
|
||||
<SlashSeparatorSpan>/</SlashSeparatorSpan>
|
||||
<MaxContextCount maxContext={contextCount.max} />
|
||||
</HStack>
|
||||
</Text>
|
||||
</HStack>
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
@@ -46,10 +47,20 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
|
||||
return (
|
||||
<Container>
|
||||
<Popover content={PopoverContent} arrow={false}>
|
||||
<MenuOutlined /> {contextCount.current} / {formatMaxCount(contextCount.max)}
|
||||
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
|
||||
<ArrowUpOutlined />
|
||||
{inputTokenCount} / {estimateTokenCount}
|
||||
<HStack>
|
||||
<HStack style={{ alignItems: 'center' }}>
|
||||
<MenuOutlined /> {contextCount.current}
|
||||
<SlashSeparatorSpan>/</SlashSeparatorSpan>
|
||||
<MaxContextCount maxContext={contextCount.max} />
|
||||
</HStack>
|
||||
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
|
||||
<HStack style={{ alignItems: 'center' }}>
|
||||
<ArrowUpOutlined />
|
||||
{inputTokenCount}
|
||||
<SlashSeparatorSpan>/</SlashSeparatorSpan>
|
||||
{estimateTokenCount}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Popover>
|
||||
</Container>
|
||||
)
|
||||
@@ -80,4 +91,9 @@ const Text = styled.div`
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const SlashSeparatorSpan = styled.span`
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
`
|
||||
|
||||
export default TokenCount
|
||||
|
||||
@@ -6,10 +6,9 @@ import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { Assistant, WebSearchProvider } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import { Tooltip } from 'antd'
|
||||
import { CircleX, Globe, Settings } from 'lucide-react'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export interface WebSearchButtonRef {
|
||||
openQuickPanel: () => void
|
||||
@@ -23,11 +22,12 @@ interface Props {
|
||||
|
||||
const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const quickPanel = useQuickPanel()
|
||||
const { providers } = useWebSearchProviders()
|
||||
const { updateAssistant } = useAssistant(assistant.id)
|
||||
|
||||
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
|
||||
|
||||
const updateSelectedWebSearchProvider = useCallback(
|
||||
(providerId?: WebSearchProvider['id']) => {
|
||||
// TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿
|
||||
@@ -78,42 +78,41 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: t('chat.input.web_search.settings'),
|
||||
icon: <Settings />,
|
||||
action: () => navigate('/settings/tool/websearch')
|
||||
})
|
||||
|
||||
items.unshift({
|
||||
label: t('common.close'),
|
||||
description: t('chat.input.web_search.no_web_search.description'),
|
||||
icon: <CircleX />,
|
||||
isSelected: !assistant.enableWebSearch && !assistant.webSearchProviderId,
|
||||
action: () => {
|
||||
updateSelectedWebSearchProvider(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}, [
|
||||
assistant.model,
|
||||
assistant.enableWebSearch,
|
||||
assistant.webSearchProviderId,
|
||||
assistant.model,
|
||||
assistant?.webSearchProviderId,
|
||||
providers,
|
||||
t,
|
||||
updateSelectedWebSearchProvider,
|
||||
updateSelectedWebSearchBuiltin,
|
||||
navigate
|
||||
updateSelectedWebSearchProvider
|
||||
])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
if (assistant.webSearchProviderId) {
|
||||
return updateSelectedWebSearchProvider(undefined)
|
||||
}
|
||||
|
||||
if (assistant.enableWebSearch) {
|
||||
return updateSelectedWebSearchBuiltin()
|
||||
}
|
||||
|
||||
quickPanel.open({
|
||||
title: t('chat.input.web_search'),
|
||||
list: providerItems,
|
||||
symbol: '?',
|
||||
pageSize: 9
|
||||
})
|
||||
}, [quickPanel, providerItems, t])
|
||||
}, [
|
||||
assistant.webSearchProviderId,
|
||||
assistant.enableWebSearch,
|
||||
quickPanel,
|
||||
t,
|
||||
providerItems,
|
||||
updateSelectedWebSearchProvider,
|
||||
updateSelectedWebSearchBuiltin
|
||||
])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '?') {
|
||||
@@ -128,13 +127,16 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.web_search')} arrow>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={enableWebSearch ? t('common.close') : t('chat.input.web_search')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<Globe
|
||||
size={18}
|
||||
style={{
|
||||
color:
|
||||
assistant?.webSearchProviderId || assistant.enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
|
||||
color: enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
|
||||
}}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CodeBlockView from '@renderer/components/CodeBlockView'
|
||||
import { CodeBlockView } from '@renderer/components/CodeBlockView'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -17,7 +17,11 @@ vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation()
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock services
|
||||
|
||||
@@ -9,9 +9,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const ImageBlock: React.FC<Props> = ({ block }) => {
|
||||
if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.PROCESSING)
|
||||
return <Skeleton.Image active style={{ width: 200, height: 200 }} />
|
||||
if (block.status === MessageBlockStatus.SUCCESS) {
|
||||
if (block.status === MessageBlockStatus.PENDING) return <Skeleton.Image active style={{ width: 200, height: 200 }} />
|
||||
if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.SUCCESS) {
|
||||
const images = block.metadata?.generateImageResponse?.images?.length
|
||||
? block.metadata?.generateImageResponse?.images
|
||||
: block?.file?.path
|
||||
|
||||
@@ -19,8 +19,6 @@ interface Props {
|
||||
role: Message['role']
|
||||
}
|
||||
|
||||
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>/g
|
||||
|
||||
const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions = [] }) => {
|
||||
// Use the passed citationBlockId directly in the selector
|
||||
const { renderInputMessageAsMarkdown } = useSettings()
|
||||
@@ -38,10 +36,6 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
|
||||
return withCitationTags(block.content, rawCitations, sourceType)
|
||||
}, [block.content, block.citationReferences, citationBlockId, rawCitations])
|
||||
|
||||
const ignoreToolUse = useMemo(() => {
|
||||
return processedContent.replace(toolUseRegex, '')
|
||||
}, [processedContent])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Render mentions associated with the message */}
|
||||
@@ -57,7 +51,7 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
|
||||
{block.content}
|
||||
</p>
|
||||
) : (
|
||||
<Markdown block={{ ...block, content: ignoreToolUse }} />
|
||||
<Markdown block={{ ...block, content: processedContent }} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -151,6 +151,7 @@ const ThinkingTimeSeconds = memo(
|
||||
|
||||
const CollapseContainer = styled(Collapse)`
|
||||
margin: 15px 0;
|
||||
margin-top: 5px;
|
||||
`
|
||||
|
||||
const MessageTitleLabel = styled.div`
|
||||
|
||||
@@ -261,51 +261,6 @@ describe('MainTextBlock', () => {
|
||||
})
|
||||
|
||||
describe('content processing', () => {
|
||||
it('should filter tool_use tags from content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'single tool_use tag',
|
||||
content: 'Before <tool_use>tool content</tool_use> after',
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'multiple tool_use tags',
|
||||
content: 'Start <tool_use>tool1</tool_use> middle <tool_use>tool2</tool_use> end',
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'multiline tool_use',
|
||||
content: `Text before
|
||||
<tool_use>
|
||||
multiline
|
||||
tool content
|
||||
</tool_use>
|
||||
text after`,
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'malformed tool_use',
|
||||
content: 'Before <tool_use>unclosed tag',
|
||||
expectsFiltering: false // Should preserve malformed tags
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ content, expectsFiltering }) => {
|
||||
const block = createMainTextBlock({ content })
|
||||
const { unmount } = renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
const renderedContent = getRenderedMarkdown()
|
||||
expect(renderedContent).toBeInTheDocument()
|
||||
|
||||
if (expectsFiltering) {
|
||||
// Check that tool_use content is not visible to user
|
||||
expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should process content through format utilities', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Content to process',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
margin: 15px 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
|
||||
@@ -188,7 +188,7 @@ const OpenButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
margin: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
align-self: flex-start;
|
||||
font-size: 12px;
|
||||
background-color: var(--color-background-soft);
|
||||
|
||||
@@ -47,7 +47,7 @@ const MessageItem: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const { assistant, setModel } = useAssistant(message.assistantId)
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
|
||||
const { messageFont, fontSize } = useSettings()
|
||||
const { messageFont, fontSize, messageStyle } = useSettings()
|
||||
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { editingMessageId, stopEditing } = useMessageEditing()
|
||||
@@ -95,7 +95,7 @@ const MessageItem: FC<Props> = ({
|
||||
stopEditing()
|
||||
}, [stopEditing])
|
||||
|
||||
const isLastMessage = index === 0
|
||||
const isLastMessage = index === 0 || !!isGrouped
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing
|
||||
|
||||
@@ -136,14 +136,7 @@ const MessageItem: FC<Props> = ({
|
||||
'message-user': !isAssistantMessage
|
||||
})}
|
||||
ref={messageContainerRef}>
|
||||
<MessageHeader
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
model={model}
|
||||
key={getModelUniqId(model)}
|
||||
index={index}
|
||||
topic={topic}
|
||||
/>
|
||||
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} topic={topic} />
|
||||
{isEditing && (
|
||||
<MessageEditor
|
||||
message={message}
|
||||
@@ -167,7 +160,7 @@ const MessageItem: FC<Props> = ({
|
||||
</MessageErrorBoundary>
|
||||
</MessageContentContainer>
|
||||
{showMenubar && (
|
||||
<MessageFooter className="MessageFooter">
|
||||
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
|
||||
<MessageMenubar
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
@@ -196,7 +189,8 @@ const MessageContainer = styled.div`
|
||||
transition: background-color 0.3s ease;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
padding: 10px 10px 0 10px;
|
||||
padding: 10px;
|
||||
padding-bottom: 0;
|
||||
border-radius: 10px;
|
||||
&.message-highlight {
|
||||
background-color: var(--color-primary-mute);
|
||||
@@ -224,14 +218,15 @@ const MessageContentContainer = styled(Scrollbar)`
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const MessageFooter = styled.div`
|
||||
const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plain' | 'bubble' }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
flex-direction: ${({ $isLastMessage, $messageStyle }) =>
|
||||
$isLastMessage && $messageStyle === 'plain' ? 'row-reverse' : 'row'};
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-left: 46px;
|
||||
margin-top: 2px;
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
const NewContextMessage = styled.div`
|
||||
|
||||
@@ -14,7 +14,7 @@ const MessageContent: React.FC<Props> = ({ message }) => {
|
||||
return (
|
||||
<>
|
||||
{!isEmpty(message.mentions) && (
|
||||
<Flex gap="8px" wrap>
|
||||
<Flex gap="8px" wrap style={{ marginBottom: '10px' }}>
|
||||
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
@@ -43,7 +43,7 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
|
||||
const model = assistant.model || assistant.defaultModel
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
|
||||
const { pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
|
||||
@@ -75,14 +75,14 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
|
||||
supportExts,
|
||||
setFiles,
|
||||
undefined, // 不需要setText
|
||||
pasteLongTextAsFile,
|
||||
false, // 不需要 pasteLongTextAsFile
|
||||
pasteLongTextThreshold,
|
||||
undefined, // 不需要text
|
||||
resizeTextArea,
|
||||
t
|
||||
)
|
||||
},
|
||||
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t]
|
||||
[model, pasteLongTextThreshold, resizeTextArea, supportExts, t]
|
||||
)
|
||||
|
||||
// 添加全局粘贴事件处理
|
||||
@@ -256,71 +256,72 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
|
||||
}, [couldAddImageFile, couldAddTextFile])
|
||||
|
||||
return (
|
||||
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
|
||||
{editedBlocks
|
||||
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
.map((block) => (
|
||||
<Textarea
|
||||
className={classNames('editing-message', isFileDragging && 'file-dragging')}
|
||||
key={block.id}
|
||||
ref={textareaRef}
|
||||
variant="borderless"
|
||||
value={block.content}
|
||||
onChange={(e) => {
|
||||
handleTextChange(block.id, e.target.value)
|
||||
resizeTextArea()
|
||||
}}
|
||||
onKeyDown={(e) => handleKeyDown(e, block.id)}
|
||||
autoFocus
|
||||
spellCheck={enableSpellCheck}
|
||||
onPaste={(e) => onPaste(e.nativeEvent)}
|
||||
onFocus={() => {
|
||||
// 记录当前聚焦的组件
|
||||
PasteService.setLastFocusedComponent('messageEditor')
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
// 阻止事件冒泡,避免触发全局的 Electron contextMenu
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
fontSize,
|
||||
padding: '0px 15px 8px 15px'
|
||||
}}>
|
||||
<TranslateButton onTranslated={onTranslated} />
|
||||
</Textarea>
|
||||
))}
|
||||
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
|
||||
files.length > 0) && (
|
||||
<FileBlocksContainer>
|
||||
{editedBlocks
|
||||
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
|
||||
.map(
|
||||
(block) =>
|
||||
block.file && (
|
||||
<CustomTag
|
||||
key={block.id}
|
||||
icon={getFileIcon(block.file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => handleFileRemove(block.id)}>
|
||||
<FileNameRender file={block.file} />
|
||||
</CustomTag>
|
||||
)
|
||||
)}
|
||||
|
||||
{files.map((file) => (
|
||||
<CustomTag
|
||||
key={file.id}
|
||||
icon={getFileIcon(file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
|
||||
<FileNameRender file={file} />
|
||||
</CustomTag>
|
||||
<>
|
||||
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
|
||||
{editedBlocks
|
||||
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
.map((block) => (
|
||||
<Textarea
|
||||
className={classNames('editing-message', isFileDragging && 'file-dragging')}
|
||||
key={block.id}
|
||||
ref={textareaRef}
|
||||
variant="borderless"
|
||||
value={block.content}
|
||||
onChange={(e) => {
|
||||
handleTextChange(block.id, e.target.value)
|
||||
resizeTextArea()
|
||||
}}
|
||||
onKeyDown={(e) => handleKeyDown(e, block.id)}
|
||||
autoFocus
|
||||
spellCheck={enableSpellCheck}
|
||||
onPaste={(e) => onPaste(e.nativeEvent)}
|
||||
onFocus={() => {
|
||||
// 记录当前聚焦的组件
|
||||
PasteService.setLastFocusedComponent('messageEditor')
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
// 阻止事件冒泡,避免触发全局的 Electron contextMenu
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
fontSize,
|
||||
padding: '0px 15px 8px 15px'
|
||||
}}>
|
||||
<TranslateButton onTranslated={onTranslated} />
|
||||
</Textarea>
|
||||
))}
|
||||
</FileBlocksContainer>
|
||||
)}
|
||||
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
|
||||
files.length > 0) && (
|
||||
<FileBlocksContainer>
|
||||
{editedBlocks
|
||||
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
|
||||
.map(
|
||||
(block) =>
|
||||
block.file && (
|
||||
<CustomTag
|
||||
key={block.id}
|
||||
icon={getFileIcon(block.file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => handleFileRemove(block.id)}>
|
||||
<FileNameRender file={block.file} />
|
||||
</CustomTag>
|
||||
)
|
||||
)}
|
||||
|
||||
{files.map((file) => (
|
||||
<CustomTag
|
||||
key={file.id}
|
||||
icon={getFileIcon(file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
|
||||
<FileNameRender file={file} />
|
||||
</CustomTag>
|
||||
))}
|
||||
</FileBlocksContainer>
|
||||
)}
|
||||
</EditorContainer>
|
||||
<ActionBar>
|
||||
<ActionBarLeft>
|
||||
{isUserMessage && (
|
||||
@@ -355,17 +356,17 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
|
||||
)}
|
||||
</ActionBarRight>
|
||||
</ActionBar>
|
||||
</EditorContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
padding: 8px 0;
|
||||
padding: 18px 0;
|
||||
padding-bottom: 5px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 15px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 18px;
|
||||
background-color: var(--color-background-opacity);
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
const { isMultiSelectMode } = useChatContext(topic)
|
||||
|
||||
const [multiModelMessageStyle, setMultiModelMessageStyle] = useState<MultiModelMessageStyle>(
|
||||
messages[0].multiModelMessageStyle || multiModelMessageStyleSetting
|
||||
// 对于单模型消息,采用简单的样式,避免 overflow 影响内部的 sticky 效果
|
||||
messages.length < 2 ? 'fold' : messages[0].multiModelMessageStyle || multiModelMessageStyleSetting
|
||||
)
|
||||
|
||||
const messageLength = messages.length
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user