Compare commits

..

29 Commits

Author SHA1 Message Date
suyao
c97f7b843d chore: version 2025-11-16 22:39:56 +08:00
suyao
438b1673d5 Merge remote-tracking branch 'origin/v2' into copilot/fix-notes-bug 2025-11-16 22:32:48 +08:00
suyao
3bd71e4618 fix(yaml-front-matter): update start method regex to match exact YAML front matter format 2025-10-29 13:53:17 +08:00
suyao
9b8e4d1d70 Add enhanced horizontal rule extension with markdown indentation support
- Create EnhancedHorizontalRule extension that preserves leading spaces (0-3) in markdown serialization
- Store indentation as data attribute and restore it during markdown rendering
- Fix YAML front matter parsing to not match indented horizontal rules as front matter
2025-10-29 08:04:21 +08:00
suyao
cfb9ee7df3 Remove verbose logging and simplify YAML front matter handling
- Eliminate logger imports and debug statements throughout YAML front matter extension
- Streamline markdown tokenizer to match YAML front matter with optional leading whitespace
- Simplify parseMarkdown and renderMarkdown logic using helper methods
- Remove custom YAML front matter plugin from markdown converter as it's now handled by TipTap extension
2025-10-29 07:54:56 +08:00
suyao
ef7e8a7201 Add YAML front matter as atom node without child content
- Use explicit 'yamlFrontMatter' node name instead of dynamic this.name reference
- Remove child content parsing since atom nodes don't require children
- Simplify logging by removing redundant hasContent check
2025-10-29 06:35:41 +08:00
suyao
54503c0e62 Add custom markdown tokenizer with logging for YAML front matter
- Implement markdown tokenizer with start() and tokenize() methods for parsing YAML front matter
- Add comprehensive logging throughout tokenization, parsing, and serialization processes
- Improve markdown serialization to ensure proper --- delimiter handling
2025-10-29 06:32:22 +08:00
suyao
a66c0860b2 Add markdown parsing and serialization for YAML front matter
- Add `markdownTokenName` property for custom parsing
- Implement `parseMarkdown` to convert markdown tokens to Tiptap JSON
- Implement `renderMarkdown` to serialize Tiptap nodes back to markdown format
2025-10-29 03:48:12 +08:00
suyao
a418b61230 fix(richtext): update regex patterns for code formatting and add disable marks on split extension 2025-10-29 03:35:23 +08:00
suyao
cd188e128e Add patched TipTap extensions and clean up dependency overrides
- Add @tiptap/extension-code as patched dependency in main dependencies
- Replace three TipTap extensions with patched versions in resolutions
- Update yarn.lock to reflect patched extension resolutions
2025-10-29 02:49:36 +08:00
suyao
252e30a66e fix(richtext): adjust task list item margin for better spacing 2025-10-29 02:45:10 +08:00
suyao
1067e6fd85 Improve task list alignment and nesting behavior
- Change task list items from `align-items: flex-start` to `align-items: center` for better vertical alignment
- Add `margin-top: 0.35rem` to task list labels for consistent spacing
- Add special handling for nested task lists to maintain proper alignment with first line of text
2025-10-29 02:42:01 +08:00
suyao
24563b524c Upgrade TipTap dependencies to v3.9.0 and fix task list styling
- Update all TipTap packages from v3.7.2 to v3.9.0 including extensions and patch files
- Fix task list alignment and nested checked item styling to prevent inheritance issues
- Remove Tab key handler from code block extension and add patch-package dependency
2025-10-29 02:38:51 +08:00
suyao
c9b1e61b8f Upgrade TipTap dependencies and patch table of contents extension
- Update TipTap packages from v3.7.2 to v3.9.0 including core extensions and React bindings
- Apply patch to table of contents extension preventing updates during composition events
- Add tab indentation support to code block extension with 4-space tab size
2025-10-29 00:38:55 +08:00
suyao
27ccc25e20 fix(package): add patch for @tiptap/extension-code to improve regex handling 2025-10-28 22:52:52 +08:00
suyao
ed6bfeca77 feat(RichEditor): add underline extension to enhance text formatting options 2025-10-28 20:19:29 +08:00
suyao
c2fe2160b5 fix(RichEditor): remove comment about underline default in useRichEditor 2025-10-28 20:12:09 +08:00
GitHub Action
b7d8dff0d3 fix(i18n): Auto update translations for PR #10929 2025-10-28 05:26:22 +00:00
suyao
ecc7f635b8 Merge remote-tracking branch 'origin/main' into copilot/fix-notes-bug 2025-10-28 13:25:00 +08:00
suyao
71f0059960 优化 Markdown 配置以支持 HTML,确保保留 <u> 标签用于下划线 2025-10-25 14:44:17 +08:00
suyao
ec16657cbb Upgrade TipTap to v3.7.2 and add text highlight feature
- Update all TipTap packages from v3.2.0 to v3.7.2 including core extensions and dependencies
- Add new highlight extension with Markdown support using ==text== syntax
- Replace custom markdown converter with TipTap's built-in Markdown extension
- Simplify link handling by using standard TipTap link extension instead of enhancedLink
- Add view menu to app menu service for better Electron app navigation
2025-10-24 14:15:48 +08:00
suyao
d5dd8bc123 Improve paste behavior in styled blocks and multi-line content
- Preserve block styles (headings, blockquotes, list items) when pasting single-line text
- Handle multi-line paste content by converting markdown to HTML
- Maintain existing plain text insertion for mid-line pastes in content blocks
2025-10-24 04:48:50 +08:00
suyao
99de3eeff7 Improve paste behavior to preserve inline text formatting
- Insert plain text when pasting mid-line to avoid unwanted line breaks
- Only convert markdown to HTML when pasting at line start or in empty paragraphs
- Disable automatic line break conversion in markdown parser to prevent extra paragraphs
2025-10-24 04:43:56 +08:00
suyao
795fb715e3 Simplify file operations and improve note navigation
- Remove fallback copy+delete logic from file/directory move operations, relying solely on `fs.rename` for better performance
- Implement note history tracking with smart navigation when deleting files, automatically switching to previously opened notes
- Cancel pending saves before delete/move operations to prevent file recreation and update path references
2025-10-24 04:40:10 +08:00
copilot-swe-agent[bot]
d8bbd3fdb9 Fix remaining notes bugs: breadcrumb root navigation and line break preservation
- Add root breadcrumb item for easy navigation back to notes home
- Add onClearActiveFile callback to deselect active file when clicking root
- Enable line breaks preservation in markdown (breaks: true) to fix forced line breaks when pasting text
- Root breadcrumb now allows users to return to notes tree view

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-10-23 18:05:22 +00:00
copilot-swe-agent[bot]
6d259bb5bd Address code review feedback - improve comment clarity
Updated comments to be more precise about the copy+delete approach

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-10-23 17:48:18 +00:00
copilot-swe-agent[bot]
4be84b59bc Improve code comments and documentation for file move operations
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-10-23 17:44:15 +00:00
copilot-swe-agent[bot]
ac9c6c204c Fix notes move operation to handle cross-filesystem moves properly
- Changed moveFile and moveDir to use copy+delete fallback when rename fails
- Added private copyDirectory helper method for recursive directory copying
- This ensures files are actually moved, not just copied, across different filesystems

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-10-23 17:43:19 +00:00
copilot-swe-agent[bot]
f1bad06ae5 Initial plan 2025-10-23 17:34:26 +00:00
79 changed files with 3847 additions and 6658 deletions

View File

@@ -0,0 +1,29 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 650402009637c04dce23b2de9baa48b69601f6e7..e4106894f67ff68b78e4e7485b7beb24570f91c0 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -29,8 +29,8 @@ module.exports = __toCommonJS(index_exports);
// src/code.ts
var import_core = require("@tiptap/core");
-var inputRegex = /(^|[^`])`([^`]+)`(?!`)$/;
-var pasteRegex = /(^|[^`])`([^`]+)`(?!`)/g;
+var inputRegex = /(?:^|\s)(`(?!\s+`)((?:[^`]+))`(?!\s+`))$/;
+var pasteRegex = /(?:^|\s)(`(?!\s+`)((?:[^`]+))`(?!\s+`))/g;
var Code = import_core.Mark.create({
name: "code",
addOptions() {
diff --git a/dist/index.js b/dist/index.js
index 7f9e650a5713377d8d6a824f884bbfe6d27fe519..3736cac514b979438a808705931636ae04b06d16 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,7 +1,7 @@
// src/code.ts
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
-var inputRegex = /(^|[^`])`([^`]+)`(?!`)$/;
-var pasteRegex = /(^|[^`])`([^`]+)`(?!`)/g;
+var inputRegex = /(?:^|\s)(`(?!\s+`)((?:[^`]+))`(?!\s+`))$/;
+var pasteRegex = /(?:^|\s)(`(?!\s+`)((?:[^`]+))`(?!\s+`))/g;
var Code = Mark.create({
name: "code",
addOptions() {

View File

@@ -1,8 +1,8 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 8e560a4406c5cc616c11bb9fd5455ac0dcf47fa3..c7cd0d65ddc971bff71e89f610de82cfdaa5a8c7 100644
index 506aa37711fdb8452c68c4e1364b769793e56290..a69f9cc11066f5cf224599cb7b01c7ab6d465bb1 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -413,6 +413,19 @@ var DragHandlePlugin = ({
@@ -454,6 +454,19 @@ var DragHandlePlugin = ({
}
return false;
},
@@ -23,10 +23,10 @@ index 8e560a4406c5cc616c11bb9fd5455ac0dcf47fa3..c7cd0d65ddc971bff71e89f610de82cf
if (locked) {
return false;
diff --git a/dist/index.js b/dist/index.js
index 39e4c3ef9986cd25544d9d3994cf6a9ada74b145..378d9130abbfdd0e1e4f743b5b537743c9ab07d0 100644
index ad58ef1637a6e5544733f4002cd0cfcc8e43022a..ce03e2e2882e8d1828726dcb3de31e9cbeb83374 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -387,6 +387,19 @@ var DragHandlePlugin = ({
@@ -428,6 +428,19 @@ var DragHandlePlugin = ({
}
return false;
},

View File

@@ -0,0 +1,28 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index f27ba0ac6bb377fb0e394e7b656edd60dd20cfd5..6dad2fc41d1df08a608ecc73ad89efabd4ccce31 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -45,6 +45,9 @@ var TableOfContentsPlugin = ({
return new import_state.Plugin({
key: new import_state.PluginKey("tableOfContent"),
appendTransaction(transactions, _oldState, newState) {
+ if (transactions.some(tr => tr.getMeta('composition'))) {
+ return
+ }
const tr = newState.tr;
let modified = false;
if (transactions.some((transaction) => transaction.docChanged)) {
diff --git a/dist/index.js b/dist/index.js
index 83afa3f0b57db38a80194d991dadb4e21a8f83da..bfbc84135845a9789f419c895eb4ea735b573363 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -12,6 +12,9 @@ var TableOfContentsPlugin = ({
return new Plugin({
key: new PluginKey("tableOfContent"),
appendTransaction(transactions, _oldState, newState) {
+ if (transactions.some(tr => tr.getMeta('composition'))) {
+ return
+ }
const tr = newState.tr;
let modified = false;
if (transactions.some((transaction) => transaction.docChanged)) {

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "2.0.0-alpha.sora.1",
"version": "2.0.0-alpha",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -181,22 +181,26 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@tiptap/extension-collaboration": "^3.2.0",
"@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.2.0#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch",
"@tiptap/extension-drag-handle-react": "^3.2.0",
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-list": "^3.2.0",
"@tiptap/extension-mathematics": "^3.2.0",
"@tiptap/extension-mention": "^3.2.0",
"@tiptap/extension-node-range": "^3.2.0",
"@tiptap/extension-table-of-contents": "^3.2.0",
"@tiptap/extension-typography": "^3.2.0",
"@tiptap/extension-underline": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/react": "^3.2.0",
"@tiptap/starter-kit": "^3.2.0",
"@tiptap/suggestion": "^3.2.0",
"@tiptap/y-tiptap": "^3.0.0",
"@tiptap/extension-code": "patch:@tiptap/extension-code@npm%3A3.10.7#~/.yarn/patches/@tiptap-extension-code-npm-3.10.7-6d3deb3e10.patch",
"@tiptap/extension-code-block": "^3.10.7",
"@tiptap/extension-collaboration": "^3.10.7",
"@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.10.7#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.10.7-332b0175fc.patch",
"@tiptap/extension-drag-handle-react": "^3.10.7",
"@tiptap/extension-image": "^3.10.7",
"@tiptap/extension-link": "^3.10.7",
"@tiptap/extension-list": "^3.10.7",
"@tiptap/extension-mathematics": "^3.10.7",
"@tiptap/extension-mention": "^3.10.7",
"@tiptap/extension-node-range": "^3.10.7",
"@tiptap/extension-table-of-contents": "patch:@tiptap/extension-table-of-contents@npm%3A3.10.7#~/.yarn/patches/@tiptap-extension-table-of-contents-npm-3.10.7-4852787461.patch",
"@tiptap/extension-typography": "^3.10.7",
"@tiptap/extension-underline": "^3.10.7",
"@tiptap/markdown": "^3.10.7",
"@tiptap/pm": "^3.10.7",
"@tiptap/react": "^3.10.7",
"@tiptap/starter-kit": "^3.10.7",
"@tiptap/suggestion": "^3.10.7",
"@tiptap/y-tiptap": "^3.0.1",
"@truto/turndown-plugin-gfm": "^1.0.2",
"@tryfabric/martian": "^1.2.4",
"@types/cli-progress": "^3",
@@ -316,6 +320,7 @@
"oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"patch-package": "^8.0.1",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"playwright": "^1.55.1",
@@ -411,7 +416,8 @@
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/google@npm:2.0.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch"
"@ai-sdk/google@npm:2.0.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch",
"@tiptap/extension-code@npm:^3.10.7": "patch:@tiptap/extension-code@npm%3A3.10.7#~/.yarn/patches/@tiptap-extension-code-npm-3.10.7-6d3deb3e10.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -68,8 +68,8 @@
],
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@tiptap/core": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/core": "^3.10.7",
"@tiptap/pm": "^3.10.7",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",

View File

@@ -666,8 +666,7 @@ export const DefaultPreferences: PreferenceSchemas = {
'knowledge',
'files',
'code_tools',
'notes',
'video'
'notes'
],
'ui.theme_mode': PreferenceTypes.ThemeMode.system,
'ui.theme_user.code_font_family': '',

View File

@@ -73,7 +73,6 @@ export type SidebarIcon =
| 'files'
| 'code_tools'
| 'notes'
| 'video'
export type AssistantIconType = 'model' | 'emoji' | 'none'

View File

@@ -0,0 +1,86 @@
// Original path: src/renderer/src/components/Icons/FileIcons.tsx
import type { CSSProperties, SVGProps } from 'react'
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
size?: string | number
text?: string
}
const textStyle: CSSProperties = {
fontStyle: 'italic',
fontSize: '7.70985px',
lineHeight: 0.8,
fontFamily: "'Times New Roman'",
textAlign: 'center',
writingMode: 'horizontal-tb',
direction: 'ltr',
textAnchor: 'middle',
fill: 'none',
stroke: '#000000',
strokeWidth: '0.289119',
strokeLinejoin: 'round',
strokeDasharray: 'none'
}
const tspanStyle: CSSProperties = {
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: 'normal',
fontStretch: 'condensed',
fontSize: '7.70985px',
lineHeight: 0.8,
fontFamily: 'Arial',
fill: '#000000',
fillOpacity: 1,
strokeWidth: '0.289119',
strokeDasharray: 'none'
}
const BaseFileIcon = ({ size = '1.1em', text = 'SVG', ...props }: BaseFileIconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
version="1.1"
id="svg4"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<defs id="defs4" />
<path d="m 14,2 v 4 a 2,2 0 0 0 2,2 h 4" id="path3" />
<path d="M 15,2 H 6 A 2,2 0 0 0 4,4 v 16 a 2,2 0 0 0 2,2 h 12 a 2,2 0 0 0 2,-2 V 7 Z" id="path4" />
<text
xmlSpace="preserve"
style={textStyle}
x="12.478625"
y="17.170216"
id="text4"
transform="scale(0.96196394,1.03954)">
<tspan id="tspan4" x="12.478625" y="17.170216" style={tspanStyle}>
{text}
</tspan>
</text>
</svg>
)
/**
* @deprecated 此图标使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)
* 计划在未来版本中移除。
*
* This icon has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages).
* Planned for removal in future versions.
*/
export const FileSvgIcon = (props: Omit<BaseFileIconProps, 'text'>) => <BaseFileIcon text="SVG" {...props} />
/**
* @deprecated 此图标使用频率仅为 2 次,不符合 UI 库提取标准(需 ≥3 次)
* 计划在未来版本中移除。
*
* This icon has only 2 usages and does not meet the UI library extraction criteria (requires ≥3 usages).
* Planned for removal in future versions.
*/
export const FilePngIcon = (props: Omit<BaseFileIconProps, 'text'>) => <BaseFileIcon text="PNG" {...props} />

View File

@@ -0,0 +1,53 @@
import type { LucideIcon } from 'lucide-react'
import {
AlignLeft,
Copy,
Eye,
Pencil,
RefreshCw,
RotateCcw,
ScanLine,
Search,
Trash,
WrapText,
Wrench
} from 'lucide-react'
import React from 'react'
// 创建一个 Icon 工厂函数
export function createIcon(IconComponent: LucideIcon, defaultSize: string | number = '1rem') {
const Icon = ({
ref,
...props
}: React.ComponentProps<typeof IconComponent> & { ref?: React.RefObject<SVGSVGElement | null> }) => (
<IconComponent ref={ref} size={defaultSize} {...props} />
)
Icon.displayName = `Icon(${IconComponent.displayName || IconComponent.name})`
return Icon
}
// 预定义的常用图标(向后兼容,只导入需要的图标)
export const CopyIcon = createIcon(Copy)
export const DeleteIcon = createIcon(Trash)
export const EditIcon = createIcon(Pencil)
export const RefreshIcon = createIcon(RefreshCw)
export const ResetIcon = createIcon(RotateCcw)
/**
* @deprecated 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次)
* 计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。
*
* This icon has 0 usages and does not meet the UI library extraction criteria (requires ≥3 usages).
* Planned for removal in future versions.
*/
export const ToolIcon = createIcon(Wrench)
export const VisionIcon = createIcon(Eye)
export const WebSearchIcon = createIcon(Search)
export const WrapIcon = createIcon(WrapText)
export const UnWrapIcon = createIcon(AlignLeft)
export const OcrIcon = createIcon(ScanLine)
// 导出 createIcon 以便用户自行创建图标组件
export type { LucideIcon }
export type { LucideProps } from 'lucide-react'

View File

@@ -0,0 +1,37 @@
/**
* @deprecated 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次)
* 计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。
*
* This component has 0 usages and does not meet the UI library extraction criteria (requires ≥3 usages).
* Planned for removal in future versions.
*/
// Original path: src/renderer/src/components/Icons/SvgSpinners180Ring.tsx
import type { SVGProps } from 'react'
import { cn } from '../../../utils'
interface SvgSpinners180RingProps extends SVGProps<SVGSVGElement> {
size?: number | string
}
export function SvgSpinners180Ring(props: SvgSpinners180RingProps) {
const { size = '1em', className, ...svgProps } = props
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
{...svgProps}
className={cn('animate-spin', className)}>
{/* Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE */}
<path
fill="currentColor"
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z"></path>
</svg>
)
}
export default SvgSpinners180Ring

View File

@@ -0,0 +1,32 @@
/**
* @deprecated 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)
* 计划在未来版本中移除。建议直接使用 lucide-react 的 Wrench 图标。
*
* This component has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages).
* Planned for removal in future versions. Consider using Wrench icon from lucide-react directly.
*/
// Original: src/renderer/src/components/Icons/ToolsCallingIcon.tsx
import { Tooltip, type TooltipProps } from '@heroui/react'
import { Wrench } from 'lucide-react'
import React from 'react'
import { cn } from '../../../utils'
interface ToolsCallingIconProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string
iconClassName?: string
TooltipProps?: TooltipProps
}
const ToolsCallingIcon = ({ className, iconClassName, TooltipProps, ...props }: ToolsCallingIconProps) => {
return (
<div className={cn('flex justify-center items-center', className)} {...props}>
<Tooltip {...TooltipProps}>
<Wrench className={cn('w-4 h-4 mr-1.5 text-[#00b96b]', iconClassName)} />
</Tooltip>
</div>
)
}
export default ToolsCallingIcon

View File

@@ -22,11 +22,43 @@ export { default as Scrollbar } from './composites/Scrollbar'
export { default as ThinkingEffect } from './composites/ThinkingEffect'
// Icon Components
export { FilePngIcon, FileSvgIcon } from './icons/FileIcons'
// export type { LucideIcon, LucideProps } from './icons/Icon'
// export {
// CopyIcon,
// createIcon,
// DeleteIcon,
// EditIcon,
// OcrIcon,
// RefreshIcon,
// ResetIcon,
// ToolIcon,
// UnWrapIcon,
// VisionIcon,
// WebSearchIcon,
// WrapIcon
// } from './icons/Icon'
export { default as SvgSpinners180Ring } from './icons/SvgSpinners180Ring'
export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon'
// Brand Logo Icons (Colorful brand logo icons - 81 items)
// Recommended to import using '@cherrystudio/ui/icons' path
// Brand Logo Icons (彩色品牌 Logo 图标 - 84个)
// 推荐使用 '@cherrystudio/ui/icons' 路径导入
export * from './icons'
// /* Selector Components */
// export { default as Selector } from './primitives/select'
// export { default as SearchableSelector } from './primitives/Selector/SearchableSelector'
// export type {
// MultipleSearchableSelectorProps,
// MultipleSelectorProps,
// SearchableSelectorItem,
// SearchableSelectorProps,
// SelectorItem,
// SelectorProps,
// SingleSearchableSelectorProps,
// SingleSelectorProps
// } from './primitives/Selector/types'
/* Additional Composite Components */
// CodeEditor
export {

View File

@@ -0,0 +1,270 @@
import type { Meta, StoryObj } from '@storybook/react'
import { FilePngIcon, FileSvgIcon } from '../../../src/components/icons/FileIcons'
// Create a dummy component for the story
const FileIconsShowcase = () => <div />
const meta: Meta<typeof FileIconsShowcase> = {
title: 'Components/Icons/FileIcons',
component: FileIconsShowcase,
parameters: {
layout: 'centered'
},
tags: ['autodocs'],
argTypes: {
size: {
description: '图标大小',
control: { type: 'text' },
defaultValue: '1.1em'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Basic File Icons
export const BasicFileIcons: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"> (默认尺寸: 1.1em)</h3>
<div className="flex items-center gap-6">
<div className="flex flex-col items-center gap-2">
<FileSvgIcon />
<span className="text-xs text-gray-600">SVG </span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon />
<span className="text-xs text-gray-600">PNG </span>
</div>
</div>
</div>
</div>
)
}
// Different Sizes
export const DifferentSizes: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"> SVG </h3>
<div className="flex items-end gap-4">
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="16" />
<span className="text-xs text-gray-600">16px</span>
</div>
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="24" />
<span className="text-xs text-gray-600">24px</span>
</div>
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="32" />
<span className="text-xs text-gray-600">32px</span>
</div>
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="48" />
<span className="text-xs text-gray-600">48px</span>
</div>
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="64" />
<span className="text-xs text-gray-600">64px</span>
</div>
</div>
</div>
<div>
<h3 className="mb-3 font-semibold"> PNG </h3>
<div className="flex items-end gap-4">
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="16" />
<span className="text-xs text-gray-600">16px</span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="24" />
<span className="text-xs text-gray-600">24px</span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="32" />
<span className="text-xs text-gray-600">32px</span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="48" />
<span className="text-xs text-gray-600">48px</span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="64" />
<span className="text-xs text-gray-600">64px</span>
</div>
</div>
</div>
</div>
)
}
// Custom Colors
export const CustomColors: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"> - SVG </h3>
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="32" color="#3B82F6" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="32" color="#10B981" />
<span className="text-xs text-gray-600">绿</span>
</div>
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="32" color="#F59E0B" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="32" color="#EF4444" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<FileSvgIcon size="32" color="#8B5CF6" />
<span className="text-xs text-gray-600"></span>
</div>
</div>
</div>
<div>
<h3 className="mb-3 font-semibold"> - PNG </h3>
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="32" color="#3B82F6" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="32" color="#10B981" />
<span className="text-xs text-gray-600">绿</span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="32" color="#F59E0B" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="32" color="#EF4444" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<FilePngIcon size="32" color="#8B5CF6" />
<span className="text-xs text-gray-600"></span>
</div>
</div>
</div>
</div>
)
}
// In File List Context
export const InFileListContext: Story = {
render: () => (
<div className="space-y-4">
<h3 className="mb-3 font-semibold">使</h3>
<div className="rounded-lg border border-gray-200 p-4">
<div className="space-y-3">
<div className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
<FileSvgIcon size="20" />
<span className="flex-1">illustration.svg</span>
<span className="text-xs text-gray-500">45 KB</span>
</div>
<div className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
<FilePngIcon size="20" />
<span className="flex-1">screenshot.png</span>
<span className="text-xs text-gray-500">1.2 MB</span>
</div>
<div className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
<FileSvgIcon size="20" />
<span className="flex-1">logo.svg</span>
<span className="text-xs text-gray-500">12 KB</span>
</div>
<div className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
<FilePngIcon size="20" />
<span className="flex-1">background.png</span>
<span className="text-xs text-gray-500">2.8 MB</span>
</div>
</div>
</div>
</div>
)
}
// File Type Grid
export const FileTypeGrid: Story = {
render: () => (
<div className="space-y-4">
<h3 className="mb-3 font-semibold"></h3>
<div className="grid grid-cols-4 gap-4">
<div className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-blue-500">
<FileSvgIcon size="48" />
<span className="text-sm font-medium">SVG</span>
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-blue-500">
<FilePngIcon size="48" />
<span className="text-sm font-medium">PNG</span>
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-blue-500">
<FileSvgIcon size="48" color="#10B981" />
<span className="text-sm font-medium">SVG</span>
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-blue-500">
<FilePngIcon size="48" color="#EF4444" />
<span className="text-sm font-medium">PNG</span>
<span className="text-xs text-gray-600"></span>
</div>
</div>
</div>
)
}
// Interactive Example
export const InteractiveExample: Story = {
render: () => {
const fileTypes = [
{ icon: FileSvgIcon, name: 'Vector Graphics', ext: 'SVG', color: '#3B82F6' },
{ icon: FilePngIcon, name: 'Raster Image', ext: 'PNG', color: '#10B981' }
]
return (
<div className="space-y-4">
<h3 className="mb-3 font-semibold"></h3>
<div className="grid grid-cols-2 gap-4">
{fileTypes.map((fileType, index) => {
const IconComponent = fileType.icon
return (
<button
key={index}
type="button"
className="flex items-center gap-3 rounded-lg border border-gray-200 p-4 text-left transition-all hover:border-blue-500 hover:shadow-md focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20">
<IconComponent size="32" color={fileType.color} />
<div>
<div className="font-medium">{fileType.ext} </div>
<div className="text-sm text-gray-600">{fileType.name}</div>
</div>
</button>
)
})}
</div>
</div>
)
}
}

View File

@@ -0,0 +1,339 @@
import type { Meta, StoryObj } from '@storybook/react'
import SvgSpinners180Ring from '../../../src/components/icons/SvgSpinners180Ring'
const meta: Meta<typeof SvgSpinners180Ring> = {
title: 'Components/Icons/SvgSpinners180Ring',
component: SvgSpinners180Ring,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'⚠️ **已废弃** - 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次)。计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。'
}
}
},
tags: ['autodocs', 'deprecated'],
argTypes: {
size: {
description: '加载图标大小',
control: { type: 'text' },
defaultValue: '1em'
},
className: {
description: '自定义 CSS 类名',
control: { type: 'text' }
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Basic Spinner
export const BasicSpinner: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="flex items-center gap-4">
<SvgSpinners180Ring />
<span className="text-sm text-gray-600"> (1em)</span>
</div>
</div>
</div>
)
}
// Different Sizes
export const DifferentSizes: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="flex items-end gap-6">
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="12" />
<span className="text-xs text-gray-600">12px</span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="16" />
<span className="text-xs text-gray-600">16px</span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="20" />
<span className="text-xs text-gray-600">20px</span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" />
<span className="text-xs text-gray-600">24px</span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="32" />
<span className="text-xs text-gray-600">32px</span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="48" />
<span className="text-xs text-gray-600">48px</span>
</div>
</div>
</div>
</div>
)
}
// Different Colors
export const DifferentColors: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="flex items-center gap-6">
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" className="text-blue-500" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" className="text-green-500" />
<span className="text-xs text-gray-600">绿</span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" className="text-orange-500" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" className="text-red-500" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" className="text-purple-500" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" className="text-gray-500" />
<span className="text-xs text-gray-600"></span>
</div>
</div>
</div>
</div>
)
}
// Loading States in Buttons
export const LoadingStatesInButtons: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="flex flex-wrap items-center gap-4">
<button
type="button"
className="flex items-center gap-2 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
disabled>
<SvgSpinners180Ring size="16" />
<span>...</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600"
disabled>
<SvgSpinners180Ring size="16" />
<span></span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600"
disabled>
<SvgSpinners180Ring size="16" />
<span></span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
disabled>
<SvgSpinners180Ring size="16" className="text-gray-500" />
<span></span>
</button>
</div>
</div>
</div>
)
}
// Loading Cards
export const LoadingCards: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<SvgSpinners180Ring size="20" className="text-blue-500" />
<div>
<h4 className="font-medium">AI </h4>
<p className="text-sm text-gray-600">...</p>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<SvgSpinners180Ring size="20" className="text-green-500" />
<div>
<h4 className="font-medium"></h4>
<p className="text-sm text-gray-600">75% </p>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<SvgSpinners180Ring size="20" className="text-orange-500" />
<div>
<h4 className="font-medium"></h4>
<p className="text-sm text-gray-600">...</p>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<SvgSpinners180Ring size="20" className="text-purple-500" />
<div>
<h4 className="font-medium"></h4>
<p className="text-sm text-gray-600">2</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// Inline Loading States
export const InlineLoadingStates: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="space-y-4">
<div className="flex items-center gap-2">
<SvgSpinners180Ring size="14" className="text-blue-500" />
<span className="text-sm">...</span>
</div>
<div className="flex items-center gap-2">
<SvgSpinners180Ring size="14" className="text-green-500" />
<span className="text-sm">...</span>
</div>
<div className="flex items-center gap-2">
<SvgSpinners180Ring size="14" className="text-orange-500" />
<span className="text-sm">...</span>
</div>
<div className="rounded bg-blue-50 p-3">
<div className="flex items-center gap-2">
<SvgSpinners180Ring size="16" className="text-blue-600" />
<span className="text-sm text-blue-800">...</span>
</div>
</div>
</div>
</div>
</div>
)
}
// Loading States with Different Speeds
export const LoadingStatesWithDifferentSpeeds: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="flex items-center gap-6">
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" className="animate-spin" style={{ animationDuration: '2s' }} />
<span className="text-xs text-gray-600"> (2s)</span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<SvgSpinners180Ring size="24" className="animate-spin" style={{ animationDuration: '0.5s' }} />
<span className="text-xs text-gray-600"> (0.5s)</span>
</div>
</div>
</div>
</div>
)
}
// Full Page Loading
export const FullPageLoading: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="relative h-64 w-full overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white/80">
<SvgSpinners180Ring size="32" className="text-blue-500" />
<p className="mt-4 text-sm text-gray-600">...</p>
</div>
{/* 模拟页面内容 */}
<div className="p-6 opacity-30">
<div className="mb-4 h-6 w-1/3 rounded bg-gray-200"></div>
<div className="mb-2 h-4 w-full rounded bg-gray-200"></div>
<div className="mb-2 h-4 w-5/6 rounded bg-gray-200"></div>
<div className="mb-4 h-4 w-4/6 rounded bg-gray-200"></div>
<div className="mb-2 h-4 w-full rounded bg-gray-200"></div>
<div className="h-4 w-3/4 rounded bg-gray-200"></div>
</div>
</div>
</div>
</div>
)
}
// Interactive Loading Demo
export const InteractiveLoadingDemo: Story = {
render: () => {
const loadingStates = [
{ text: '发送消息', color: 'text-blue-500', bgColor: 'bg-blue-500' },
{ text: '上传文件', color: 'text-green-500', bgColor: 'bg-green-500' },
{ text: '生成内容', color: 'text-purple-500', bgColor: 'bg-purple-500' },
{ text: '搜索结果', color: 'text-orange-500', bgColor: 'bg-orange-500' }
]
return (
<div className="space-y-4">
<h3 className="mb-3 font-semibold"></h3>
<div className="grid grid-cols-2 gap-4">
{loadingStates.map((state, index) => (
<button
key={index}
type="button"
className={`flex items-center justify-center gap-2 rounded-lg ${state.bgColor} px-4 py-3 text-white transition-all hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2`}
onClick={() => {
// 演示用途 - 在实际应用中这里会触发真实的加载状态
alert(`触发 ${state.text} 加载状态`)
}}>
<SvgSpinners180Ring size="16" />
<span>{state.text}...</span>
</button>
))}
</div>
<p className="text-xs text-gray-500"></p>
</div>
)
}
}

View File

@@ -0,0 +1,374 @@
import type { Meta, StoryObj } from '@storybook/react'
import ToolsCallingIcon from '../../../src/components/icons/ToolsCallingIcon'
const meta: Meta<typeof ToolsCallingIcon> = {
title: 'Components/Icons/ToolsCallingIcon',
component: ToolsCallingIcon,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'⚠️ **已废弃** - 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)。计划在未来版本中移除。建议直接使用 lucide-react 的 Wrench 图标。'
}
}
},
tags: ['autodocs', 'deprecated'],
argTypes: {
className: {
description: '容器的自定义 CSS 类名',
control: { type: 'text' }
},
iconClassName: {
description: '图标的自定义 CSS 类名',
control: { type: 'text' }
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Basic Tools Calling Icon
export const BasicToolsCallingIcon: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="flex items-center gap-4">
<ToolsCallingIcon />
</div>
<p className="mt-2 text-sm text-gray-600">"函数调用"</p>
</div>
</div>
)
}
// Different Sizes
export const DifferentSizes: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="flex items-end gap-6">
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-3 h-3" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-5 h-5" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-6 h-6" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-8 h-8" />
<span className="text-xs text-gray-600"></span>
</div>
</div>
</div>
</div>
)
}
// Different Colors
export const DifferentColors: Story = {
render: () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 font-semibold"></h3>
<div className="flex items-center gap-6">
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon />
<span className="text-xs text-gray-600">绿</span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-blue-500" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-orange-500" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-red-500" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-purple-500" />
<span className="text-xs text-gray-600"></span>
</div>
<div className="flex flex-col items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-gray-500" />
<span className="text-xs text-gray-600"></span>
</div>
</div>
</div>
</div>
)
}
// Model Features Context
export const ModelFeaturesContext: Story = {
render: () => (
<div className="space-y-4">
<h3 className="mb-3 font-semibold">使</h3>
<div className="grid gap-4">
<div className="rounded-lg border border-gray-200 p-4">
<div className="mb-2 flex items-center gap-2">
<h4 className="font-medium">GPT-4 Turbo</h4>
<ToolsCallingIcon />
</div>
<p className="text-sm text-gray-600">API</p>
<div className="mt-2 flex gap-2">
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800"></span>
<span className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800"></span>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="mb-2 flex items-center gap-2">
<h4 className="font-medium">Claude 3.5 Sonnet</h4>
<ToolsCallingIcon />
</div>
<p className="text-sm text-gray-600">Anthropic的高性能模型使</p>
<div className="mt-2 flex gap-2">
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800"></span>
<span className="rounded bg-orange-100 px-2 py-1 text-xs text-orange-800"></span>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="mb-2 flex items-center gap-2">
<h4 className="font-medium">Llama 3.1 8B</h4>
{/* 不支持函数调用 */}
</div>
<p className="text-sm text-gray-600">Meta的开源模型</p>
<div className="mt-2 flex gap-2">
<span className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-800"></span>
</div>
</div>
</div>
</div>
)
}
// Chat Message Context
export const ChatMessageContext: Story = {
render: () => (
<div className="space-y-4">
<h3 className="mb-3 font-semibold">使</h3>
<div className="space-y-3">
<div className="rounded-lg bg-blue-50 p-3">
<div className="mb-1 flex items-center gap-2 text-sm text-blue-800">
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
<span className="font-medium">调用工具: weather_api</span>
</div>
<p className="text-sm text-blue-700">...</p>
</div>
<div className="rounded-lg bg-green-50 p-3">
<div className="mb-1 flex items-center gap-2 text-sm text-green-800">
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
<span className="font-medium">调用工具: search_web</span>
</div>
<p className="text-sm text-green-700">AI新闻...</p>
</div>
<div className="rounded-lg bg-orange-50 p-3">
<div className="mb-1 flex items-center gap-2 text-sm text-orange-800">
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
<span className="font-medium">调用工具: code_interpreter</span>
</div>
<p className="text-sm text-orange-700">Python代码计算结果...</p>
</div>
</div>
</div>
)
}
// Tool Availability Indicator
export const ToolAvailabilityIndicator: Story = {
render: () => (
<div className="space-y-4">
<h3 className="mb-3 font-semibold"></h3>
<div className="rounded-lg border border-gray-200">
<div className="border-b border-gray-200 p-3">
<h4 className="font-medium text-gray-900"></h4>
</div>
<div className="divide-y divide-gray-200">
<div className="flex items-center justify-between p-3 hover:bg-gray-50">
<div className="flex items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-[#00b96b]" />
<span className="font-medium"></span>
</div>
<span className="rounded-full bg-green-100 px-2 py-1 text-xs text-green-800"></span>
</div>
<div className="flex items-center justify-between p-3 hover:bg-gray-50">
<div className="flex items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-[#00b96b]" />
<span className="font-medium"></span>
</div>
<span className="rounded-full bg-green-100 px-2 py-1 text-xs text-green-800"></span>
</div>
<div className="flex items-center justify-between p-3 hover:bg-gray-50 opacity-60">
<div className="flex items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-gray-400" />
<span className="font-medium"></span>
</div>
<span className="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-800"></span>
</div>
<div className="flex items-center justify-between p-3 hover:bg-gray-50">
<div className="flex items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-yellow-600" />
<span className="font-medium"></span>
</div>
<span className="rounded-full bg-yellow-100 px-2 py-1 text-xs text-yellow-800">使</span>
</div>
</div>
</div>
</div>
)
}
// Interactive Tool Selection
export const InteractiveToolSelection: Story = {
render: () => {
const tools = [
{ name: '天气查询', description: '获取实时天气信息', available: true },
{ name: '网络搜索', description: '搜索最新信息', available: true },
{ name: '代码执行', description: '运行Python代码', available: false },
{ name: '图像分析', description: '分析和描述图像', available: true },
{ name: '数据可视化', description: '创建图表和图形', available: false }
]
return (
<div className="space-y-4">
<h3 className="mb-3 font-semibold"></h3>
<div className="grid grid-cols-1 gap-3">
{tools.map((tool, index) => (
<button
key={index}
type="button"
className={`flex items-center gap-3 rounded-lg border p-3 text-left transition-all hover:shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/20 ${
tool.available
? 'border-gray-200 hover:border-blue-500'
: 'border-gray-200 opacity-60 cursor-not-allowed'
}`}
disabled={!tool.available}>
<ToolsCallingIcon
iconClassName={`w-4 h-4 mr-1.5 ${tool.available ? 'text-[#00b96b]' : 'text-gray-400'}`}
/>
<div className="flex-1">
<div className="font-medium">{tool.name}</div>
<div className="text-sm text-gray-600">{tool.description}</div>
</div>
<div className="text-xs">
{tool.available ? (
<span className="rounded bg-green-100 px-2 py-1 text-green-800"></span>
) : (
<span className="rounded bg-gray-100 px-2 py-1 text-gray-800"></span>
)}
</div>
</button>
))}
</div>
</div>
)
}
}
// Loading Tool Calls
export const LoadingToolCalls: Story = {
render: () => (
<div className="space-y-4">
<h3 className="mb-3 font-semibold"></h3>
<div className="space-y-3">
<div className="rounded-lg border border-gray-200 p-3">
<div className="flex items-center gap-2">
<ToolsCallingIcon />
<span className="font-medium">...</span>
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500"></div>
</div>
<p className="mt-1 text-sm text-gray-600">weather_api(city="北京")</p>
</div>
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
<div className="flex items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-green-600" />
<span className="font-medium text-green-800"></span>
<span className="text-green-600"></span>
</div>
<p className="mt-1 text-sm text-green-700"> 22°C</p>
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="flex items-center gap-2">
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-red-600" />
<span className="font-medium text-red-800"></span>
<span className="text-red-600"></span>
</div>
<p className="mt-1 text-sm text-red-700">API密钥无效</p>
</div>
</div>
</div>
)
}
// Settings Panel
export const SettingsPanel: Story = {
render: () => (
<div className="space-y-4">
<h3 className="mb-3 font-semibold">使</h3>
<div className="rounded-lg border border-gray-200 p-4">
<div className="mb-4 flex items-center gap-2">
<ToolsCallingIcon />
<h4 className="font-medium"></h4>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium"></div>
<div className="text-sm text-gray-600">AI模型调用外部工具</div>
</div>
<input type="checkbox" className="rounded" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium"></div>
<div className="text-sm text-gray-600"></div>
</div>
<input type="checkbox" className="rounded" />
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium"></div>
<div className="text-sm text-gray-600"></div>
</div>
<input type="checkbox" className="rounded" defaultChecked />
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,28 +0,0 @@
import type { VideoEndpointType, VideoStatus } from '@types'
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps } from './columnHelpers'
export const videoTable = sqliteTable(
'video',
{
id: text().primaryKey(),
type: text('type').$type<VideoEndpointType>().notNull(),
providerId: text('providerId').notNull(),
name: text('name'),
thumbnail: text('thumbnail'),
fileId: text('fileId'),
prompt: text('prompt').notNull(),
status: text('status').$type<VideoStatus>().notNull(),
progress: integer('progress'),
metadata: text('metadata', { mode: 'json' }),
error: text('error', { mode: 'json' }),
...createUpdateTimestamps
},
(table) => [
index('status_idx').on(table.status),
index('provider_idx').on(table.providerId),
index('type_idx').on(table.type),
uniqueIndex('file_id_idx').on(table.fileId)
]
)

View File

@@ -396,13 +396,12 @@ class FileStorage {
throw new Error(`Source file does not exist: ${filePath}`)
}
// 确保目标目录存在
// Ensure the destination directory exists
const destDir = path.dirname(newPath)
if (!fs.existsSync(destDir)) {
await fs.promises.mkdir(destDir, { recursive: true })
}
// 移动文件
await fs.promises.rename(filePath, newPath)
logger.debug(`File moved successfully: ${filePath} to ${newPath}`)
} catch (error) {
@@ -417,13 +416,12 @@ class FileStorage {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
// 确保目标父目录存在
// Ensure the parent directory of the destination exists
const parentDir = path.dirname(newDirPath)
if (!fs.existsSync(parentDir)) {
await fs.promises.mkdir(parentDir, { recursive: true })
}
// 移动目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {

View File

@@ -21,7 +21,6 @@ import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import AssistantPresetsPage from './pages/store/assistants/presets/AssistantPresetsPage'
import TranslatePage from './pages/translate/TranslatePage'
import { VideoPage } from './pages/video/VideoPage'
const Router: FC = () => {
const { navbarPosition } = useNavbarPosition()
@@ -42,7 +41,6 @@ const Router: FC = () => {
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
<Route path="/video" element={<VideoPage />} />
</Routes>
</ErrorBoundary>
)

View File

@@ -517,34 +517,6 @@ export default class ModernAiProvider {
return images
}
/**
* We manually implement this method before aisdk supports it well
*/
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
return this.legacyProvider.createVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
return this.legacyProvider.retrieveVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
return this.legacyProvider.retrieveVideoContent(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
return this.legacyProvider.deleteVideo(params)
}
public getBaseURL(): string {
return this.legacyProvider.getBaseURL()
}

View File

@@ -15,8 +15,6 @@ import {
import { isSupportDeveloperRoleProvider } from '@renderer/config/providers'
import { estimateTextTokens } from '@renderer/services/TokenService'
import type {
CreateVideoParams,
DeleteVideoParams,
FileMetadata,
MCPCallToolResponse,
MCPTool,
@@ -24,8 +22,6 @@ import type {
Model,
OpenAIServiceTier,
Provider,
RetrieveVideoContentParams,
RetrieveVideoParams,
ToolCallResponse
} from '@renderer/types'
import { FileTypes, WebSearchSource } from '@renderer/types'
@@ -155,26 +151,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return await sdk.responses.create(payload, options)
}
public async createVideo(params: CreateVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.create(params.params, params.options)
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.retrieve(params.videoId, params.options)
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<Response> {
const sdk = await this.getSdkInstance()
return sdk.videos.downloadContent(params.videoId, params.query, params.options)
}
public async deleteVideo(params: DeleteVideoParams): Promise<OpenAI.Videos.VideoDeleteResponse> {
const sdk = await this.getSdkInstance()
return sdk.videos.delete(params.videoId, params.options)
}
private async handlePdfFile(file: FileMetadata): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
if (file.size > 32 * MB) return undefined
try {

View File

@@ -5,19 +5,7 @@ import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@render
import { getProviderByModel } from '@renderer/services/AssistantService'
import { withSpanResult } from '@renderer/services/SpanManagerService'
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type {
CreateVideoParams,
CreateVideoResult,
DeleteVideoParams,
DeleteVideoResult,
GenerateImageParams,
Model,
Provider,
RetrieveVideoContentParams,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types'
import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import { isSupportedToolUse } from '@renderer/utils/mcp-tools'
@@ -191,54 +179,6 @@ export default class AiProvider {
return this.apiClient.generateImage(params)
}
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.createVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.retrieveVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const response = await this.apiClient.retrieveVideoContent(params)
return {
type: 'openai',
response
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const result = await this.apiClient.deleteVideo(params)
return {
type: 'openai',
result
}
} else {
throw new Error('Video deletion is not supported by this provider')
}
}
public getBaseURL(): string {
return this.apiClient.getBaseURL()
}

View File

@@ -452,6 +452,7 @@
.tiptap ul[data-type='taskList'] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
margin-top: 0.1rem;
user-select: none;
display: flex;
align-items: center;
@@ -461,12 +462,39 @@
flex: 1 1 auto;
}
/* For nested task lists, align label with first line of text */
.tiptap ul[data-type='taskList'] li:has(ul[data-type='taskList']) {
align-items: flex-start;
}
.tiptap ul[data-type='taskList'] li:has(ul[data-type='taskList']) > label {
margin-top: 0.4rem;
}
.tiptap ul[data-type='taskList'] li > div p {
margin: 0;
}
/* Checked task item appearance */
.tiptap ul[data-type='taskList'] li[data-checked='true'] > div {
.tiptap ul[data-type='taskList'] li[data-checked='true'] > div > p {
color: var(--color-text-2);
text-decoration: line-through;
}
/* Prevent nested task lists from inheriting checked styles */
.tiptap ul[data-type='taskList'] li[data-checked='true'] > div ul[data-type='taskList'] li > div > p {
color: inherit;
text-decoration: none;
}
.tiptap
ul[data-type='taskList']
li[data-checked='true']
> div
ul[data-type='taskList']
li[data-checked='true']
> div
> p {
color: var(--color-text-2);
text-decoration: line-through;
}

View File

@@ -14,6 +14,7 @@ import {
Heading1,
Heading2,
Heading3,
Highlighter,
Image,
Italic,
Link,
@@ -193,6 +194,20 @@ const DEFAULT_COMMANDS: Command[] = [
toolbarGroup: 'formatting',
formattingCommand: 'strike'
},
{
id: 'highlight',
title: 'Highlight',
description: 'Highlight text',
category: CommandCategory.TEXT,
icon: Highlighter,
keywords: ['highlight', 'marker', 'background'],
handler: (editor: Editor) => {
editor.chain().focus().toggleHighlight().run()
},
showInToolbar: true,
toolbarGroup: 'formatting',
formattingCommand: 'highlight'
},
{
id: 'inlineCode',
title: 'Inline Code',
@@ -348,11 +363,11 @@ const DEFAULT_COMMANDS: Command[] = [
id: 'link',
title: 'Link',
description: 'Add a link',
category: CommandCategory.SPECIAL,
category: CommandCategory.TEXT,
icon: Link,
keywords: ['link', 'url', 'href'],
handler: (editor: Editor) => {
editor.chain().focus().setEnhancedLink({ href: '' }).run()
editor.chain().focus().setLink({ href: '' }).run()
},
showInToolbar: true,
toolbarGroup: 'media',

View File

@@ -13,6 +13,8 @@ export const CodeBlockShiki = CodeBlock.extend<CodeBlockShikiOptions>({
addOptions() {
return {
...this.parent?.(),
tabSize: 4,
enableTabIndentation: true,
languageClassPrefix: 'language-',
exitOnTripleEnter: true,
exitOnArrowDown: true,
@@ -58,12 +60,6 @@ export const CodeBlockShiki = CodeBlock.extend<CodeBlockShikiOptions>({
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.isActive(this.name)) {
return this.editor.commands.insertContent(' ')
}
return false
},
'Shift-Tab': () => {
if (this.editor.isActive(this.name)) {
const { selection } = this.editor.state

View File

@@ -105,12 +105,7 @@ const createLinkHoverPlugin = (options: LinkHoverPluginOptions) => {
const $pos = view.state.doc.resolve(pos)
// Find the link mark at this position
const linkMark = $pos
.marks()
.find(
(mark) =>
(mark.type.name === 'enhancedLink' || mark.type.name === 'link') && mark.attrs.href === href
)
const linkMark = $pos.marks().find((mark) => mark.type.name === 'link' && mark.attrs.href === href)
if (linkMark) {
// Use ProseMirror's mark range finding
@@ -153,12 +148,7 @@ const createLinkHoverPlugin = (options: LinkHoverPluginOptions) => {
const startPos = view.posAtDOM(linkElement, 0)
if (startPos >= 0) {
const $pos = view.state.doc.resolve(startPos)
const linkMark = $pos
.marks()
.find(
(mark) =>
(mark.type.name === 'enhancedLink' || mark.type.name === 'link') && mark.attrs.href === href
)
const linkMark = $pos.marks().find((mark) => mark.type.name === 'link' && mark.attrs.href === href)
if (linkMark) {
const range = getMarkRange($pos, linkMark.type, linkMark.attrs)
@@ -235,7 +225,7 @@ const createLinkAutoUpdatePlugin = () => {
newState.doc.descendants((node, pos) => {
if (node.isText && node.marks) {
node.marks.forEach((mark) => {
if (mark.type.name === 'enhancedLink') {
if (mark.type.name === 'link') {
const text = node.text || ''
const currentHref = mark.attrs.href || ''
@@ -280,16 +270,7 @@ const createLinkAutoUpdatePlugin = () => {
})
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
enhancedLink: {
setEnhancedLink: (attributes: { href: string; title?: string }) => ReturnType
toggleEnhancedLink: (attributes: { href: string; title?: string }) => ReturnType
unsetEnhancedLink: () => ReturnType
updateLinkText: (text: string) => ReturnType
}
}
}
// Commands are inherited from the parent Link extension, no need to redeclare
export interface EnhancedLinkOptions {
onLinkHover?: (
@@ -304,7 +285,7 @@ export interface EnhancedLinkOptions {
}
export const EnhancedLink = Link.extend<EnhancedLinkOptions>({
name: 'enhancedLink',
name: 'link', // Use 'link' instead of 'enhancedLink' to be compatible with Markdown extension
addOptions() {
return {

View File

@@ -0,0 +1,73 @@
import { Mark } from '@tiptap/core'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
highlight: {
toggleHighlight: () => ReturnType
}
}
}
export const Highlight = Mark.create({
name: 'highlight',
addOptions() {
return {
HTMLAttributes: {}
}
},
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', HTMLAttributes, 0]
},
// define a custom Markdown tokenizer to recognize ==text==
markdownTokenizer: {
name: 'highlight',
level: 'inline', // inline element
// fast hint for the lexer to find candidate positions
start: (src) => src.indexOf('=='),
tokenize: (src, tokens, lexer) => {
// Match ==text== at the start of the remaining source
const match = /^==([^=]+)==/.exec(src)
if (!match) return undefined
return {
type: 'highlight', // token type (must match name)
raw: match[0], // full matched string: ==text==
text: match[1], // inner content: text
// Let the Markdown lexer process nested inline formatting
tokens: lexer.inlineTokens(match[1])
}
}
},
// Parse Markdown token to Tiptap JSON
parseMarkdown: (token, helpers) => {
// Parse nested inline tokens into Tiptap inline content
const content = helpers.parseInline(token.tokens || [])
// Apply the 'highlight' mark to the parsed content
return helpers.applyMark('highlight', content)
},
// Render Tiptap node back to Markdown
renderMarkdown: (node, helpers) => {
const content = helpers.renderChildren(node.content || [])
// Wrap serialized children in == delimiters
return `==${content}==`
},
addCommands() {
return {
toggleHighlight:
() =>
({ commands }) => {
return commands.toggleMark(this.name)
}
}
}
})

View File

@@ -17,6 +17,63 @@ export const YamlFrontMatter = Node.create({
atom: true,
draggable: false,
// Custom tokenizer for YAML front matter
markdownTokenizer: {
name: 'yamlFrontMatter',
level: 'block',
start(src: string) {
const result = src.match(/^---\n/) ? 0 : -1
return result
},
// Parse YAML front matter
tokenize(src: string) {
// Match: ---\n...yaml content...\n---
const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(src)
if (!match) {
return undefined
}
const token = {
type: 'yamlFrontMatter',
raw: match[0],
text: match[1] // YAML content without delimiters
}
return token
}
},
// Parse markdown token to Tiptap JSON
parseMarkdown(token, helpers) {
const attrs = {
content: token.text || ''
}
return helpers.createNode('yamlFrontMatter', attrs)
},
// Serialize Tiptap node to markdown
renderMarkdown(node) {
const content = node.attrs?.content || ''
if (!content.trim()) {
return ''
}
let result = ''
// Ensure proper format with opening and closing ---
// The content is stored without the --- delimiters, so we need to add them back
if (content.endsWith('---')) {
// Content already has closing ---, just add opening
result = '---\n' + content + '\n\n'
} else {
// Add both opening and closing ---
result = '---\n' + content + '\n---\n\n'
}
return result
},
addOptions() {
return {
HTMLAttributes: {}

View File

@@ -212,7 +212,6 @@ const RichEditor = ({
tableOfContentsItems,
linkEditor,
setMarkdown,
setHtml,
clear,
getPreviewText
} = useRichEditor({
@@ -419,8 +418,8 @@ const RichEditor = ({
const { from, to, $from } = selection
// 如果当前已经是链接,则取消链接
if (editor.isActive('enhancedLink')) {
editor.chain().focus().unsetEnhancedLink().run()
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
} else {
// 获取当前段落的文本内容
if (from !== to) {
@@ -429,7 +428,7 @@ const RichEditor = ({
const url = selectedText.trim().startsWith('http')
? selectedText.trim()
: `https://${selectedText.trim()}`
editor.chain().focus().setTextSelection({ from, to }).setEnhancedLink({ href: url }).run()
editor.chain().focus().setTextSelection({ from, to }).setLink({ href: url }).run()
}
} else {
const paragraphText = $from.parent.textContent
@@ -444,13 +443,13 @@ const RichEditor = ({
const { $from } = selection
const start = $from.start()
const end = $from.end()
editor.chain().focus().setTextSelection({ from: start, to: end }).setEnhancedLink({ href: url }).run()
editor.chain().focus().setTextSelection({ from: start, to: end }).setLink({ href: url }).run()
} catch (error) {
logger.warn('Failed to set enhanced link:', error as Error)
editor.chain().focus().toggleEnhancedLink({ href: '' }).run()
logger.warn('Failed to set link:', error as Error)
editor.chain().focus().toggleLink({ href: '' }).run()
}
} else {
editor.chain().focus().toggleEnhancedLink({ href: '' }).run()
editor.chain().focus().toggleLink({ href: '' }).run()
}
}
}
@@ -476,6 +475,10 @@ const RichEditor = ({
break
case 'taskList':
editor.chain().focus().toggleTaskList().run()
break
case 'highlight':
editor.chain().focus().toggleHighlight().run()
break
}
},
[editor]
@@ -489,10 +492,7 @@ const RichEditor = ({
getHtml: () => html,
getMarkdown: () => markdown,
setContent: (content: string) => {
editor?.commands.setContent(content)
},
setHtml: (htmlContent: string) => {
setHtml(htmlContent)
editor?.commands.setContent(content, { contentType: 'markdown' })
},
setMarkdown: (markdownContent: string) => {
setMarkdown(markdownContent)
@@ -513,7 +513,7 @@ const RichEditor = ({
}
},
getPreviewText: (maxLength?: number) => {
return getPreviewText(markdown, maxLength)
return getPreviewText(maxLength)
},
getScrollTop: () => {
return scrollContainerRef.current?.scrollTop ?? 0
@@ -548,7 +548,7 @@ const RichEditor = ({
getAllCommands,
getToolbarCommands
}),
[editor, html, markdown, setHtml, setMarkdown, clear, getPreviewText]
[editor, html, markdown, setMarkdown, clear, getPreviewText]
)
return (

View File

@@ -72,7 +72,8 @@ const getTooltipText = (t: TFunction, command: FormattingCommand): string => {
table: t('richEditor.toolbar.table'),
image: t('richEditor.toolbar.image'),
blockMath: t('richEditor.toolbar.blockMath'),
inlineMath: t('richEditor.toolbar.inlineMath')
inlineMath: t('richEditor.toolbar.inlineMath'),
highlight: t('richEditor.toolbar.highlight')
}
return tooltipMap[command] || command
@@ -301,6 +302,8 @@ function getFormattingState(state: FormattingState, command: FormattingCommand):
return state?.isMath || false
case 'inlineMath':
return state?.isInlineMath || false
case 'highlight':
return state?.isHighlight || false
default:
return false
}

View File

@@ -93,8 +93,6 @@ export interface RichEditorRef {
getMarkdown: () => string
/** Set editor content (plain text) */
setContent: (content: string) => void
/** Set editor HTML content */
setHtml: (html: string) => void
/** Set editor Markdown content */
setMarkdown: (markdown: string) => void
/** Focus the editor */
@@ -197,6 +195,8 @@ export interface FormattingState {
canMath: boolean
/** Whether taskList is active */
isTaskList: boolean
/** Whether highlight is active */
isHighlight: boolean
}
export type FormattingCommand =
@@ -225,6 +225,7 @@ export type FormattingCommand =
| 'table'
| 'taskList'
| 'image'
| 'highlight'
export interface ToolbarProps {
/** Editor instance ref */

View File

@@ -5,12 +5,6 @@ import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import type { FormattingState } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import {
htmlToMarkdown,
isMarkdownContent,
markdownToHtml,
markdownToPreviewText
} from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list'
@@ -22,16 +16,19 @@ import {
TableOfContents
} from '@tiptap/extension-table-of-contents'
import Typography from '@tiptap/extension-typography'
import Underline from '@tiptap/extension-underline'
import { Markdown } from '@tiptap/markdown'
import { useEditor, useEditorState } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import StarterKit from '@tiptap/starter-kit'
import { t } from 'i18next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { commandSuggestion } from './command'
import { CodeBlockShiki } from './extensions/code-block-shiki/code-block-shiki'
import CodeBlockShiki from './extensions/code-block-shiki'
import { EnhancedImage } from './extensions/enhanced-image'
import { EnhancedLink } from './extensions/enhanced-link'
import { EnhancedMath } from './extensions/enhanced-math'
import { Highlight } from './extensions/hightlight'
import { Placeholder } from './extensions/placeholder'
import { YamlFrontMatter } from './extensions/yaml-front-matter'
import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers/imageUtils'
@@ -63,6 +60,16 @@ const SourceLineAttribute = Extension.create({
}
})
// Create extension to disable marks on split (Enter key)
const DisableMarksOnSplit = Extension.create({
name: 'disableMarksOnSplit',
addKeyboardShortcuts() {
return {
Enter: () => this.editor.commands.splitBlock({ keepMarks: false })
}
}
})
export interface UseRichEditorOptions {
/** Initial markdown content */
initialContent?: string
@@ -105,8 +112,6 @@ export interface UseRichEditorReturn {
html: string
/** Preview text for display */
previewText: string
/** Whether content is detected as markdown */
isMarkdown: boolean
/** Whether editor is disabled */
disabled: boolean
/** Current formatting state from TipTap editor */
@@ -125,19 +130,11 @@ export interface UseRichEditorReturn {
/** Set markdown content */
setMarkdown: (content: string) => void
/** Set HTML content (converts to markdown) */
setHtml: (html: string) => void
/** Clear all content */
clear: () => void
/** Convert markdown to HTML */
toHtml: (markdown: string) => string
/** Convert markdown to safe HTML */
toSafeHtml: (markdown: string) => string
/** Convert HTML to markdown */
toMarkdown: (html: string) => string
/** Get preview text from markdown */
getPreviewText: (markdown: string, maxLength?: number) => string
getPreviewText: (maxLength?: number) => string
}
/**
@@ -162,20 +159,6 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
const [markdown, setMarkdownState] = useState<string>(initialContent)
const html = useMemo(() => {
if (!markdown) return ''
return markdownToHtml(markdown)
}, [markdown])
const previewText = useMemo(() => {
if (!markdown) return ''
return markdownToPreviewText(markdown, previewLength)
}, [markdown, previewLength])
const isMarkdown = useMemo(() => {
return isMarkdownContent(markdown)
}, [markdown])
// Get theme and language mapping from CodeStyleProvider
const { activeShikiTheme } = useCodeStyle()
@@ -223,7 +206,13 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// TipTap editor extensions
const extensions = useMemo(
() => [
Markdown.configure({
markedOptions: {
gfm: true
}
}),
SourceLineAttribute,
DisableMarksOnSplit,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6]
@@ -236,6 +225,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
onLinkHoverEnd: handleLinkHoverEnd,
editable: editable
}),
Underline,
TableOfContents.configure({
getIndex: getHierarchicalIndexes,
onUpdate(content) {
@@ -380,7 +370,8 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
TaskList,
TaskItem.configure({
nested: true
})
}),
Highlight
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeholder, activeShikiTheme, handleLinkHover, handleLinkHoverEnd]
@@ -389,7 +380,8 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
const editor = useEditor({
shouldRerenderOnTransaction: true,
extensions,
content: html || '',
content: markdown || '',
contentType: 'markdown',
editable: editable,
editorProps: {
handlePaste: (view, event) => {
@@ -421,17 +413,32 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// Default behavior for non-code blocks
const text = event.clipboardData?.getData('text/plain') ?? ''
if (text) {
const html = markdownToHtml(text)
const { $from } = selection
const parentNode = $from.parent
const atStartOfLine = $from.parentOffset === 0
const inEmptyParagraph = $from.parent.type.name === 'paragraph' && $from.parent.textContent === ''
const isEmptyBlock = parentNode.textContent === ''
const hasMultipleLines = text.includes('\n')
if (!atStartOfLine && !inEmptyParagraph) {
const cleanHtml = html.replace(/^<p>(.*?)<\/p>/s, '$1')
editor.commands.insertContent(cleanHtml)
} else {
editor.commands.insertContent(html)
// Check if we're in a styled block (heading, blockquote, etc.) that should preserve its style
const styledBlocks = ['heading', 'blockquote', 'listItem']
const isInStyledBlock = styledBlocks.includes(parentNode.type.name)
// If in a styled block (like H1), always insert as plain text to preserve the style
// even if the block is empty or we're at the start
if (isInStyledBlock && !hasMultipleLines) {
const tr = view.state.tr.insertText(text, selection.from, selection.to)
view.dispatch(tr)
return true
}
// If pasting in the middle of a line (not at start, block has content), insert plain text
if (!atStartOfLine && !isEmptyBlock && !hasMultipleLines) {
const tr = view.state.tr.insertText(text, selection.from, selection.to)
view.dispatch(tr)
return true
}
editor.commands.insertContent(text, { contentType: 'markdown' })
onPaste?.(html)
return true
}
@@ -449,10 +456,10 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
onUpdate: ({ editor }) => {
const content = editor.getText()
const htmlContent = editor.getHTML()
const markdownContent = editor.getMarkdown()
try {
const convertedMarkdown = htmlToMarkdown(htmlContent)
setMarkdownState(convertedMarkdown)
onChange?.(convertedMarkdown)
setMarkdownState(markdownContent)
onChange?.(markdownContent)
onContentChange?.(content)
if (onHtmlChange) {
@@ -475,6 +482,9 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
}
})
const html = editor.getHTML()
const previewText = editor.getText().slice(0, previewLength)
// Handle image paste function
const handleImagePaste = useCallback(
async (file: File) => {
@@ -562,7 +572,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
.setTextSelection({ from: linkRange.from, to: linkRange.to })
.insertContent(text)
.setTextSelection({ from: linkRange.from, to: linkRange.from + text.length })
.setEnhancedLink({ href })
.setLink({ href })
.run()
}
setLinkEditorState({
@@ -582,11 +592,11 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
if (linkRange) {
// Use a more reliable method - directly remove the mark from the range
const tr = editor.state.tr
tr.removeMark(linkRange.from, linkRange.to, editor.schema.marks.enhancedLink || editor.schema.marks.link)
tr.removeMark(linkRange.from, linkRange.to, editor.schema.marks.link)
editor.view.dispatch(tr)
} else {
// No explicit range - try to extend current mark range and remove
editor.chain().focus().extendMarkRange('enhancedLink').unsetEnhancedLink().run()
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}
// Close link editor
@@ -710,7 +720,8 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
isMath: false,
isInlineMath: false,
canMath: false,
isTaskList: false
isTaskList: false,
isHighlight: false
}
}
@@ -737,9 +748,9 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
isOrderedList: editor.isActive('orderedList') ?? false,
isCodeBlock: editor.isActive('codeBlock') ?? false,
isBlockquote: editor.isActive('blockquote') ?? false,
isLink: (editor.isActive('enhancedLink') || editor.isActive('link')) ?? false,
canLink: editor.can().chain().setEnhancedLink({ href: '' }).run() ?? false,
canUnlink: editor.can().chain().unsetEnhancedLink().run() ?? false,
isLink: editor.isActive('link') ?? false,
canLink: editor.can().chain().setLink({ href: '' }).run() ?? false,
canUnlink: editor.can().chain().unsetLink().run() ?? false,
canUndo: editor.can().chain().undo().run() ?? false,
canRedo: editor.can().chain().redo().run() ?? false,
isTable: editor.isActive('table') ?? false,
@@ -748,7 +759,8 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
isMath: editor.isActive('blockMath') ?? false,
isInlineMath: editor.isActive('inlineMath') ?? false,
canMath: true,
isTaskList: editor.isActive('taskList') ?? false
isTaskList: editor.isActive('taskList') ?? false,
isHighlight: editor.isActive('highlight') ?? false
}
}
})
@@ -759,33 +771,12 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
setMarkdownState(content)
onChange?.(content)
const convertedHtml = markdownToHtml(content)
editor.commands.setContent(convertedHtml)
onHtmlChange?.(convertedHtml)
editor.commands.setContent(content, { contentType: 'markdown' })
} catch (error) {
logger.error('Error setting markdown content:', error as Error)
}
},
[editor.commands, onChange, onHtmlChange]
)
const setHtml = useCallback(
(htmlContent: string) => {
try {
const convertedMarkdown = htmlToMarkdown(htmlContent)
setMarkdownState(convertedMarkdown)
onChange?.(convertedMarkdown)
editor.commands.setContent(htmlContent)
onHtmlChange?.(htmlContent)
} catch (error) {
logger.error('Error setting HTML content:', error as Error)
}
},
[editor.commands, onChange, onHtmlChange]
[editor.commands, onChange]
)
const clear = useCallback(() => {
@@ -794,55 +785,25 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
onHtmlChange?.('')
}, [onChange, onHtmlChange])
// Utility methods
const toHtml = useCallback((content: string): string => {
try {
return markdownToHtml(content)
} catch (error) {
logger.error('Error converting markdown to HTML:', error as Error)
return ''
}
}, [])
const toSafeHtml = useCallback((content: string): string => {
try {
return markdownToHtml(content)
} catch (error) {
logger.error('Error converting markdown to safe HTML:', error as Error)
return ''
}
}, [])
const toMarkdown = useCallback((htmlContent: string): string => {
try {
return htmlToMarkdown(htmlContent)
} catch (error) {
logger.error('Error converting HTML to markdown:', error as Error)
return ''
}
}, [])
const getPreviewText = useCallback(
(content: string, maxLength?: number): string => {
(maxLength?: number): string => {
try {
return markdownToPreviewText(content, maxLength || previewLength)
return editor.getText().slice(0, maxLength || previewLength)
} catch (error) {
logger.error('Error generating preview text:', error as Error)
return ''
}
},
[previewLength]
[editor, previewLength]
)
return {
// Editor instance
editor,
// State
markdown,
html,
previewText,
isMarkdown,
disabled: !editable,
formattingState,
tableOfContentsItems,
@@ -857,13 +818,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// Actions
setMarkdown,
setHtml,
clear,
// Utilities
toHtml,
toSafeHtml,
toMarkdown,
getPreviewText
}
}

View File

@@ -33,7 +33,6 @@ import {
Sparkle,
Sun,
Terminal,
Video,
X
} from 'lucide-react'
import { useCallback, useEffect, useMemo } from 'react'
@@ -108,8 +107,6 @@ const getTabIcon = (
return <Settings size={14} />
case 'code':
return <Terminal size={14} />
case 'video':
return <Video size={14} />
default:
return null
}

View File

@@ -26,8 +26,7 @@ import {
Palette,
Settings,
Sparkle,
Sun,
Video
Sun
} from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -141,8 +140,7 @@ const MainMenus: FC = () => {
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={18} className="icon" />,
notes: <NotepadText size={18} className="icon" />,
code_tools: <Code size={18} className="icon" />,
video: <Video size={18} className="icon" />
code_tools: <Code size={18} className="icon" />
}
const pathMap = {
@@ -154,8 +152,7 @@ const MainMenus: FC = () => {
knowledge: '/knowledge',
files: '/files',
code_tools: '/code',
notes: '/notes',
video: '/video'
notes: '/notes'
}
return visibleSidebarIcons.map((icon) => {

View File

@@ -1,149 +0,0 @@
import type { SystemProviderId, Video } from '@renderer/types'
// Hard-encoded for now. We may implement a function to filter video generation model from provider.models.
export const videoModelsMap = {
openai: ['sora-2', 'sora-2-pro'] as const
} as const satisfies Partial<Record<SystemProviderId, string[]>>
// Mock data for testing
export const mockVideos: Video[] = [
{
id: '1',
type: 'openai',
status: 'downloaded',
prompt: 'A beautiful sunset over the ocean with waves crashing',
thumbnail: 'https://picsum.photos/200/200?random=1',
fileId: 'file-001',
providerId: 'openai',
name: 'video-001',
metadata: {
id: 'video-001',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2',
progress: 100,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'completed'
}
},
{
id: '2',
type: 'openai',
status: 'in_progress',
prompt: 'A cat playing with a ball of yarn in slow motion',
progress: 65,
providerId: 'openai',
name: 'video-002',
metadata: {
id: 'video-002',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 65,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'in_progress'
}
},
{
id: '3',
type: 'openai',
status: 'queued',
prompt: 'Time-lapse of flowers blooming in a garden',
providerId: 'openai',
name: 'video-003',
metadata: {
id: 'video-003',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '12',
size: '1280x720',
status: 'queued'
}
},
{
id: '4',
type: 'openai',
prompt: 'Birds flying in formation against blue sky',
status: 'downloading',
progress: 80,
thumbnail: 'https://picsum.photos/200/200?random=4',
providerId: 'openai',
name: 'video-004',
metadata: {
id: 'video-004',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'completed'
}
},
{
id: '5',
type: 'openai',
status: 'failed',
error: { code: '400', message: 'Video generation failed' },
prompt: 'Mountain landscape with snow peaks and forest',
providerId: 'openai',
name: 'video-005',
metadata: {
id: 'video-005',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: { code: '400', message: 'Video generation failed' },
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'failed'
}
},
{
id: '6',
type: 'openai',
status: 'completed',
thumbnail: 'https://picsum.photos/200/200?random=6',
prompt: 'City street at night with neon lights reflecting on wet pavement',
providerId: 'openai',
name: 'video-006',
metadata: {
id: 'video-006',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '12',
size: '1024x1792',
status: 'completed'
}
}
]

View File

@@ -1,17 +0,0 @@
import { useAppDispatch } from '@renderer/store'
import { setPendingAction } from '@renderer/store/runtime'
import { useCallback } from 'react'
import { useRuntime } from './useRuntime'
export const usePending = () => {
const { pendingMap } = useRuntime()
const dispatch = useAppDispatch()
const setPending = useCallback(
(id: string, value: boolean | undefined) => {
dispatch(setPendingAction({ id, value }))
},
[dispatch]
)
return { pendingMap, setPending }
}

View File

@@ -1,65 +0,0 @@
import type OpenAI from '@cherrystudio/openai'
import { useCallback } from 'react'
import { useProviderVideos } from './useProviderVideos'
export const useAddOpenAIVideo = (providerId: string) => {
const { addVideo } = useProviderVideos(providerId)
const addOpenAIVideo = useCallback(
(video: OpenAI.Videos.Video, prompt: string) => {
switch (video.status) {
case 'queued':
addVideo({
id: video.id,
name: video.id,
providerId,
status: video.status,
type: 'openai',
metadata: video,
prompt
})
break
case 'in_progress':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'in_progress',
type: 'openai',
progress: video.progress,
metadata: video,
prompt
})
break
case 'completed':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'completed',
type: 'openai',
metadata: video,
prompt,
thumbnail: null
})
break
case 'failed':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'failed',
type: 'openai',
error: video.error,
metadata: video,
prompt
})
break
}
},
[addVideo, providerId]
)
return addOpenAIVideo
}

View File

@@ -1,48 +0,0 @@
import { retrieveVideo } from '@renderer/services/ApiService'
import type { SWRConfiguration } from 'swr'
import useSWR, { useSWRConfig } from 'swr'
import { useProvider } from '../useProvider'
import { useVideo } from './useVideo'
export const useOpenAIVideo = (providerId: string, id: string) => {
const { provider } = useProvider(providerId)
const fetcher = async () => {
switch (provider.type) {
case 'openai-response':
return retrieveVideo({
type: 'openai',
videoId: id,
provider
})
default:
throw new Error(`Unsupported provider type: ${provider.type}`)
}
}
const video = useVideo(providerId, id)
let options: SWRConfiguration = {}
switch (video?.status) {
case 'queued':
case 'in_progress':
options = {
refreshInterval: 3000
}
break
default:
options = {
revalidateOnFocus: false,
revalidateOnMount: true
}
}
const { data, isLoading, error } = useSWR(`video/openai/${id}`, fetcher, options)
const { mutate } = useSWRConfig()
const revalidate = () => mutate(`video/openai/${id}`)
return {
video: data,
isLoading,
error,
revalidate
}
}

View File

@@ -1,175 +0,0 @@
import { loggerService } from '@logger'
import { retrieveVideo } from '@renderer/services/ApiService'
import { getProviderById } from '@renderer/services/ProviderService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addVideoAction, setVideoAction, setVideosAction, updateVideoAction } from '@renderer/store/video'
import type { Video } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useVideos } from './useVideos'
import { useVideoThumbnail } from './useVideoThumbnail'
const logger = loggerService.withContext('useVideo')
export const useProviderVideos = (providerId: string) => {
const { removeVideo } = useVideos()
const videos = useAppSelector((state) => state.video.videoMap[providerId])
const videosRef = useRef(videos)
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
videosRef.current = videos
}, [videos])
const getVideo = useCallback(
(id: string) => {
return videos?.find((v) => v.id === id)
},
[videos]
)
const addVideo = useCallback(
(video: Video) => {
if (videos && videos.every((v) => v.id !== video.id)) {
dispatch(addVideoAction({ providerId, video }))
}
},
[dispatch, providerId, videos]
)
const updateVideo = useCallback(
(update: Partial<Omit<Video, 'status'>> & { id: string }) => {
dispatch(updateVideoAction({ providerId, update }))
},
[dispatch, providerId]
)
const setVideo = useCallback(
(video: Video) => {
dispatch(setVideoAction({ providerId, video }))
},
[dispatch, providerId]
)
const setVideos = useCallback(
(newVideos: Video[]) => {
dispatch(setVideosAction({ providerId, videos: newVideos }))
},
[dispatch, providerId]
)
const removeProviderVideo = useCallback(
(videoId: string) => {
removeVideo(videoId, providerId)
},
[providerId, removeVideo]
)
useEffect(() => {
if (!videos) {
setVideos([])
}
}, [setVideos, videos])
// update videos from api
// NOTE: This provider should support openai videos endpoint. No runtime check here.
const provider = getProviderById(providerId)
const fetcher = async () => {
if (!videos || !provider) return []
if (provider.type === 'openai-response') {
const openaiVideos = videos
.filter((v) => v.type === 'openai')
.filter((v) => v.status === 'queued' || v.status === 'in_progress')
const jobs = openaiVideos.map((v) => retrieveVideo({ type: 'openai', videoId: v.id, provider }))
const result = await Promise.allSettled(jobs)
return result.filter((p) => p.status === 'fulfilled').map((p) => p.value)
} else {
throw new Error(`Provider type ${provider.type} is not supported for video status polling`)
}
}
const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 })
const { retrieveThumbnail, isRetrieving } = useVideoThumbnail()
useEffect(() => {
if (error) {
logger.error('Failed to fetch video status updates', error)
return
}
if (!provider) {
logger.warn(`Provider ${providerId} not found.`)
return
}
const videos = videosRef.current
if (!data || !videos) return
data.forEach((v) => {
const retrievedVideo = v.video
const storeVideo = videos.find((v) => v.id === retrievedVideo.id)
if (!storeVideo) {
logger.warn(`Try to update video ${retrievedVideo.id}, but it's not in the store.`)
return
}
switch (retrievedVideo.status) {
case 'in_progress':
if (storeVideo.status === 'queued' || storeVideo.status === 'in_progress') {
setVideo({
...storeVideo,
status: 'in_progress',
progress: retrievedVideo.progress,
metadata: retrievedVideo
})
}
break
case 'completed': {
if (storeVideo.status === 'in_progress' || storeVideo.status === 'queued') {
const newVideo = { ...storeVideo, status: 'completed', thumbnail: null, metadata: retrievedVideo } as const
setVideo(newVideo)
// Try to get thumbnail
if (isRetrieving(storeVideo.id)) return
retrieveThumbnail(newVideo)
.then((thumbnail) => {
const latestVideo = videosRef.current?.find((v) => v.id === newVideo.id)
if (
thumbnail !== null &&
latestVideo &&
latestVideo.status !== 'queued' &&
latestVideo.status !== 'in_progress' &&
latestVideo.status !== 'failed'
) {
setVideo({
...latestVideo,
thumbnail
})
}
})
.catch((e) => {
logger.error('Failed to get thumbnail', e as Error)
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
})
}
break
}
case 'failed':
setVideo({
...storeVideo,
status: 'failed',
error: retrievedVideo.error,
metadata: retrievedVideo
})
}
})
}, [data, error, provider, providerId, retrieveThumbnail, setVideo, t])
return {
videos: videos ?? [],
getVideo,
addVideo,
updateVideo,
setVideos,
setVideo,
removeVideo: removeProviderVideo
}
}

View File

@@ -1,7 +0,0 @@
import { useProviderVideos } from './useProviderVideos'
export const useVideo = (providerId: string, id: string) => {
const { videos } = useProviderVideos(providerId)
const video = videos.find((v) => v.id === id)
return video
}

View File

@@ -1,94 +0,0 @@
import { loggerService } from '@logger'
import { retrieveVideoContent } from '@renderer/services/ApiService'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderById } from '@renderer/services/ProviderService'
import type { Video } from '@renderer/types'
import { useCallback } from 'react'
const logger = loggerService.withContext('useRetrieveThumbnail')
const pendingSet = new Set<string>()
export const useVideoThumbnail = () => {
const getThumbnailKey = useCallback((id: string) => {
return `video-thumbnail-${id}`
}, [])
const isRetrieving = useCallback(
(id: string) => {
const key = getThumbnailKey(id)
return pendingSet.has(key)
},
[getThumbnailKey]
)
const retrieveThumbnail = useCallback(
async (video: Video): Promise<string> => {
const provider = getProviderById(video.providerId)
if (!provider) {
throw new Error(`Provider not found for id ${video.providerId}`)
}
const thumbnailKey = getThumbnailKey(video.id)
if (isRetrieving(video.id)) {
throw new Error('Thumbnail retrieval already pending')
}
pendingSet.add(thumbnailKey)
try {
const cachedThumbnail = await ImageStorage.get(thumbnailKey)
if (cachedThumbnail) {
return cachedThumbnail
}
const result = await retrieveVideoContent({
type: 'openai',
provider,
videoId: video.id,
query: { variant: 'thumbnail' }
})
const { response } = result
if (!response.ok) {
throw new Error(`Unexpected thumbnail status: ${response.status}`)
}
const blob = await response.blob()
if (!blob || blob.size === 0) {
throw new Error('Thumbnail response body is empty')
}
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert thumbnail to base64'))
}
}
reader.onerror = () => reject(reader.error ?? new Error('Failed to read thumbnail blob'))
reader.readAsDataURL(blob)
})
await ImageStorage.set(thumbnailKey, base64)
return base64
} catch (e) {
logger.error(`Failed to get thumbnail for video ${video.id}`, e as Error)
throw e
} finally {
pendingSet.delete(thumbnailKey)
}
},
[getThumbnailKey]
)
const removeThumbnail = useCallback(
async (id: string) => {
const key = getThumbnailKey(id)
return ImageStorage.remove(key)
},
[getThumbnailKey]
)
return { getThumbnailKey, retrieveThumbnail, removeThumbnail, isRetrieving }
}

View File

@@ -1,48 +0,0 @@
import FileManager from '@renderer/services/FileManager'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { removeVideoAction } from '@renderer/store/video'
import { objectValues } from '@renderer/types'
import { useCallback } from 'react'
import { useVideoThumbnail } from './useVideoThumbnail'
export const useVideos = () => {
const videoMap = useAppSelector((state) => state.video.videoMap)
const dispatch = useAppDispatch()
const { removeThumbnail } = useVideoThumbnail()
const videos = objectValues(videoMap)
.flat()
.filter((v) => v !== undefined)
const getVideo = useCallback(
(videoId: string) => {
return videos.find((v) => v.id === videoId)
},
[videos]
)
const removeVideo = useCallback(
(videoId: string, providerId?: string) => {
const video = getVideo(videoId)
if (!video) {
return
}
if (!providerId) {
providerId = video.providerId
}
// should delete from redux state, and related thumbnail image, video file
if (video.thumbnail) {
removeThumbnail(videoId)
}
if (video.fileId) {
FileManager.deleteFile(video.fileId)
}
dispatch(removeVideoAction({ providerId, videoId }))
},
[dispatch, getVideo, removeThumbnail]
)
return { videos, getVideo, removeVideo }
}

View File

@@ -146,8 +146,7 @@ const titleKeyMap = {
notes: 'title.notes',
paintings: 'title.paintings',
settings: 'title.settings',
translate: 'title.translate',
video: 'title.video'
translate: 'title.translate'
} as const
export const getTitleLabel = (key: string): string => {
@@ -185,8 +184,7 @@ const sidebarIconKeyMap = {
knowledge: 'knowledge.title',
files: 'files.title',
code_tools: 'code.title',
notes: 'notes.title',
video: 'video.title'
notes: 'notes.title'
} as const
export const getSidebarIconLabel = (key: string): string => {

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "Are you sure you want to delete?",
"delete_failed": "Failed to delete",
"delete_success": "Deleted successfully",
"deleting": "Deleting...",
"description": "Description",
"detail": "Detail",
"disabled": "Disabled",
@@ -1158,12 +1157,10 @@
"prompt": "Prompt",
"provider": "Provider",
"reasoning_content": "Deep reasoning",
"redownload": "Redownload",
"refresh": "Refresh",
"regenerate": "Regenerate",
"rename": "Rename",
"reset": "Reset",
"retry": "Retry",
"save": "Save",
"saved": "Saved",
"search": "Search",
@@ -1171,7 +1168,6 @@
"selected": "Selected",
"selectedItems": "Selected {{count}} items",
"selectedMessages": "Selected {{count}} messages",
"send": "Send",
"settings": "Settings",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "Content",
"data": "Data",
"delete": {
"failed": "Failed to delete."
},
"detail": "Error Details",
"details": "Details",
"errors": "Errors",
@@ -1341,8 +1334,7 @@
"size": "Size",
"text": "Text",
"title": "Files",
"type": "Type",
"video": "Video"
"type": "Type"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "Heading 4",
"heading5": "Heading 5",
"heading6": "Heading 6",
"highlight": "Highlight",
"image": "Image",
"inlineMath": "Inline Equation",
"italic": "Italic",
@@ -4751,8 +4744,7 @@
"paintings": "Paintings",
"settings": "Settings",
"store": "Assistant Library",
"translate": "Translate",
"video": "Video"
"translate": "Translate"
},
"trace": {
"backList": "Back To List",
@@ -4910,56 +4902,6 @@
"saveDataError": "Failed to save data, please try again.",
"title": "Update"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "The video was not found remotely. It will only be deleted locally.",
"title": "Video not found."
}
}
},
"error": {
"create": "Failed to create video",
"download": "Failed to download video.",
"invalid": "Invalid video",
"load": {
"message": "Failed to load the video",
"reason": "The file may be corrupted or has been deleted externally."
}
},
"expired": "Expired",
"input_reference": {
"add": {
"error": {
"format": "Not a image",
"size": "This image is too large. It should be under 5MB."
},
"tooltip": "Add image reference"
}
},
"prompt": {
"placeholder": "describes the video to generate"
},
"seconds": "Seconds",
"size": "Size",
"status": {
"completed": "Generation Completed",
"downloading": "Downloading",
"failed": "Generation Failed",
"in_progress": "Generating",
"queued": "Queued"
},
"thumbnail": {
"error": {
"get": "Failed to get thumbnail"
},
"get": "Get thumbnail",
"placeholder": "No thumbnail"
},
"title": "Video",
"undefined": "No available video"
},
"warning": {
"missing_provider": "The supplier does not exist; reverted to the default supplier {{provider}}. This may cause issues."
},

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "确定要删除吗?",
"delete_failed": "删除失败",
"delete_success": "删除成功",
"deleting": "删除中...",
"description": "描述",
"detail": "详情",
"disabled": "已禁用",
@@ -1158,12 +1157,10 @@
"prompt": "提示词",
"provider": "提供商",
"reasoning_content": "已深度思考",
"redownload": "重新下载",
"refresh": "刷新",
"regenerate": "重新生成",
"rename": "重命名",
"reset": "重置",
"retry": "重试",
"save": "保存",
"saved": "已保存",
"search": "搜索",
@@ -1171,7 +1168,6 @@
"selected": "已选择",
"selectedItems": "已选择 {{count}} 项",
"selectedMessages": "选中 {{count}} 条消息",
"send": "发送",
"settings": "设置",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "内容",
"data": "数据",
"delete": {
"failed": "删除失败"
},
"detail": "错误详情",
"details": "详细信息",
"errors": "错误",
@@ -1341,8 +1334,7 @@
"size": "大小",
"text": "文本",
"title": "文件",
"type": "类型",
"video": "视频"
"type": "类型"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "四级标题",
"heading5": "五级标题",
"heading6": "六级标题",
"highlight": "高亮",
"image": "图片",
"inlineMath": "行内数学公式",
"italic": "斜体",
@@ -4751,8 +4744,7 @@
"paintings": "绘画",
"settings": "设置",
"store": "助手库",
"translate": "翻译",
"video": "视频"
"translate": "翻译"
},
"trace": {
"backList": "返回列表",
@@ -4910,56 +4902,6 @@
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "远程未找到该视频,仅会删除本地记录。",
"title": "视频未找到"
}
}
},
"error": {
"create": "创建视频失败",
"download": "视频下载失败",
"invalid": "无效的视频",
"load": {
"message": "加载视频失败",
"reason": "文件可能已损坏或已被外部删除。"
}
},
"expired": "已过期",
"input_reference": {
"add": {
"error": {
"format": "需要上传图片格式的文件",
"size": "图片过大,应小于 5MB"
},
"tooltip": "添加图像参考"
}
},
"prompt": {
"placeholder": "描述要生成的视频"
},
"seconds": "秒数",
"size": "尺寸",
"status": {
"completed": "生成完成",
"downloading": "下载中",
"failed": "生成失败",
"in_progress": "生成中",
"queued": "排队中"
},
"thumbnail": {
"error": {
"get": "获取缩略图失败"
},
"get": "获取缩略图",
"placeholder": "无缩略图"
},
"title": "视频",
"undefined": "无可用视频"
},
"warning": {
"missing_provider": "供应商不存在,已回退到默认供应商 {{provider}}。这可能导致问题。"
},

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "確定要刪除嗎?",
"delete_failed": "刪除失敗",
"delete_success": "刪除成功",
"deleting": "[to be translated]:Deleting...",
"description": "描述",
"detail": "詳情",
"disabled": "已停用",
@@ -1158,12 +1157,10 @@
"prompt": "提示詞",
"provider": "供應商",
"reasoning_content": "已深度思考",
"redownload": "[to be translated]:Redownload",
"refresh": "重新整理",
"regenerate": "重新生成",
"rename": "重新命名",
"reset": "重設",
"retry": "[to be translated]:Retry",
"save": "儲存",
"saved": "已儲存",
"search": "搜尋",
@@ -1171,7 +1168,6 @@
"selected": "已選擇",
"selectedItems": "已選擇 {{count}} 項",
"selectedMessages": "選中 {{count}} 條訊息",
"send": "[to be translated]:Send",
"settings": "設定",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "內容",
"data": "数据",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "錯誤詳情",
"details": "詳細信息",
"errors": "錯誤",
@@ -1341,8 +1334,7 @@
"size": "大小",
"text": "文字",
"title": "檔案",
"type": "類型",
"video": "[to be translated]:Video"
"type": "類型"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "四級標題",
"heading5": "五級標題",
"heading6": "六級標題",
"highlight": "突顯",
"image": "圖片",
"inlineMath": "行內數學公式",
"italic": "斜體",
@@ -4751,8 +4744,7 @@
"paintings": "繪畫",
"settings": "設定",
"store": "助手庫",
"translate": "翻譯",
"video": "[to be translated]:Video"
"translate": "翻譯"
},
"trace": {
"backList": "返回清單",
@@ -4910,56 +4902,6 @@
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "供應商不存在,已回退到預設供應商 {{provider}}。這可能導致問題。"
},

View File

@@ -2766,6 +2766,7 @@
"heading4": "Überschrift 4",
"heading5": "Überschrift 5",
"heading6": "Überschrift 6",
"highlight": "Hervorheben",
"image": "Bild",
"inlineMath": "Inline-Mathematikformel",
"italic": "Kursiv",

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
"delete_failed": "Αποτυχία διαγραφής",
"delete_success": "Η διαγραφή ήταν επιτυχής",
"deleting": "[to be translated]:Deleting...",
"description": "Περιγραφή",
"detail": "Λεπτομέρειες",
"disabled": "Απενεργοποιημένο",
@@ -1158,12 +1157,10 @@
"prompt": "Ενδεικτικός ρήματος",
"provider": "Παρέχων",
"reasoning_content": "Έχει σκεφτεί πολύ καλά",
"redownload": "[to be translated]:Redownload",
"refresh": "Ανανέωση",
"regenerate": "Ξαναπαραγωγή",
"rename": "Μετονομασία",
"reset": "Επαναφορά",
"retry": "[to be translated]:Retry",
"save": "Αποθήκευση",
"saved": "Αποθηκεύτηκε",
"search": "Αναζήτηση",
@@ -1171,7 +1168,6 @@
"selected": "Επιλεγμένο",
"selectedItems": "Επιλέχθηκαν {{count}} αντικείμενα",
"selectedMessages": "Επιλέχθηκαν {{count}} μηνύματα",
"send": "[to be translated]:Send",
"settings": "Ρυθμίσεις",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "Περιεχόμενο",
"data": "δεδομένα",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Λεπτομέρειες σφάλματος",
"details": "Λεπτομέρειες",
"errors": "Λάθος",
@@ -1341,8 +1334,7 @@
"size": "Μέγεθος",
"text": "Κείμενο",
"title": "Αρχεία",
"type": "Τύπος",
"video": "[to be translated]:Video"
"type": "Τύπος"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "επίπεδο 4 τίτλος",
"heading5": "Επίπεδο 5 τίτλος",
"heading6": "εξάβαθμος τίτλος",
"highlight": "Επισήμανση",
"image": "εικόνα",
"inlineMath": "εντός γραμμής μαθηματικοί τύποι",
"italic": "πλάγια",
@@ -4751,8 +4744,7 @@
"paintings": "Ζωγραφική",
"settings": "Ρυθμίσεις",
"store": "Βιβλιοθήκη βοηθών",
"translate": "Μετάφραση",
"video": "[to be translated]:Video"
"translate": "Μετάφραση"
},
"trace": {
"backList": "Επιστροφή στη λίστα",
@@ -4910,56 +4902,6 @@
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Ο προμηθευτής δεν υπάρχει, έγινε επαναφορά στον προεπιλεγμένο προμηθευτή {{provider}}. Αυτό μπορεί να προκαλέσει προβλήματα."
},

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
"delete_failed": "Error al eliminar",
"delete_success": "Eliminación exitosa",
"deleting": "[to be translated]:Deleting...",
"description": "Descripción",
"detail": "Detalles",
"disabled": "Desactivado",
@@ -1158,12 +1157,10 @@
"prompt": "Prompt",
"provider": "Proveedor",
"reasoning_content": "Pensamiento profundo",
"redownload": "[to be translated]:Redownload",
"refresh": "Actualizar",
"regenerate": "Regenerar",
"rename": "Renombrar",
"reset": "Restablecer",
"retry": "[to be translated]:Retry",
"save": "Guardar",
"saved": "Guardado",
"search": "Buscar",
@@ -1171,7 +1168,6 @@
"selected": "Seleccionado",
"selectedItems": "{{count}} elementos seleccionados",
"selectedMessages": "{{count}} mensajes seleccionados",
"send": "[to be translated]:Send",
"settings": "Configuración",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "contenido",
"data": "datos",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Detalles del error",
"details": "Detalles",
"errors": "error",
@@ -1341,8 +1334,7 @@
"size": "Tamaño",
"text": "Texto",
"title": "Archivo",
"type": "Tipo",
"video": "[to be translated]:Video"
"type": "Tipo"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "título de cuarto nivel",
"heading5": "encabezado de quinto nivel",
"heading6": "título de sexto nivel",
"highlight": "Destacar",
"image": "imagen",
"inlineMath": "fórmulas matemáticas en línea",
"italic": "cursiva",
@@ -4751,8 +4744,7 @@
"paintings": "Pinturas",
"settings": "Configuración",
"store": "Biblioteca de asistentes",
"translate": "Traducir",
"video": "[to be translated]:Video"
"translate": "Traducir"
},
"trace": {
"backList": "Volver a la lista",
@@ -4910,56 +4902,6 @@
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "El proveedor no existe, se ha revertido al proveedor predeterminado {{provider}}. Esto podría causar problemas."
},

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
"delete_failed": "Échec de la suppression",
"delete_success": "Suppression réussie",
"deleting": "[to be translated]:Deleting...",
"description": "Description",
"detail": "détails",
"disabled": "Désactivé",
@@ -1158,12 +1157,10 @@
"prompt": "Prompt",
"provider": "Fournisseur",
"reasoning_content": "Réflexion approfondie",
"redownload": "[to be translated]:Redownload",
"refresh": "Actualiser",
"regenerate": "Regénérer",
"rename": "Renommer",
"reset": "Réinitialiser",
"retry": "[to be translated]:Retry",
"save": "Enregistrer",
"saved": "enregistré",
"search": "Rechercher",
@@ -1171,7 +1168,6 @@
"selected": "Sélectionné",
"selectedItems": "{{count}} éléments sélectionnés",
"selectedMessages": "{{count}} messages sélectionnés",
"send": "[to be translated]:Send",
"settings": "Paramètres",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "suivre l'instruction du système",
"data": "données",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Détails de l'erreur",
"details": "Informations détaillées",
"errors": "erreur",
@@ -1341,8 +1334,7 @@
"size": "Taille",
"text": "Texte",
"title": "Fichier",
"type": "Type",
"video": "[to be translated]:Video"
"type": "Type"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "titre de niveau quatre",
"heading5": "Titre de niveau 5",
"heading6": "titre de niveau six",
"highlight": "Surligner",
"image": "image",
"inlineMath": "formule mathématique en ligne",
"italic": "italique",
@@ -4751,8 +4744,7 @@
"paintings": "Peintures",
"settings": "Paramètres",
"store": "Bibliothèque d'assistants",
"translate": "Traduire",
"video": "[to be translated]:Video"
"translate": "Traduire"
},
"trace": {
"backList": "Retour à la liste",
@@ -4910,56 +4902,6 @@
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Le fournisseur nexiste pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes."
},

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "削除してもよろしいですか?",
"delete_failed": "削除に失敗しました",
"delete_success": "削除に成功しました",
"deleting": "[to be translated]:Deleting...",
"description": "説明",
"detail": "詳細",
"disabled": "無効",
@@ -1158,12 +1157,10 @@
"prompt": "プロンプト",
"provider": "プロバイダー",
"reasoning_content": "深く考察済み",
"redownload": "[to be translated]:Redownload",
"refresh": "更新",
"regenerate": "再生成",
"rename": "名前を変更",
"reset": "リセット",
"retry": "[to be translated]:Retry",
"save": "保存",
"saved": "保存されました",
"search": "検索",
@@ -1171,7 +1168,6 @@
"selected": "選択済み",
"selectedItems": "{{count}}件の項目を選択しました",
"selectedMessages": "{{count}}件のメッセージを選択しました",
"send": "[to be translated]:Send",
"settings": "設定",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "内容",
"data": "データ",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "エラーの詳細",
"details": "詳細",
"errors": "エラー",
@@ -1341,8 +1334,7 @@
"size": "サイズ",
"text": "テキスト",
"title": "ファイル",
"type": "タイプ",
"video": "[to be translated]:Video"
"type": "タイプ"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "レベル4タイトル",
"heading5": "レベル5タイトル",
"heading6": "CET-6タイトル",
"highlight": "ハイライト",
"image": "写真",
"inlineMath": "業界の数式",
"italic": "イタリック",
@@ -4751,8 +4744,7 @@
"paintings": "ペインティング",
"settings": "設定",
"store": "アシスタントライブラリ",
"translate": "翻訳",
"video": "[to be translated]:Video"
"translate": "翻訳"
},
"trace": {
"backList": "リストに戻る",
@@ -4910,56 +4902,6 @@
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "サプライヤーが存在しないため、デフォルトのサプライヤー {{provider}} にロールバックされました。これにより問題が発生する可能性があります。"
},

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "Tem certeza de que deseja excluir?",
"delete_failed": "Falha ao excluir",
"delete_success": "Excluído com sucesso",
"deleting": "[to be translated]:Deleting...",
"description": "Descrição",
"detail": "detalhes",
"disabled": "Desativado",
@@ -1158,12 +1157,10 @@
"prompt": "Prompt",
"provider": "Fornecedor",
"reasoning_content": "Pensamento profundo concluído",
"redownload": "[to be translated]:Redownload",
"refresh": "Atualizar",
"regenerate": "Regenerar",
"rename": "Renomear",
"reset": "Redefinir",
"retry": "[to be translated]:Retry",
"save": "Salvar",
"saved": "Guardado",
"search": "Pesquisar",
@@ -1171,7 +1168,6 @@
"selected": "Selecionado",
"selectedItems": "{{count}} itens selecionados",
"selectedMessages": "{{count}} mensagens selecionadas",
"send": "[to be translated]:Send",
"settings": "Configurações",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "conteúdo",
"data": "dados",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Detalhes do erro",
"details": "Detalhes",
"errors": "erro",
@@ -1341,8 +1334,7 @@
"size": "Tamanho",
"text": "Texto",
"title": "Arquivo",
"type": "Tipo",
"video": "[to be translated]:Video"
"type": "Tipo"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "título de quarto nível",
"heading5": "Título de quinto nível",
"heading6": "título de nível seis",
"highlight": "Destacar",
"image": "imagem",
"inlineMath": "fórmulas matemáticas em linha",
"italic": "itálico",
@@ -4751,8 +4744,7 @@
"paintings": "Pinturas",
"settings": "Configurações",
"store": "Biblioteca de assistentes",
"translate": "Traduzir",
"video": "[to be translated]:Video"
"translate": "Traduzir"
},
"trace": {
"backList": "Voltar à lista",
@@ -4910,56 +4902,6 @@
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "O fornecedor não existe; foi revertido para o fornecedor predefinido {{provider}}. Isto pode causar problemas."
},

View File

@@ -1113,7 +1113,6 @@
"delete_confirm": "Вы уверены, что хотите удалить?",
"delete_failed": "Не удалось удалить",
"delete_success": "Удаление выполнено успешно",
"deleting": "[to be translated]:Deleting...",
"description": "Описание",
"detail": "Подробности",
"disabled": "Отключено",
@@ -1158,12 +1157,10 @@
"prompt": "Промпт",
"provider": "Провайдер",
"reasoning_content": "Глубокий анализ",
"redownload": "[to be translated]:Redownload",
"refresh": "Обновить",
"regenerate": "Пересоздать",
"rename": "Переименовать",
"reset": "Сбросить",
"retry": "[to be translated]:Retry",
"save": "Сохранить",
"saved": "Сохранено",
"search": "Поиск",
@@ -1171,7 +1168,6 @@
"selected": "Выбрано",
"selectedItems": "Выбрано {{count}} элементов",
"selectedMessages": "Выбрано {{count}} сообщений",
"send": "[to be translated]:Send",
"settings": "Настройки",
"sort": {
"pinyin": {
@@ -1231,9 +1227,6 @@
},
"content": "Содержание",
"data": "данные",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Детали ошибки",
"details": "Подробности",
"errors": "ошибка",
@@ -1341,8 +1334,7 @@
"size": "Размер",
"text": "Текст",
"title": "Файлы",
"type": "Тип",
"video": "[to be translated]:Video"
"type": "Тип"
},
"gpustack": {
"keep_alive_time": {
@@ -2774,6 +2766,7 @@
"heading4": "Название 4 уровня",
"heading5": "Название 5 -го уровня",
"heading6": "CET-6 название",
"highlight": "Выделить",
"image": "картина",
"inlineMath": "Математические формулы в отрасли",
"italic": "Курсив",
@@ -4751,8 +4744,7 @@
"paintings": "Рисунки",
"settings": "Настройки",
"store": "Библиотека помощников",
"translate": "Перевод",
"video": "[to be translated]:Video"
"translate": "Перевод"
},
"trace": {
"backList": "Вернуться к списку",
@@ -4910,56 +4902,6 @@
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Поставщик не существует, возвращение к поставщику по умолчанию {{provider}}. Это может привести к проблемам."
},

View File

@@ -22,8 +22,7 @@ import {
File as FileIcon,
FileImage,
FileText,
FileType as FileTypeIcon,
FileVideo
FileType as FileTypeIcon
} from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
@@ -147,7 +146,6 @@ const FilesPage: FC = () => {
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FileIcon size={16} /> },
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImage size={16} /> },
{ key: FileTypes.TEXT, label: t('files.text'), icon: <FileTypeIcon size={16} /> },
{ key: FileTypes.VIDEO, label: t('files.video'), icon: <FileVideo size={16} /> },
{ key: 'all', label: t('files.all'), icon: <FileText size={16} /> }
]

View File

@@ -1,7 +1,7 @@
import App from '@renderer/components/MinApp/MinApp'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useSettings } from '@renderer/hooks/useSettings'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle, Video } from 'lucide-react'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -62,12 +62,6 @@ const LaunchpadPage: FC = () => {
text: t('title.notes'),
path: '/notes',
bgColor: 'linear-gradient(135deg, #F97316, #FB923C)' // 笔记:橙色,代表活力和清晰思路
},
{
icon: <Video size={32} className="icon" />,
text: t('title.video'),
path: '/video',
bgColor: 'linear-gradient(135deg, #7C3AED, #A78BFA)' // Video Generation: deep purple, representing creativity and dynamic media
}
]

View File

@@ -15,7 +15,14 @@ import { menuItems } from './MenuConfig'
const logger = loggerService.withContext('HeaderNavbar')
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
const HeaderNavbar = ({
notesTree,
getCurrentNoteContent,
onToggleStar,
onExpandPath,
onRenameNode,
onClearActiveFile
}) => {
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
const { activeNode } = useActiveNode(notesTree)
const [breadcrumbItems, setBreadcrumbItems] = useState<
@@ -53,11 +60,14 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
const handleBreadcrumbClick = useCallback(
(item: { treePath: string; isFolder: boolean }) => {
if (item.isFolder && onExpandPath) {
if (item.treePath === '/' && onClearActiveFile) {
// Clicking root clears the active file and returns to tree view
onClearActiveFile()
} else if (item.isFolder && onExpandPath) {
onExpandPath(item.treePath)
}
},
[onExpandPath]
[onExpandPath, onClearActiveFile]
)
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -81,6 +81,7 @@ const NotesPage: FC = () => {
const isRenamingRef = useRef(false)
const isCreatingNoteRef = useRef(false)
const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null)
const noteHistoryRef = useRef<string[]>([]) // Track recently opened notes for smart navigation
const activeFilePathRef = useRef<string | undefined>(activeFilePath)
const currentContentRef = useRef(currentContent)
@@ -115,6 +116,31 @@ const NotesPage: FC = () => {
[dispatch, store]
)
// Find the previous valid note from history, excluding the current path
const findPreviousNote = useCallback(
(excludePath: string): string | undefined => {
const normalizedExclude = normalizePathValue(excludePath)
// Iterate through history in reverse order (most recent first)
for (let i = noteHistoryRef.current.length - 1; i >= 0; i--) {
const historicalPath = noteHistoryRef.current[i]
const normalizedHistorical = normalizePathValue(historicalPath)
// Skip if it's the excluded path or if it's inside a deleted folder
if (normalizedHistorical === normalizedExclude || normalizedHistorical.startsWith(`${normalizedExclude}/`)) {
continue
}
// Check if the note still exists in the tree
const node = findNodeByPath(notesTree, normalizedHistorical)
if (node && node.type === 'file') {
return historicalPath
}
}
return undefined
},
[notesTree]
)
const mergeTreeState = useCallback(
(nodes: NotesTreeNode[]): NotesTreeNode[] => {
return nodes.map((node) => {
@@ -215,6 +241,26 @@ const NotesPage: FC = () => {
useEffect(() => {
activeFilePathRef.current = activeFilePath
// Track note history for smart navigation
if (activeFilePath) {
const normalized = normalizePathValue(activeFilePath)
const history = noteHistoryRef.current
const existingIndex = history.findIndex((p) => normalizePathValue(p) === normalized)
// Remove if already exists (to move to end)
if (existingIndex !== -1) {
history.splice(existingIndex, 1)
}
// Add to end (most recent)
history.push(activeFilePath)
// Keep only last 20 notes
if (history.length > 20) {
history.shift()
}
}
}, [activeFilePath])
useEffect(() => {
@@ -538,6 +584,21 @@ const NotesPage: FC = () => {
const nodeToDelete = findNode(notesTree, nodeId)
if (!nodeToDelete) return
// Cancel any pending debounced saves before deleting files
// to prevent the deleted file from being recreated
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
const normalizedLastPath = lastFilePathRef.current ? normalizePathValue(lastFilePathRef.current) : undefined
if (nodeToDelete.type === 'file' && normalizedLastPath === normalizedDeletePath) {
debouncedSaveRef.current?.cancel()
lastFilePathRef.current = undefined
lastContentRef.current = ''
} else if (nodeToDelete.type === 'folder' && normalizedLastPath?.startsWith(`${normalizedDeletePath}/`)) {
debouncedSaveRef.current?.cancel()
lastFilePathRef.current = undefined
lastContentRef.current = ''
}
await delNode(nodeToDelete)
updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder'))
@@ -546,7 +607,6 @@ const NotesPage: FC = () => {
)
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
const isActiveNode = normalizedActivePath === normalizedDeletePath
const isActiveDescendant =
nodeToDelete.type === 'folder' &&
@@ -554,8 +614,17 @@ const NotesPage: FC = () => {
normalizedActivePath.startsWith(`${normalizedDeletePath}/`)
if (isActiveNode || isActiveDescendant) {
dispatch(setActiveFilePath(undefined))
editorRef.current?.clear()
// Try to find the previous note from history
const previousNote = findPreviousNote(nodeToDelete.externalPath)
if (previousNote) {
// Navigate to previous note
dispatch(setActiveFilePath(previousNote))
invalidateFileContent(previousNote)
} else {
// No previous note available, clear editor
dispatch(setActiveFilePath(undefined))
editorRef.current?.clear()
}
}
await refreshTree()
@@ -563,7 +632,16 @@ const NotesPage: FC = () => {
logger.error('Failed to delete node:', error as Error)
}
},
[notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths]
[
notesTree,
activeFilePath,
dispatch,
refreshTree,
updateStarredPaths,
updateExpandedPaths,
findPreviousNote,
invalidateFileContent
]
)
// 重命名节点
@@ -699,10 +777,24 @@ const NotesPage: FC = () => {
return
}
// Cancel any pending debounced saves before moving files
// to prevent the old path from being recreated
if (sourceNode.type === 'file') {
debouncedSaveRef.current?.cancel()
await window.api.file.move(sourceNode.externalPath, destinationPath)
// Update lastFilePathRef to prevent emergency save using old path
if (lastFilePathRef.current === sourceNode.externalPath) {
lastFilePathRef.current = destinationPath
}
} else {
// For folder moves, cancel saves for all affected files
debouncedSaveRef.current?.cancel()
await window.api.file.moveDir(sourceNode.externalPath, destinationPath)
// Update lastFilePathRef if it's inside the moved folder
if (lastFilePathRef.current && lastFilePathRef.current.startsWith(`${sourceNode.externalPath}/`)) {
const suffix = lastFilePathRef.current.slice(sourceNode.externalPath.length)
lastFilePathRef.current = `${destinationPath}${suffix}`
}
}
updateStarredPaths((prev) =>
@@ -714,29 +806,43 @@ const NotesPage: FC = () => {
return next
})
// First refresh the tree to ensure the new structure is loaded
await refreshTree()
// Then update active file path if needed
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
if (normalizedActivePath) {
let newActivePath: string | undefined
if (normalizedActivePath === sourceNode.externalPath) {
// Cancel debounced save to prevent saving to old path
debouncedSaveRef.current?.cancel()
lastFilePathRef.current = destinationPath
dispatch(setActiveFilePath(destinationPath))
newActivePath = destinationPath
} else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) {
const suffix = normalizedActivePath.slice(sourceNode.externalPath.length)
const newActivePath = `${destinationPath}${suffix}`
// Cancel debounced save to prevent saving to old path
debouncedSaveRef.current?.cancel()
lastFilePathRef.current = newActivePath
newActivePath = `${destinationPath}${suffix}`
}
if (newActivePath) {
// Update active file path and invalidate cache to trigger reload
dispatch(setActiveFilePath(newActivePath))
invalidateFileContent(newActivePath)
}
}
await refreshTree()
} catch (error) {
logger.error('Failed to move nodes:', error as Error)
}
},
[activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
[
activeFilePath,
dispatch,
notesPath,
notesTree,
refreshTree,
updateStarredPaths,
updateExpandedPaths,
invalidateFileContent
]
)
// 处理节点排序
@@ -785,6 +891,10 @@ const NotesPage: FC = () => {
[notesTree, updateExpandedPaths]
)
const handleClearActiveFile = useCallback(() => {
dispatch(setActiveFilePath(undefined))
}, [dispatch])
const getCurrentNoteContent = useCallback(() => {
if (settings.defaultEditMode === 'source') {
return currentContent
@@ -878,6 +988,7 @@ const NotesPage: FC = () => {
onToggleStar={handleToggleStar}
onExpandPath={handleExpandPath}
onRenameNode={handleRenameNode}
onClearActiveFile={handleClearActiveFile}
/>
<NotesEditor
activeNodeId={activeNode?.id}

View File

@@ -12,8 +12,7 @@ import {
MessageSquareQuote,
NotepadText,
Palette,
Sparkle,
Video
Sparkle
} from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
@@ -114,8 +113,7 @@ const SidebarIconsManager: FC<SidebarIconsManagerProps> = ({
knowledge: <FileSearch size={16} />,
files: <Folder size={16} />,
notes: <NotepadText size={16} />,
code_tools: <Code size={16} />,
video: <Video size={16} />
code_tools: <Code size={16} />
}) satisfies Record<SidebarIcon, ReactNode>,
[]
)

View File

@@ -1,35 +0,0 @@
import type { Video } from '@renderer/types'
import { PlusIcon } from 'lucide-react'
import { VideoListItem } from './VideoListItem'
export type VideoListProps = {
videos: Video[]
activeVideoId?: string
setActiveVideoId: (id: string | undefined) => void
onDelete: (id: string) => void
onGetThumbnail: (id: string) => void
}
export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete, onGetThumbnail }: VideoListProps) => {
return (
<div className="flex w-40 flex-col gap-1 space-y-3 overflow-auto p-2">
<div
className="group relative flex aspect-square cursor-pointer items-center justify-center rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg"
onClick={() => setActiveVideoId(undefined)}>
<PlusIcon size={24} />
</div>
{/* {mockVideos.map((video) => ( */}
{videos.map((video) => (
<VideoListItem
key={video.id}
video={video}
isActive={activeVideoId === video.id}
onClick={() => setActiveVideoId(video.id)}
onDelete={() => onDelete(video.id)}
onGetThhumbnail={() => onGetThumbnail(video.id)}
/>
))}
</div>
)
}

View File

@@ -1,158 +0,0 @@
import { cn, Progress, Spinner } from '@heroui/react'
import { DeleteIcon } from '@renderer/components/Icons'
import type { Video } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { CheckCircleIcon, CircleXIcon, ClockIcon, DownloadIcon, ImageDownIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export const VideoListItem = ({
video,
isActive,
onClick,
onDelete,
onGetThhumbnail
}: {
video: Video
isActive: boolean
onClick: () => void
onDelete: () => void
onGetThhumbnail: () => void
}) => {
const { t } = useTranslation()
const getStatusIcon = () => {
switch (video.status) {
case 'queued':
return <ClockIcon size={20} className="text-default-500" />
case 'in_progress':
return <Spinner size="sm" color="primary" />
case 'completed':
return <CheckCircleIcon size={20} className="text-success" />
case 'downloading':
return <DownloadIcon size={20} className="text-primary" />
case 'downloaded':
return null // No indicator for downloaded state
case 'failed':
return <CircleXIcon size={20} className="text-danger" />
default:
return null
}
}
const getStatusColor = () => {
switch (video.status) {
case 'queued':
return 'bg-default-100'
case 'in_progress':
return 'bg-primary-50'
case 'completed':
return 'bg-success-50'
case 'downloading':
return 'bg-primary-50'
case 'downloaded':
return 'bg-success-50'
case 'failed':
return 'bg-danger-50'
default:
return 'bg-default-50'
}
}
const getStatusLabel = () => {
switch (video.status) {
case 'queued':
return t('video.status.queued')
case 'in_progress':
return t('video.status.in_progress')
case 'completed':
return t('video.status.completed')
case 'downloading':
return t('video.status.downloading')
case 'downloaded':
return ''
case 'failed':
return t('video.status.failed')
default:
return ''
}
}
const showProgress = video.status === 'in_progress' || video.status === 'downloading'
const showThumbnail =
(video.status === 'completed' || video.status === 'downloading' || video.status === 'downloaded') &&
video.thumbnail !== null
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className={cn(
`group relative aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg ${getStatusColor()}`,
isActive ? 'border-primary' : undefined
)}
onClick={onClick}>
{/* Thumbnail placeholder */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-default-100 to-default-200">
{showThumbnail ? (
<img src={video.thumbnail ?? ''} alt="Video thumbnail" className="h-full w-full object-cover" />
) : (
<div className="flex flex-col items-center gap-2 text-default-400">
<div className="text-2xl">🎬</div>
</div>
)}
</div>
{/* Status overlay */}
<div className="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100" />
{/* Status indicator */}
{getStatusIcon() && (
<div className="absolute top-2 right-2 flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 backdrop-blur-sm">
{getStatusIcon()}
<span className="font-medium text-black text-xs">{getStatusLabel()}</span>
</div>
)}
{/* Progress bar for in_progress and downloading states */}
{showProgress && (
<div className="absolute right-0 bottom-0 left-0 p-2">
<Progress
aria-label="progress bar"
size="sm"
value={video.progress}
color={video.status === 'downloading' ? 'primary' : 'primary'}
className="w-full"
showValueLabel={false}
/>
</div>
)}
{/* Video info overlay */}
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/60 to-transparent p-3 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="text-white">
<p className="truncate font-medium text-sm">{video.metadata.id}</p>
{video.prompt && <p className="mt-1 line-clamp-2 text-xs opacity-80">{video.prompt}</p>}
</div>
</div>
{/* Failed state overlay */}
{video.status === 'failed' && (
<div className="absolute inset-0 flex items-center justify-center bg-danger/10"></div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{video.thumbnail === null && (
<ContextMenuItem onSelect={onGetThhumbnail}>
<ImageDownIcon />
<span>{t('video.thumbnail.get')}</span>
</ContextMenuItem>
)}
<ContextMenuItem onSelect={onDelete}>
<DeleteIcon className="text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@@ -1,166 +0,0 @@
// interface VideoPageProps {}
import { Divider } from '@heroui/react'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { usePending } from '@renderer/hooks/usePending'
import { useProvider } from '@renderer/hooks/useProvider'
import { useProviderVideos } from '@renderer/hooks/video/useProviderVideos'
import { useVideoThumbnail } from '@renderer/hooks/video/useVideoThumbnail'
import { deleteVideo } from '@renderer/services/ApiService'
import type { CreateVideoParams } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import { deepUpdate } from '@renderer/utils/deepUpdate'
import { isVideoModel } from '@renderer/utils/model/video'
import type { DeepPartial } from 'ai'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelSetting } from './settings/ModelSetting'
import { OpenAIParamSettings } from './settings/OpenAIParamSettings'
import { ProviderSetting } from './settings/ProviderSetting'
import { SettingsGroup } from './settings/shared'
import { VideoList } from './VideoList'
import { VideoPanel } from './VideoPanel'
export const VideoPage = () => {
const { t } = useTranslation()
const [providerId, setProviderId] = useState<string>(SystemProviderIds.openai)
const { provider } = useProvider(providerId)
const [params, setParams] = useState<CreateVideoParams>({
type: 'openai',
provider,
params: {
model: 'sora-2',
prompt: ''
},
options: {}
})
const { videos, removeVideo, getVideo, updateVideo } = useProviderVideos(providerId)
// const activeVideo = useMemo(() => mockVideos.find((v) => v.id === activeVideoId), [activeVideoId])
const [activeVideoId, setActiveVideoId] = useState<string>()
const activeVideo = useMemo(() => videos.find((v) => v.id === activeVideoId), [activeVideoId, videos])
const { setPending } = usePending()
const { removeThumbnail, retrieveThumbnail } = useVideoThumbnail()
const updateParams = useCallback((update: DeepPartial<Omit<CreateVideoParams, 'type'>>) => {
setParams((prev) => deepUpdate<CreateVideoParams>(prev, update))
}, [])
const updateModelId = useCallback(
(id: string) => {
if (isVideoModel(id)) {
updateParams({ params: { model: id } })
}
},
[updateParams]
)
const afterDeleteVideo = useCallback(
(id: string) => {
removeVideo(id)
removeThumbnail(id)
},
[removeThumbnail, removeVideo]
)
const handleDeleteVideo = useCallback(
async (id: string) => {
switch (provider.type) {
case 'openai-response':
try {
setPending(id, true)
const promise = deleteVideo({
type: 'openai',
videoId: id,
provider
})
window.toast.loading({
title: t('common.deleting'),
promise
})
const result = await promise
if (result.result.deleted) {
afterDeleteVideo(id)
} else {
window.toast.error(t('error.delete.failed'))
}
} catch (e) {
if (e instanceof Error && e.message.includes('404')) {
window.toast.warning({
title: t('video.delete.error.not_found.title'),
description: t('video.delete.error.not_found.description')
})
afterDeleteVideo(id)
} else {
window.toast.error({ title: t('error.delete.failed'), description: getErrorMessage(e) })
}
} finally {
setPending(id, undefined)
}
break
default:
throw new Error(`Provider type "${provider.type}" is not supported for video deletion`)
}
},
[afterDeleteVideo, provider, setPending, t]
)
const handleGetThumbnail = useCallback(
async (id: string) => {
const video = getVideo(id)
if (video && video.thumbnail === null) {
try {
const promise = retrieveThumbnail(video)
window.toast.loading({ title: t('video.thumbnail.get'), promise })
const thumbnail = await promise
if (thumbnail) {
updateVideo({ id: video.id, thumbnail })
}
} catch (e) {
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
}
}
},
[getVideo, retrieveThumbnail, t, updateVideo]
)
return (
<div className="flex flex-1 flex-col">
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('video.title')}</NavbarCenter>
</Navbar>
<div id="content-container" className="flex flex-1 overflow-hidden">
{/* Settings */}
<div className="flex w-70 flex-col p-2">
<SettingsGroup>
<ProviderSetting providerId={providerId} setProviderId={setProviderId} />
<ModelSetting
providerId={providerId}
modelId={params.params.model ?? 'sora-2'}
setModelId={updateModelId}
/>
</SettingsGroup>
{provider.type === 'openai-response' && <OpenAIParamSettings params={params} updateParams={updateParams} />}
</div>
<Divider orientation="vertical" />
<VideoPanel
provider={provider}
params={params}
updateParams={updateParams}
setActiveVideoId={setActiveVideoId}
video={activeVideo}
/>
<Divider orientation="vertical" />
{/* Video list */}
<VideoList
videos={videos}
activeVideoId={activeVideoId}
setActiveVideoId={setActiveVideoId}
onDelete={handleDeleteVideo}
onGetThumbnail={handleGetThumbnail}
/>
</div>
</div>
)
}

View File

@@ -1,303 +0,0 @@
import { Button, cn, Image, Skeleton, Textarea, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { usePending } from '@renderer/hooks/usePending'
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo'
import { useProviderVideos } from '@renderer/hooks/video/useProviderVideos'
import { createVideo, retrieveVideoContent } from '@renderer/services/ApiService'
import FileManager from '@renderer/services/FileManager'
import type { CreateVideoParams, Provider, Video, VideoFileMetadata } from '@renderer/types'
import { FileTypes } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import { MB } from '@shared/config/constant'
import type { DeepPartial } from 'ai'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
import mime from 'mime-types'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { VideoViewer } from './VideoViewer'
export type VideoPanelProps = {
provider: Provider
video?: Video
params: CreateVideoParams
updateParams: (upadte: DeepPartial<Omit<CreateVideoParams, 'type'>>) => void
setActiveVideoId: (id: string) => void
}
const logger = loggerService.withContext('VideoPanel')
export const VideoPanel = ({ provider, video, params, updateParams, setActiveVideoId }: VideoPanelProps) => {
const { t } = useTranslation()
const addOpenAIVideo = useAddOpenAIVideo(provider.id)
const { setVideo } = useProviderVideos(provider.id)
const { pendingMap, setPending: setPendingById } = usePending()
const [isProcessing, setIsProcessing] = useState<boolean | undefined>()
const fileInputRef = useRef<HTMLInputElement>(null)
const inputReference = params.params.input_reference
const couldCreateVideo = useMemo(
() =>
!isEmpty(params.params.prompt) &&
video?.status !== 'queued' &&
video?.status !== 'downloading' &&
video?.status !== 'in_progress' &&
(video === undefined || pendingMap[video.id] !== true),
[params.params.prompt, pendingMap, video]
)
useEffect(() => {
if (video) {
updateParams({ params: { prompt: video.prompt } })
} else {
updateParams({ params: { prompt: '' } })
}
}, [updateParams, video])
const isPending = (video ? pendingMap[video.id] : false) || isProcessing
const setPending = useCallback(
(value: boolean) => {
if (video) {
setPendingById(video.id, value ? value : undefined)
} else {
setIsProcessing(value)
}
},
[setPendingById, video]
)
const handleCreateVideo = useCallback(async () => {
if (!couldCreateVideo) return
setPending(true)
try {
if (video === undefined) {
const result = await createVideo(params)
const video = result.video
switch (result.type) {
case 'openai':
addOpenAIVideo(video, params.params.prompt)
break
default:
logger.error(`Invalid video type ${result.type}.`)
}
setActiveVideoId(result.video.id)
} else {
// TODO: remix video
window.toast.info('Remix video is not implemented.')
}
} catch (e) {
window.toast.error({ title: t('video.error.create'), description: getErrorMessage(e), timeout: 5000 })
} finally {
setPending(false)
}
}, [addOpenAIVideo, couldCreateVideo, params, setPending, t, video])
const handleRegenerateVideo = useCallback(() => {
window.toast.info('Not implemented')
}, [])
const handleDownloadVideo = useCallback(async () => {
if (!video) return
if (video.status !== 'completed' && video.status !== 'downloaded') return
const baseVideo: Video = {
...video,
status: 'downloading',
progress: 0,
thumbnail: video.thumbnail
}
setVideo(baseVideo)
try {
const { response } = await retrieveVideoContent({ type: 'openai', videoId: video.id, provider })
if (!response.body) {
throw new Error('Video response body is empty')
}
const reader = response.body.getReader()
const contentLengthHeader = response.headers.get('content-length')
const totalSize = contentLengthHeader ? Number(contentLengthHeader) : undefined
const chunks: Uint8Array[] = []
let receivedLength = 0
let progressValue = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
chunks.push(value)
receivedLength += value.length
if (totalSize && Number.isFinite(totalSize) && totalSize > 0) {
progressValue = Math.floor((receivedLength / totalSize) * 100)
} else {
progressValue = Math.min(progressValue + 1, 99)
}
setVideo({
...baseVideo,
progress: Math.min(progressValue, 99)
})
}
const fileData = new Uint8Array(receivedLength)
let offset = 0
for (const chunk of chunks) {
fileData.set(chunk, offset)
offset += chunk.length
}
const contentType = response.headers.get('content-type') ?? 'video/mp4'
const normalizedContentType = contentType.split(';')[0]?.trim() || 'video/mp4'
const extension = (() => {
const ext = mime.extension(normalizedContentType)
return ext ? `.${ext}` : '.mp4'
})()
const fileName = `${video.id}${extension}`.toLowerCase()
const tempFilePath = await window.api.file.createTempFile(fileName)
await window.api.file.write(tempFilePath, fileData)
const tempFileMetadata = {
id: crypto.randomUUID(),
name: fileName,
origin_name: fileName,
path: tempFilePath,
size: receivedLength,
ext: extension,
type: FileTypes.VIDEO,
created_at: dayjs().toISOString(),
count: 1
} satisfies VideoFileMetadata
const uploadedFile = await FileManager.uploadFile(tempFileMetadata)
setVideo({
...video,
status: 'downloaded',
thumbnail: video.thumbnail,
fileId: uploadedFile.id,
name: uploadedFile.origin_name
})
} catch (error) {
logger.error(`Failed to download video ${video.id}.`, error as Error)
window.toast.error(t('video.error.download'))
setVideo(video)
}
}, [provider, setVideo, t, video])
const handleUploadFile = useCallback(() => {
fileInputRef.current?.click()
}, [])
const setPrompt = useCallback((value: string) => updateParams({ params: { prompt: value } }), [updateParams])
const UploadImageReferenceButton = useCallback(() => {
const content = inputReference ? (
<div className="group">
<Image
className="aspect-square max-h-50 max-w-50 object-contain"
src={URL.createObjectURL(inputReference as File)}
/>
<Button
variant="light"
color="danger"
className="absolute top-1 right-1 z-100 h-6 w-6 min-w-0 opacity-0 group-hover:opacity-100"
isIconOnly
startContent={<CircleXIcon size={16} className="text-danger" />}
onPress={() => updateParams({ params: { input_reference: undefined } })}
/>
</div>
) : (
t('video.input_reference.add.tooltip')
)
return (
<>
<Tooltip content={content} closeDelay={0}>
<Button
variant="light"
startContent={<ImageIcon size={16} className={cn(inputReference ? 'text-primary' : undefined)} />}
isIconOnly
className="h-6 w-6 min-w-0"
isDisabled={isPending}
onPress={handleUploadFile}
/>
</Tooltip>
</>
)
}, [handleUploadFile, inputReference, isPending, t, updateParams])
return (
<div className="flex flex-1 flex-col overflow-hidden p-2">
<div className="m-8 flex-1 overflow-hidden">
<Skeleton className="h-full w-full rounded-2xl" classNames={{ content: 'h-full w-full' }} isLoaded={true}>
{video && <VideoViewer video={video} onDownload={handleDownloadVideo} onRegenerate={handleRegenerateVideo} />}
{!video && <VideoViewer video={video} />}
</Skeleton>
</div>
<div className="relative">
<Textarea
label={t('common.prompt')}
placeholder={t('video.prompt.placeholder')}
value={params.params.prompt}
onValueChange={setPrompt}
isClearable
isDisabled={isPending}
classNames={{ inputWrapper: 'pb-8' }}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleCreateVideo()
}
}}
/>
<div className="absolute bottom-0 flex w-full items-end justify-between p-2">
<div className="flex">
<UploadImageReferenceButton />
<input
ref={fileInputRef}
type="file"
hidden
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
if (!file.type.startsWith('image/')) {
window.toast.error(t('video.input_reference.add.error.format'))
return
}
const maxSize = 5 * MB
if (file.size > maxSize) {
window.toast.error(t('video.input_reference.add.error.size'))
return
}
updateParams({ params: { input_reference: file } })
} else {
updateParams({ params: { input_reference: undefined } })
}
}}
/>
</div>
<Tooltip content={t('common.send')} closeDelay={0}>
<Button
color="primary"
radius="full"
isIconOnly
isDisabled={!couldCreateVideo}
isLoading={isPending}
className="h-6 w-6 min-w-0"
onPress={handleCreateVideo}>
<ArrowUp size={16} className="text-primary-foreground" />
</Button>
</Tooltip>
</div>
</div>
</div>
)
}

View File

@@ -1,233 +0,0 @@
import {
Alert,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
Progress,
Skeleton,
Spinner,
useDisclosure
} from '@heroui/react'
import { usePending } from '@renderer/hooks/usePending'
import FileManager from '@renderer/services/FileManager'
import type { Video, VideoDownloaded, VideoFailed } from '@renderer/types'
import dayjs from 'dayjs'
import { CheckCircleIcon, CircleXIcon, Clock9Icon } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
export type VideoViewerProps =
| {
video: undefined
onDownload?: never
onRegenerate?: never
}
| {
video: Video
onDownload: () => void
onRegenerate: () => void
}
export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProps) => {
const { t } = useTranslation()
const [loadSuccess, setLoadSuccess] = useState<boolean | undefined>(undefined)
const { pendingMap } = usePending()
const isPending = video ? pendingMap[video.id] : false
useEffect(() => {
setLoadSuccess(undefined)
}, [video?.id])
return (
<>
<div className="flex h-full max-h-full w-full items-center justify-center rounded-2xl bg-foreground-200">
{video === undefined && t('video.undefined')}
{video && video.status === 'queued' && <QueuedVideo />}
{video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />}
{video && video.status === 'completed' && (
<CompletedVideo video={video} isDisabled={isPending} onDownload={onDownload} onRegenerate={onRegenerate} />
)}
{video && video.status === 'downloading' && <DownloadingVideo progress={video.progress} />}
{video && video.status === 'downloaded' && loadSuccess !== false && (
<VideoPlayer video={video} setLoadSuccess={setLoadSuccess} />
)}
{video && video.status === 'failed' && <FailedVideo error={video.error} />}
{video && video.status === 'downloaded' && loadSuccess === false && (
<LoadFailedVideo isDisabled={isPending} onRedownload={onDownload} />
)}
</div>
</>
)
}
const QueuedVideo = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Spinner variant="dots" />
<span>{t('video.status.queued')}</span>
</div>
)
}
const InProgressVideo = ({ progress }: { progress: number }) => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Progress
label={t('video.status.in_progress')}
aria-label={t('video.status.in_progress')}
className="max-w-md"
color="primary"
showValueLabel={true}
size="md"
value={progress}
/>
</div>
)
}
const CompletedVideo = ({
video,
isDisabled,
onDownload,
onRegenerate
}: {
video: Video
isDisabled?: boolean
onDownload: () => void
onRegenerate: () => void
}) => {
const { t } = useTranslation()
const isExpired = video.metadata.expires_at !== null && video.metadata.expires_at < dayjs().unix()
if (isExpired) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-2xl bg-warning-200">
<Clock9Icon size={64} className="text-warning" />
<span className="font-bold text-2xl">{t('video.expired')}</span>
<Button onPress={onRegenerate} isDisabled={isDisabled}>
{t('common.regenerate')}
</Button>
</div>
)
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-2xl bg-success-200">
<CheckCircleIcon size={64} className="text-success" />
<span className="font-bold text-2xl">{t('video.status.completed')}</span>
<Button onPress={onDownload} isDisabled={isDisabled}>
{t('common.download')}
</Button>
</div>
)
}
const DownloadingVideo = ({ progress }: { progress?: number }) => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Progress
label={t('video.status.downloading')}
aria-label={t('video.status.downloading')}
className="max-w-md"
color="primary"
showValueLabel={true}
size="md"
value={progress}
/>
</div>
)
}
const FailedVideo = ({ error }: { error: VideoFailed['error'] }) => {
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
const alert = useMemo(() => {
if (error === null) {
return <Alert color="danger" title={t('error.unknown')} />
} else {
return <Alert color="danger" title={error.code} description={error.message} />
}
}, [error, t])
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl bg-danger-200">
<CircleXIcon size={64} className="fill-danger text-danger-200" />
<span className="font-bold text-2xl">{t('video.status.failed')}</span>
<div className="my-2 flex justify-between gap-2">
<Button onPress={onOpen}>{t('common.detail')}</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalBody>
<ModalContent>
<div className="p-4">{alert}</div>
</ModalContent>
</ModalBody>
<ModalFooter></ModalFooter>
</Modal>
<Button onPress={() => window.toast.info('Not implemented')}>{t('common.retry')}</Button>
</div>
</div>
)
}
const LoadFailedVideo = ({ isDisabled, onRedownload }: { isDisabled?: boolean; onRedownload: () => void }) => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl bg-danger-200">
<CircleXIcon size={64} className="fill-danger text-danger-200" />
<span className="font-bold text-2xl">{t('video.error.load.message')}</span>
<span>{t('video.error.load.reason')}</span>
<div className="my-2 flex justify-between gap-2">
<Button onPress={onRedownload} isDisabled={isDisabled}>
{t('common.redownload')}
</Button>
</div>
</div>
)
}
const VideoPlayer = ({
video,
setLoadSuccess
}: {
video: VideoDownloaded
setLoadSuccess: (value: boolean) => void
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const fetcher = async () => {
const file = await FileManager.getFile(video.fileId)
if (!file) {
throw new Error(`Video file ${video.fileId} not exist.`)
}
return FileManager.getFilePath(file)
}
const { data: src, isLoading, error } = useSWRImmutable(`video/file/${video.id}`, fetcher)
useEffect(() => {
const videoElement = videoRef.current
if (videoElement) {
videoElement.load()
}
}, [video?.id])
if (error) {
setLoadSuccess(false)
}
if (isLoading) {
return <Skeleton />
}
return (
<video
ref={videoRef}
controls
className="h-full w-full rounded-2xl bg-content2 object-contain dark:bg-background"
onLoadedData={() => setLoadSuccess(true)}
onError={() => setLoadSuccess(false)}>
<source src={`file://${src}`} type="video/mp4" />
</video>
)
}

View File

@@ -1,44 +0,0 @@
import { Select, SelectItem } from '@heroui/react'
import { videoModelsMap } from '@renderer/config/models/video'
import type { Model } from '@renderer/types'
import { useTranslation } from 'react-i18next'
import { SettingItem } from './shared'
export interface ModelSettingProps {
providerId: string
modelId: string
setModelId: (id: string) => void
}
interface ModelSelectItem extends Model {
key: string
label: string
}
export const ModelSetting = ({ providerId, modelId, setModelId }: ModelSettingProps) => {
const { t } = useTranslation()
const items: ModelSelectItem[] = videoModelsMap[providerId]?.map((m: string) => ({ key: m, label: m })) ?? []
return (
<SettingItem>
<Select
label={t('common.model')}
labelPlacement="outside"
selectionMode="single"
items={items}
defaultSelectedKeys={[modelId]}
disallowEmptySelection
onSelectionChange={(keys) => {
if (keys.currentKey) setModelId(keys.currentKey)
}}>
{(model) => (
<SelectItem textValue={model.label}>
<span>{model.label}</span>
</SelectItem>
)}
</Select>
</SettingItem>
)
}

View File

@@ -1,80 +0,0 @@
import type { VideoSeconds, VideoSize } from '@cherrystudio/openai/resources'
import { Select, SelectItem } from '@heroui/react'
import type { OpenAICreateVideoParams } from '@renderer/types'
import type { DeepPartial } from 'ai'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingItem, SettingsGroup } from './shared'
export type OpenAIParamSettingsProps = {
params: OpenAICreateVideoParams
updateParams: (update: DeepPartial<Omit<OpenAICreateVideoParams, 'type'>>) => void
}
export const OpenAIParamSettings = ({ params, updateParams }: OpenAIParamSettingsProps) => {
const { t } = useTranslation()
const secondItems = [{ key: '4' }, { key: '8' }, { key: '12' }] as const satisfies { key: VideoSeconds }[]
const sizeItems = [
{ key: '720x1280' },
{ key: '1280x720' },
{ key: '1024x1792' },
{ key: '1792x1024' }
] as const satisfies { key: VideoSize }[]
const updateSeconds = useCallback(
(seconds: VideoSeconds) => {
updateParams({ params: { seconds } })
},
[updateParams]
)
const updateSize = useCallback(
(size: VideoSize) => {
updateParams({ params: { size } })
},
[updateParams]
)
return (
<SettingsGroup>
<SettingItem>
<Select
label={t('video.seconds')}
labelPlacement="outside"
selectedKeys={[params.params.seconds ?? '4']}
onSelectionChange={(keys) => {
if (keys.currentKey) updateSeconds(keys.currentKey as VideoSeconds)
}}
items={secondItems}
selectionMode="single"
disallowEmptySelection>
{(item) => (
<SelectItem key={item.key} textValue={item.key}>
<span>{item.key}</span>
</SelectItem>
)}
</Select>
</SettingItem>
<SettingItem>
<Select
label={t('video.size')}
labelPlacement="outside"
selectedKeys={[params.params.size ?? '720x1280']}
onSelectionChange={(keys) => {
if (keys.currentKey) updateSize(keys.currentKey as VideoSize)
}}
items={sizeItems}
selectionMode="single"
disallowEmptySelection>
{(item) => (
<SelectItem key={item.key} textValue={item.key}>
<span>{item.key}</span>
</SelectItem>
)}
</Select>
</SettingItem>
</SettingsGroup>
)
}

View File

@@ -1,60 +0,0 @@
import { Select, SelectItem } from '@heroui/react'
import { ProviderAvatar } from '@renderer/components/ProviderAvatar'
import { useProviders } from '@renderer/hooks/useProvider'
import type { Provider, SystemProviderId } from '@renderer/types'
import { getFancyProviderName } from '@renderer/utils'
import type { Dispatch, SetStateAction } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingItem } from './shared'
export interface ProviderSettingProps {
providerId: string
setProviderId: Dispatch<SetStateAction<string>>
}
interface ProviderSelectItem extends Provider {
key: string
label: string
}
export const ProviderSetting = ({ providerId, setProviderId }: ProviderSettingProps) => {
const { t } = useTranslation()
// Support limited providers.
const supportedProviderIds = ['openai'] satisfies SystemProviderId[]
const { providers } = useProviders()
const items: ProviderSelectItem[] = providers
.filter((p) => supportedProviderIds.some((id) => id === p.id))
.map((p) => ({ ...p, key: p.id, label: getFancyProviderName(p) }))
return (
<SettingItem>
<Select
label={t('common.provider')}
labelPlacement="outside"
selectionMode="single"
items={items}
defaultSelectedKeys={[providerId]}
disallowEmptySelection
onSelectionChange={(keys) => {
if (keys.currentKey) setProviderId(keys.currentKey)
}}
renderValue={(items) => {
const provider = items[0].data
if (!provider) return null
return (
<div className="flex items-center gap-2">
<ProviderAvatar provider={provider} size={16} />
<span>{provider.label}</span>
</div>
)
}}>
{(provider) => (
<SelectItem textValue={provider.label} startContent={<ProviderAvatar provider={provider} size={16} />}>
<span>{provider.label}</span>
</SelectItem>
)}
</Select>
</SettingItem>
)
}

View File

@@ -1,15 +0,0 @@
import { Divider } from '@heroui/react'
import type { PropsWithChildren } from 'react'
export const SettingsGroup = ({ children }: PropsWithChildren) => {
return <div className="mb-4 flex flex-col rounded-2xl border border-foreground-200 p-3">{children}</div>
}
export const SettingItem = ({ children, divider = false }: PropsWithChildren<{ divider?: boolean }>) => {
return (
<>
<div className="mb-2">{children}</div>
{divider && <Divider className="my-2" />}
</>
)
}

View File

@@ -10,22 +10,8 @@ import { buildStreamTextParams } from '@renderer/aiCore/prepareParams'
import { isDedicatedImageGenerationModel, isEmbeddingModel } from '@renderer/config/models'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import type {
Assistant,
CreateVideoParams,
CreateVideoResult,
DeleteVideoParams,
DeleteVideoResult,
FetchChatCompletionParams,
MCPServer,
MCPTool,
Model,
Provider,
RetrieveVideoContentParams,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types'
import type { FetchChatCompletionParams } from '@renderer/types'
import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { type Chunk, ChunkType } from '@renderer/types/chunk'
import type { Message } from '@renderer/types/newMessage'
@@ -413,26 +399,6 @@ export async function fetchGenerate({
}
}
export async function createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
const ai = new AiProviderNew(params.provider)
return ai.createVideo(params)
}
export async function retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
const ai = new AiProviderNew(params.provider)
return ai.retrieveVideo(params)
}
export async function retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
const ai = new AiProviderNew(params.provider)
return ai.retrieveVideoContent(params)
}
export async function deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
const ai = new AiProviderNew(params.provider)
return ai.deleteVideo(params)
}
export function hasApiKey(provider: Provider) {
if (!provider) return false
if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true

View File

@@ -35,7 +35,6 @@ import shortcuts from './shortcuts'
import tabs from './tabs'
import toolPermissions from './toolPermissions'
import translate from './translate'
import video from './video'
import websearch from './websearch'
const logger = loggerService.withContext('Store')
@@ -65,8 +64,7 @@ const rootReducer = combineReducers({
translate,
ocr,
note,
toolPermissions,
video
toolPermissions
})
const persistedReducer = persistReducer(

View File

@@ -2824,20 +2824,6 @@ const migrateConfig = {
logger.error('migrate 174 error', error as Error)
return state
}
},
'163': (state: RootState) => {
try {
if (state.settings && state.settings.sidebarIcons) {
if (!state.settings.sidebarIcons.visible.includes('video')) {
state.settings.sidebarIcons.visible = [...state.settings.sidebarIcons.visible, 'video']
}
}
state.video.videoMap = {}
return state
} catch (error) {
logger.error('migrate 161 error', error as Error)
return state
}
}
}

View File

@@ -192,14 +192,6 @@ const runtimeSlice = createSlice({
setSessionWaitingAction: (state, action: PayloadAction<{ id: string; value: boolean }>) => {
const { id, value } = action.payload
state.chat.sessionWaiting[id] = value
},
setPendingAction: (state, action: PayloadAction<{ id: string; value: boolean | undefined }>) => {
const { id, value } = action.payload
if (value) {
state.pendingMap[id] = value
} else {
delete state.pendingMap[id]
}
}
}
})

View File

@@ -1,86 +0,0 @@
import { loggerService } from '@logger'
import type { PayloadAction } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'
import type { Video } from '@renderer/types'
const logger = loggerService.withContext('Store:video')
export interface VideoState {
/** Provider ID to videos */
videoMap: Record<string, Video[] | undefined>
}
const initialState: VideoState = {
videoMap: {}
}
const videoSlice = createSlice({
name: 'video',
initialState,
reducers: {
addVideo: (state: VideoState, action: PayloadAction<{ providerId: string; video: Video }>) => {
const { providerId, video } = action.payload
if (state.videoMap[providerId]) {
state.videoMap[providerId].unshift(video)
} else {
state.videoMap[providerId] = [video]
}
},
removeVideo: (state: VideoState, action: PayloadAction<{ providerId: string; videoId: string }>) => {
const { providerId, videoId } = action.payload
const videos = state.videoMap[providerId]
state.videoMap[providerId] = videos?.filter((c) => c.id !== videoId)
},
updateVideo: (
state: VideoState,
action: PayloadAction<{ providerId: string; update: Partial<Omit<Video, 'status'>> & { id: string } }>
) => {
const { providerId, update } = action.payload
const videos = state.videoMap[providerId]
if (videos) {
let video = videos.find((v) => v.id === update.id)
if (video) {
switch (video.status) {
case 'queued':
case 'in_progress':
video = { ...video, ...update, thumbnail: undefined }
break
default:
video = { ...video, ...update }
}
} else {
logger.error(`Video with id ${update.id} not found in ${providerId}`)
}
} else {
logger.error(`Videos with Provider ${providerId} is undefined.`)
}
},
setVideo: (state: VideoState, action: PayloadAction<{ providerId: string; video: Video }>) => {
const { providerId, video } = action.payload
if (state.videoMap[providerId]) {
const index = state.videoMap[providerId].findIndex((v) => v.id === video.id)
if (index !== -1) {
state.videoMap[providerId][index] = video
} else {
state.videoMap[providerId].push(video)
}
} else {
state.videoMap[providerId] = [video]
}
},
setVideos: (state: VideoState, action: PayloadAction<{ providerId: string; videos: Video[] }>) => {
const { providerId, videos } = action.payload
state.videoMap[providerId] = videos
}
}
})
export const {
addVideo: addVideoAction,
removeVideo: removeVideoAction,
updateVideo: updateVideoAction,
setVideo: setVideoAction,
setVideos: setVideosAction
} = videoSlice.actions
export default videoSlice.reducer

View File

@@ -127,10 +127,6 @@ export type ImageFileMetadata = FileMetadata & {
type: FileTypes.IMAGE
}
export type VideoFileMetadata = FileMetadata & {
type: FileTypes.VIDEO
}
export type PdfFileMetadata = FileMetadata & {
ext: '.pdf'
}

View File

@@ -24,7 +24,6 @@ export * from './notification'
export * from './ocr'
export * from './plugin'
export * from './provider'
export * from './video'
export type Assistant = {
id: string
@@ -550,7 +549,6 @@ export const isAutoDetectionMethod = (method: string): method is AutoDetectionMe
// | 'files'
// | 'code_tools'
// | 'notes'
// | 'video'
export type ExternalToolResult = {
mcpTools?: MCPTool[]

View File

@@ -1,194 +0,0 @@
import type OpenAI from '@cherrystudio/openai'
import type { Provider } from './provider'
// Only OpenAI (Responses) is supported for now.
export type VideoEndpointType = 'openai'
export type VideoStatus = 'queued' | 'in_progress' | 'completed' | 'downloading' | 'downloaded' | 'failed'
interface VideoBase {
readonly id: string
readonly type: VideoEndpointType
readonly providerId: string
name: string
thumbnail?: string | null
fileId?: string
prompt: string
/**
* Represents the possible states of a video generation or download process.
*
* - `queued`: The video task has been submitted and is waiting to be processed.
* - `in_progress`: The video is currently being generated.
* - `completed`: The video has been successfully generated and is ready for download.
* - `downloading`: The video content is being downloaded to local storage.
* - `downloaded`: The video has been fully downloaded and is available locally.
* - `failed`: The video task encountered an error and could not be completed.
*/
readonly status: VideoStatus
}
interface OpenAIVideoBase {
readonly type: 'openai'
metadata: OpenAI.Videos.Video
}
export interface VideoQueued extends VideoBase {
readonly status: 'queued'
thumbnail?: never
}
export interface VideoInProgress extends VideoBase {
readonly status: 'in_progress'
/** integer percent */
progress: number
thumbnail?: never
}
export interface VideoCompleted extends VideoBase {
readonly status: 'completed'
/** Base64 image string. When generation completed, firstly try to retrieve thumbnail. */
thumbnail: string | null
}
export interface VideoDownloading extends VideoBase {
readonly status: 'downloading'
/** Base64 image string */
thumbnail: string | null
/** integer percent */
progress: number
}
export interface VideoDownloaded extends VideoBase {
readonly status: 'downloaded'
/** Base64 image string */
thumbnail: string | null
/** Managed by fileManager */
fileId: string
}
export interface VideoFailedBase extends VideoBase {
readonly status: 'failed'
error: unknown
}
export interface OpenAIVideoQueued extends VideoQueued, OpenAIVideoBase {}
export interface OpenAIVideoInProgress extends VideoInProgress, OpenAIVideoBase {}
export interface OpenAIVideoCompleted extends VideoCompleted, OpenAIVideoBase {}
export interface OpenAIVideoDownloading extends VideoDownloading, OpenAIVideoBase {}
export interface OpenAIVideoDownloaded extends VideoDownloaded, OpenAIVideoBase {}
export interface OpenAIVideoFailed extends VideoFailedBase, OpenAIVideoBase {
error: OpenAI.Videos.Video['error']
}
export type VideoFailed = OpenAIVideoFailed
export type OpenAIVideo =
| OpenAIVideoQueued
| OpenAIVideoInProgress
| OpenAIVideoCompleted
| OpenAIVideoDownloading
| OpenAIVideoDownloaded
| OpenAIVideoFailed
export type Video = OpenAIVideo
// Create Video
interface CreateVideoBaseParams {
type: VideoEndpointType
provider: Provider
}
export interface OpenAICreateVideoParams extends CreateVideoBaseParams {
type: 'openai'
params: OpenAI.VideoCreateParams
options?: OpenAI.RequestOptions
}
export type CreateVideoParams = OpenAICreateVideoParams
interface CreateVideoBaseResult {
type: VideoEndpointType
video: unknown
}
export interface OpenAICreateVideoResult extends CreateVideoBaseResult {
type: 'openai'
video: OpenAI.Videos.Video
}
export type CreateVideoResult = OpenAICreateVideoResult
// Retrieve Video
interface RetrieveVideoBaseParams {
type: VideoEndpointType
provider: Provider
}
export interface OpenAIRetrieveVideoParams extends RetrieveVideoBaseParams {
type: 'openai'
videoId: string
options?: OpenAI.RequestOptions
}
export type RetrieveVideoParams = OpenAIRetrieveVideoParams
interface RetrieveVideoBaseResult {
type: VideoEndpointType
}
export interface OpenAIRetrieveVideoResult extends RetrieveVideoBaseResult {
type: 'openai'
video: OpenAI.Videos.Video
}
export type RetrieveVideoResult = OpenAIRetrieveVideoResult
// Retrieve Video Content
interface RetrieveVideoContentBaseParams {
type: VideoEndpointType
provider: Provider
}
export interface OpenAIRetrieveVideoContentParams extends RetrieveVideoContentBaseParams {
type: 'openai'
videoId: string
query?: OpenAI.Videos.VideoDownloadContentParams
options?: OpenAI.RequestOptions
}
export type RetrieveVideoContentParams = OpenAIRetrieveVideoContentParams
interface RetrieveVideoContentBaseResult {
type: VideoEndpointType
}
export interface OpenAIRetrieveVideoContentResult extends RetrieveVideoContentBaseResult {
type: 'openai'
response: Response
}
export type RetrieveVideoContentResult = OpenAIRetrieveVideoContentResult
// Delete Video
export interface DeleteVideoBaseParams {
type: VideoEndpointType
provider: Provider
}
export interface OpenAIDeleteVideoParams extends DeleteVideoBaseParams {
type: 'openai'
videoId: string
options?: OpenAI.RequestOptions
}
export type DeleteVideoParams = OpenAIDeleteVideoParams
interface DeleteVideoBaseResult {
type: VideoEndpointType
result: unknown
}
export interface OpenAIDeleteVideoResult extends DeleteVideoBaseResult {
type: 'openai'
result: OpenAI.Videos.VideoDeleteResponse
}
export type DeleteVideoResult = OpenAIDeleteVideoResult

View File

@@ -1,554 +0,0 @@
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import { describe, expect, it } from 'vitest'
import { htmlToMarkdown, markdownToHtml } from '../markdownConverter'
/**
* Strip markdown line number attributes for testing HTML structure
*/
const LINE_NUMBER_REGEX = new RegExp(`\\s*${MARKDOWN_SOURCE_LINE_ATTR.replace(/-/g, '\\-')}="\\d+"`, 'g')
function stripLineNumbers(html: string): string {
return html.replace(LINE_NUMBER_REGEX, '')
}
describe('markdownConverter', () => {
describe('htmlToMarkdown', () => {
it('should convert HTML to Markdown', () => {
const html = '<h1>Hello World</h1>'
const result = htmlToMarkdown(html)
expect(result).toBe('# Hello World')
})
it('should keep <br> to <br>', () => {
const html = '<p>Text with<br>\nindentation<br>\nand without indentation</p>'
const result = htmlToMarkdown(html)
expect(result).toBe('Text with<br>indentation<br>and without indentation')
})
it('should convert task list HTML back to Markdown', () => {
const html =
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><input type="checkbox" disabled> abcd</li><li data-type="taskItem" class="task-list-item" data-checked="true"><input type="checkbox" checked disabled> efgh</li></ul>'
const result = htmlToMarkdown(html)
expect(result).toContain('- [ ] abcd')
expect(result).toContain('- [x] efgh')
})
it('should convert task list HTML back to Markdown with label', () => {
const html =
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox"> abcd</label></li><li data-type="taskItem" class="task-list-item" data-checked="true"><label><input type="checkbox" checked> efgh</label></li></ul>'
const result = htmlToMarkdown(html)
expect(result).toBe('- [ ] abcd\n\n- [x] efgh')
})
it('should handle empty HTML', () => {
const result = htmlToMarkdown('')
expect(result).toBe('')
})
it('should handle null/undefined input', () => {
expect(htmlToMarkdown(null as any)).toBe('')
expect(htmlToMarkdown(undefined as any)).toBe('')
})
it('should keep math block containers intact', () => {
const html = '<div data-latex="a+b+c" data-type="block-math"></div>'
const result = htmlToMarkdown(html)
expect(result).toBe('$$a+b+c$$')
})
it('should convert multiple math blocks to Markdown', () => {
const html =
'<div data-latex="\\begin{array}{c}\n\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &amp;\n= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} &amp; = 4 \\pi \\rho \\\\\n\n\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} &amp; = \\vec{\\mathbf{0}} \\\\\n\n\\nabla \\cdot \\vec{\\mathbf{B}} &amp; = 0\n\n\\end{array}" data-type="block-math"></div>'
const result = htmlToMarkdown(html)
expect(result).toBe(
'$$\\begin{array}{c}\n\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &\n= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\\n\n\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\\n\n\\nabla \\cdot \\vec{\\mathbf{B}} & = 0\n\n\\end{array}$$'
)
})
it('should convert math inline syntax to Markdown', () => {
const html = '<span data-latex="a+b+c" data-type="inline-math"></span>'
const result = htmlToMarkdown(html)
expect(result).toBe('$a+b+c$')
})
it('shoud convert multiple math blocks and inline math to Markdown', () => {
const html =
'<div data-latex="a+b+c" data-type="block-math"></div><p><span data-latex="d+e+f" data-type="inline-math"></span></p>'
const result = htmlToMarkdown(html)
expect(result).toBe('$$a+b+c$$\n\n$d+e+f$')
})
it('should convert heading and img to Markdown', () => {
const html = '<h1>Hello</h1>\n<p><img src="https://example.com/image.png" alt="alt text" /></p>\n'
const result = htmlToMarkdown(html)
expect(result).toBe('# Hello\n\n![alt text](https://example.com/image.png)')
})
it('should convert heading and paragraph to Markdown', () => {
const html = '<h1>Hello</h1>\n<p>Hello</p>\n'
const result = htmlToMarkdown(html)
expect(result).toBe('# Hello\n\nHello')
})
it('should convert code block to Markdown', () => {
const html = '<pre><code>console.log("Hello, world!");</code></pre>'
const result = htmlToMarkdown(html)
expect(result).toBe('```\nconsole.log("Hello, world!");\n```')
})
it('should convert code block with language to Markdown', () => {
const html = '<pre><code class="language-javascript">console.log("Hello, world!");</code></pre>'
const result = htmlToMarkdown(html)
expect(result).toBe('```javascript\nconsole.log("Hello, world!");\n```')
})
it('should convert table to Markdown', () => {
const html =
'<table><tbody><tr><th ><p>f</p></th><th ><p></p></th><th ><p></p></th></tr><tr><td ><p></p></td><td ><p>f</p></td><td ><p></p></td></tr><tr><td ><p></p></td><td ><p></p></td><td ><p>f</p></td></tr></tbody></table><p></p>'
const result = htmlToMarkdown(html)
expect(result).toBe('| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |')
})
})
describe('markdownToHtml', () => {
it('should convert <br> to <br>', () => {
const markdown = 'Text with<br>\nindentation<br>\nand without indentation'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Text with<br>\nindentation<br>\nand without indentation</p>\n')
})
it('should handle indentation in blockquotes', () => {
const markdown = '> Quote line 1\n> Quote line 2 with indentation'
const result = stripLineNumbers(markdownToHtml(markdown))
// This should preserve indentation within the blockquote
expect(result).toContain('Quote line 1')
expect(result).toContain('Quote line 2 with indentation')
})
it('should preserve indentation in nested lists', () => {
const markdown = '- Item 1\n - Nested item\n - Double nested\n with continued line'
const result = stripLineNumbers(markdownToHtml(markdown))
// Should create proper nested list structure
expect(result).toContain('<ul>')
expect(result).toContain('<li>')
})
it('should handle poetry or formatted text with indentation', () => {
const markdown = 'Roses are red\n Violets are blue\n Sugar is sweet\n And so are you'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Roses are red\nViolets are blue\nSugar is sweet\nAnd so are you</p>\n')
})
it('should preserve indentation after line breaks with multiple paragraphs', () => {
const markdown = 'First paragraph\n\n with indentation\n\n Second paragraph\n\nwith different indentation'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe(
'<p>First paragraph</p>\n<pre><code>with indentation\n\nSecond paragraph\n</code></pre><p>with different indentation</p>\n'
)
})
it('should handle zero-width indentation (just line break)', () => {
const markdown = 'Hello\n\nWorld'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Hello</p>\n<p>World</p>\n')
})
it('should preserve indentation in mixed content', () => {
const markdown =
'Normal text\n Indented continuation\n\n- List item\n List continuation\n\n> Quote\n> Indented quote'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe(
'<p>Normal text\nIndented continuation</p>\n<ul>\n<li>List item\nList continuation</li>\n</ul>\n<blockquote>\n<p>Quote\nIndented quote</p>\n</blockquote>\n'
)
})
it('should convert Markdown to HTML', () => {
const markdown = '# Hello World'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('<h1>Hello World</h1>')
})
it('should convert math block syntax to HTML', () => {
const markdown = '$$a+b+c$$'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('<div data-latex="a+b+c" data-type="block-math"></div>')
})
it('should convert math inline syntax to HTML', () => {
const markdown = '$a+b+c$'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('<span data-latex="a+b+c" data-type="inline-math"></span>')
})
it('should convert multiple math blocks to HTML', () => {
const markdown = `$$\\begin{array}{c}
\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &
= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\
\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\
\\nabla \\cdot \\vec{\\mathbf{B}} & = 0
\\end{array}$$`
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain(
'<div data-latex="\\begin{array}{c}\n\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &amp;\n= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} &amp; = 4 \\pi \\rho \\\\\n\n\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} &amp; = \\vec{\\mathbf{0}} \\\\\n\n\\nabla \\cdot \\vec{\\mathbf{B}} &amp; = 0\n\n\\end{array}" data-type="block-math"></div>'
)
})
it('should convert task list syntax to proper HTML', () => {
const markdown = '- [ ] abcd\n\n- [x] efgh\n\n'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('data-type="taskList"')
expect(result).toContain('data-type="taskItem"')
expect(result).toContain('data-checked="false"')
expect(result).toContain('data-checked="true"')
expect(result).toContain('<input type="checkbox" disabled>')
expect(result).toContain('<input type="checkbox" checked disabled>')
expect(result).toContain('abcd')
expect(result).toContain('efgh')
})
it('should convert mixed task list with checked and unchecked items', () => {
const markdown = '- [ ] First task\n\n- [x] Second task\n\n- [ ] Third task'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('data-type="taskList"')
expect(result).toContain('First task')
expect(result).toContain('Second task')
expect(result).toContain('Third task')
expect(result.match(/data-checked="false"/g)).toHaveLength(2)
expect(result.match(/data-checked="true"/g)).toHaveLength(1)
})
it('should NOT convert standalone task syntax to task list', () => {
const markdown = '[x] abcd'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('<p>[x] abcd</p>')
expect(result).not.toContain('data-type="taskList"')
})
it('should handle regular list items alongside task lists', () => {
const markdown = '- Regular item\n\n- [ ] Task item\n\n- Another regular item'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('data-type="taskList"')
expect(result).toContain('Regular item')
expect(result).toContain('Task item')
expect(result).toContain('Another regular item')
})
it('should handle empty Markdown', () => {
const result = markdownToHtml('')
expect(result).toBe('')
})
it('should handle null/undefined input', () => {
expect(markdownToHtml(null as any)).toBe('')
expect(markdownToHtml(undefined as any)).toBe('')
})
it('should handle heading and img', () => {
const markdown = `# 🌠 Screenshot
![](https://example.com/image.png)`
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<h1>🌠 Screenshot</h1>\n<p><img src="https://example.com/image.png" alt="" /></p>\n')
})
it('should handle heading and paragraph', () => {
const markdown = '# Hello\n\nHello'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<h1>Hello</h1>\n<p>Hello</p>\n')
})
it('should convert code block to HTML', () => {
const markdown = '```\nconsole.log("Hello, world!");\n```'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<pre><code>console.log(&#x22;Hello, world!&#x22;);\n</code></pre>')
})
it('should convert code block with language to HTML', () => {
const markdown = '```javascript\nconsole.log("Hello, world!");\n```'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe(
'<pre><code class="language-javascript">console.log(&#x22;Hello, world!&#x22;);\n</code></pre>'
)
})
it('should convert table to HTML', () => {
const markdown = '| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe(
'<table>\n<thead>\n<tr>\n<th>f</th>\n<th></th>\n<th></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td></td>\n<td>f</td>\n<td></td>\n</tr>\n<tr>\n<td></td>\n<td></td>\n<td>f</td>\n</tr>\n</tbody>\n</table>\n'
)
})
it('should escape XML-like tags in code blocks', () => {
const markdown = '```jsx\nconst component = <><div>content</div></>\n```'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe(
'<pre><code class="language-jsx">const component = &#x3C;&#x3E;&#x3C;div&#x3E;content&#x3C;/div&#x3E;&#x3C;/&#x3E;\n</code></pre>'
)
})
it('should escape XML-like tags in inline code', () => {
const markdown = 'Use `<>` for fragments'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Use <code>&#x3C;&#x3E;</code> for fragments</p>\n')
})
it('shoud convert XML-like tags in paragraph', () => {
const markdown = '<abc></abc>'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p><abc></abc></p>\n')
})
})
describe('Task List with Labels', () => {
it('should wrap task items with labels when label option is true', () => {
const markdown = '- [ ] abcd\n\n- [x] efgh'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe(
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<p><label><input type="checkbox" disabled> abcd</label></p>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="true">\n<p><label><input type="checkbox" checked disabled> efgh</label></p>\n</li>\n</ul>\n'
)
})
})
describe('Task List Round Trip', () => {
it('should maintain task list structure through markdown → html → markdown conversion', () => {
const originalMarkdown = '- [ ] abcd\n\n- [x] efgh'
const html = markdownToHtml(originalMarkdown)
const backToMarkdown = htmlToMarkdown(html)
expect(backToMarkdown).toBe(originalMarkdown)
})
it('should maintain task list structure through html → markdown → html conversion', () => {
const originalHtml =
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox" disabled></label><div><p></p></div></li></ul>'
const markdown = htmlToMarkdown(originalHtml)
const html = stripLineNumbers(markdownToHtml(markdown))
expect(html).toBe(
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox" disabled></label><div><p></p></div></li>\n</ul>\n'
)
})
it('should maintain task list structure through html → markdown → html conversion2', () => {
const originalHtml =
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<label><input type="checkbox" disabled></label><div><p>123</p></div>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<label><input type="checkbox" disabled></label><div><p></p></div>\n</li>\n</ul>\n'
const markdown = htmlToMarkdown(originalHtml)
const html = stripLineNumbers(markdownToHtml(markdown))
expect(html).toBe(originalHtml)
})
it('should handle complex task lists with multiple items', () => {
const originalMarkdown =
'- [ ] First unchecked task\n\n- [x] First checked task\n\n- [ ] Second unchecked task\n\n- [x] Second checked task'
const html = markdownToHtml(originalMarkdown)
const backToMarkdown = htmlToMarkdown(html)
expect(backToMarkdown).toBe(originalMarkdown)
})
})
describe('LaTeX Escaping in Tables', () => {
it('should test simple inline math with backslashes', () => {
const html = '<span data-latex="\\int_{-\\infty}^{\\infty}" data-type="inline-math"></span>'
const result = htmlToMarkdown(html)
expect(result).toBe('$\\int_{-\\infty}^{\\infty}$')
})
it('should test inline math within table structure', () => {
const tableHtml =
'<table><thead><tr><th>Formula</th><th>Description</th></tr></thead><tbody><tr><td><span data-latex="\\int_{-\\infty}^{\\infty} e^{-x&sup2;} dx = \\sqrt{\\pi}" data-type="inline-math"></span></td><td>Gaussian integral</td></tr></tbody></table>'
const result = htmlToMarkdown(tableHtml)
expect(result).toContain('$\\int_{-\\infty}^{\\infty} e^{-x²} dx = \\sqrt{\\pi}$')
})
it('should preserve LaTeX backslashes in table cells during round trip conversion', () => {
const tableWithLatex =
'| Formula | Description |\n| --- | --- |\n| $\\int_{-\\infty}^{\\infty} e^{-x²} dx = \\sqrt{\\pi}$ | Gaussian integral |'
const html = markdownToHtml(tableWithLatex)
const backToMarkdown = htmlToMarkdown(html)
// The LaTeX formula should preserve its backslashes
expect(backToMarkdown).toContain('$\\int_{-\\infty}^{\\infty} e^{-x²} dx = \\sqrt{\\pi}$')
expect(backToMarkdown).not.toContain('$\\\\int_{-\\\\infty}^{\\\\infty} e^{-x²} dx = \\\\sqrt{\\\\pi}$')
})
it('should handle LaTeX in table cells without double escaping', () => {
const markdown =
'| Math | Result |\n| --- | --- |\n| $E = mc^2$ | Energy-mass equivalence |\n| $\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$ | Sum formula |'
const html = markdownToHtml(markdown)
const result = htmlToMarkdown(html)
expect(result).toContain('$E = mc^2$')
expect(result).toContain('$\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$')
expect(result).not.toContain('$\\\\sum_{i=1}^{n} i = \\\\frac{n(n+1)}{2}$')
})
})
describe('markdown image', () => {
it('should convert markdown image to HTML img tag', () => {
const markdown = '![foo](train.jpg)'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p><img src="train.jpg" alt="foo" /></p>\n')
})
it('should convert markdown image with file:// protocol to HTML img tag', () => {
const markdown =
'![pasted_image_45285c9c-a7cd-4c3d-a9b6-6854c3bbe479.png](file:///Users/xxxx/Library/Application Support/CherryStudioDev/Data/Files/45285c9c-a7cd-4c3d-a9b6-6854c3bbe479.png)'
const result = markdownToHtml(markdown)
expect(result).toContain(
'<img src="file:///Users/xxxx/Library/Application Support/CherryStudioDev/Data/Files/45285c9c-a7cd-4c3d-a9b6-6854c3bbe479.png" alt="pasted_image_45285c9c-a7cd-4c3d-a9b6-6854c3bbe479.png" />'
)
})
it('should handle file:// protocol images differently from http images', () => {
const markdown =
'Local: ![Local image](file:///path/to/local.png)\\n\\nRemote: ![Remote image](https://example.com/remote.png)'
const result = markdownToHtml(markdown)
// file:// should be converted to HTML img tag
expect(result).toContain('<img src="file:///path/to/local.png" alt="Local image" />')
// https:// should be processed by markdown-it normally
expect(result).toContain('<img src="https://example.com/remote.png" alt="Remote image" />')
})
it('should handle images with spaces in file:// protocol paths', () => {
const markdown = '![My Image](file:///path/to/my image with spaces.png)'
const result = htmlToMarkdown(markdownToHtml(markdown))
expect(result).toBe(markdown)
})
it('shoud img label to markdown', () => {
const html = '<img src="file:///path/to/my image with spaces.png" alt="My Image" />'
const result = htmlToMarkdown(html)
expect(result).toBe('![My Image](file:///path/to/my image with spaces.png)')
})
})
it('should handle hardbreak with backslash followed by indented text', () => {
const markdown = 'Text with \\\n indentation \\\nand without indentation'
const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Text with <br />\nindentation <br />\nand without indentation</p>\n')
})
describe('Custom XML Tags Preservation', () => {
it('should preserve custom XML tags through markdown-to-HTML and HTML-to-markdown conversion', () => {
const markdown = 'Some text with <custom-tag>content</custom-tag> and more text'
const html = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(html)
expect(html).toContain('Some text with <custom-tag>content</custom-tag> and more text')
expect(backToMarkdown).toBe('Some text with <custom-tag>content</custom-tag> and more text')
})
it('should preserve single custom XML tags', () => {
const markdown = '<abc>'
const html = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(html)
expect(html).toBe('<p><abc></p>')
expect(backToMarkdown).toBe('<abc>')
})
it('should preserve single custom XML tags in html', () => {
const html = '<p><abc></p>'
const markdown = htmlToMarkdown(html)
const backToHtml = markdownToHtml(markdown)
expect(markdown).toBe('<abc>')
expect(backToHtml).toBe('<p><abc></p>')
})
it('should preserve custom XML tags mixed with regular markdown', () => {
const markdown = '# Heading\n\n<custom-widget id="widget1">Widget content</custom-widget>\n\n**Bold text**'
const html = stripLineNumbers(markdownToHtml(markdown))
const backToMarkdown = htmlToMarkdown(html)
expect(html).toContain('<h1>Heading</h1>')
expect(html).toContain('<custom-widget id="widget1">Widget content</custom-widget>')
expect(html).toContain('<strong>Bold text</strong>')
expect(backToMarkdown).toContain('# Heading')
expect(backToMarkdown).toContain('<custom-widget id="widget1">Widget content</custom-widget>')
expect(backToMarkdown).toContain('**Bold text**')
})
})
describe('Typing behavior issues', () => {
it('should not add unwanted line breaks during simple text typing', () => {
const html = '<p>Hello world</p>'
const markdown = htmlToMarkdown(html)
const backToHtml = stripLineNumbers(markdownToHtml(markdown))
expect(markdown).toBe('Hello world')
expect(backToHtml).toBe('<p>Hello world</p>\n')
})
it('should preserve simple paragraph structure during round-trip conversion', () => {
const originalHtml = '<p>This is a simple paragraph being typed</p>'
const markdown = htmlToMarkdown(originalHtml)
const backToHtml = stripLineNumbers(markdownToHtml(markdown))
expect(markdown).toBe('This is a simple paragraph being typed')
expect(backToHtml).toBe('<p>This is a simple paragraph being typed</p>\n')
})
})
describe('should keep YAML front matter', () => {
it('should keep YAML front matter', () => {
const markdown = `---
tags:
- 你好
aliases:
- "1111"
- "222"
- "333"
- "3333"
cssclasses:
- fffff
- ssss
- s12
---`
const result = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(result)
expect(backToMarkdown).toBe(markdown)
})
})
describe('should keep []', () => {
it('should keep [[foo]]', () => {
const markdown = `[[foo]]`
const result = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(result)
expect(backToMarkdown).toBe(markdown)
})
it('should keep []', () => {
const markdown = `[foo]`
const result = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(result)
expect(backToMarkdown).toBe(markdown)
})
})
describe('should have markdown line number injected in HTML', () => {
it('should inject line numbers into paragraphs', () => {
const markdown = 'First paragraph\n\nSecond paragraph\n\nThird paragraph'
const result = markdownToHtml(markdown)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="1">First paragraph</p>`)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="3">Second paragraph</p>`)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="5">Third paragraph</p>`)
})
it('should inject line numbers into mixed content', () => {
const markdown = 'Text\n\n- List\n\n> Quote'
const result = markdownToHtml(markdown)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="1">Text</p>`)
expect(result).toContain(`<ul ${MARKDOWN_SOURCE_LINE_ATTR}="3">`)
expect(result).toContain(`<li ${MARKDOWN_SOURCE_LINE_ATTR}="3">List</li>`)
expect(result).toContain(`<blockquote ${MARKDOWN_SOURCE_LINE_ATTR}="5">`)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="5">Quote</p>`)
})
})
})

View File

@@ -1,35 +0,0 @@
import type { DeepPartial } from 'ai'
import { cloneDeep } from 'lodash'
/**
* Deeply updates an object, allowing undefined to overwrite existing properties, without using `any`
* @param target Original object
* @param update Update object (may contain undefined)
* @returns New object
*/
export function deepUpdate<T extends object>(target: T, update: DeepPartial<T>): T {
const result = cloneDeep(target)
for (const key in update) {
if (Object.hasOwn(update, key)) {
// @ts-ignore it's runtime safe
const prev = result[key]
const next = update[key]
if (
next &&
typeof next === 'object' &&
!Array.isArray(next) &&
prev &&
typeof prev === 'object' &&
!Array.isArray(prev)
) {
// @ts-ignore it's runtime safe
result[key] = deepUpdate(prev, next as any)
} else {
// @ts-ignore it's runtime safe
result[key] = next
}
}
}
return result
}

View File

@@ -1,82 +1,10 @@
import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import type { TurndownPlugin } from '@truto/turndown-plugin-gfm'
import he from 'he'
import htmlTags, { type HtmlTags } from 'html-tags'
import * as htmlparser2 from 'htmlparser2'
import MarkdownIt from 'markdown-it'
import striptags from 'striptags'
import TurndownService from 'turndown'
const logger = loggerService.withContext('markdownConverter')
function escapeCustomTags(html: string) {
let result = ''
let currentPos = 0
const processedPositions = new Set<number>()
const parser = new htmlparser2.Parser({
onopentagname(tagname) {
const startPos = parser.startIndex
const endPos = parser.endIndex
// Add content before this tag
result += html.slice(currentPos, startPos)
if (!htmlTags.includes(tagname as HtmlTags)) {
// This is a custom tag, escape it
const tagHtml = html.slice(startPos, endPos + 1)
result += tagHtml.replace(/</g, '&lt;').replace(/>/g, '&gt;')
} else {
// This is a standard HTML tag, keep it as-is
result += html.slice(startPos, endPos + 1)
}
currentPos = endPos + 1
},
onclosetag(tagname) {
const startPos = parser.startIndex
const endPos = parser.endIndex
// Skip if we've already processed this position (handles malformed HTML)
if (processedPositions.has(endPos) || endPos + 1 <= currentPos) {
return
}
processedPositions.add(endPos)
// Get the actual HTML content at this position to verify what tag it really is
const actualTagHtml = html.slice(startPos, endPos + 1)
const actualTagMatch = actualTagHtml.match(/<\/([^>]+)>/)
const actualTagName = actualTagMatch ? actualTagMatch[1] : tagname
if (!htmlTags.includes(actualTagName as HtmlTags)) {
// This is a custom tag, escape it
result += html.slice(currentPos, startPos)
result += actualTagHtml.replace(/</g, '&lt;').replace(/>/g, '&gt;')
currentPos = endPos + 1
} else {
// This is a standard HTML tag, add content up to and including the closing tag
result += html.slice(currentPos, endPos + 1)
currentPos = endPos + 1
}
},
onend() {
result += html.slice(currentPos)
}
})
parser.write(html)
parser.end()
return result
}
export interface TaskListOptions {
label?: boolean
}
// Create markdown-it instance with task list plugin
const md = new MarkdownIt({
html: true, // Enable HTML tags in source
@@ -129,391 +57,6 @@ defaultBlockRules.forEach((ruleName) => {
}
})
// Override the code_block and code_inline renderers to properly escape HTML entities
md.renderer.rules.code_block = function (tokens, idx) {
const token = tokens[idx]
const langName = token.info ? ` class="language-${token.info.trim()}"` : ''
const escapedContent = he.encode(token.content, { useNamedReferences: false })
let html = `<pre><code${langName}>${escapedContent}</code></pre>`
html = injectLineNumber(token, html)
return html
}
md.renderer.rules.code_inline = function (tokens, idx) {
const token = tokens[idx]
const escapedContent = he.encode(token.content, { useNamedReferences: false })
return `<code>${escapedContent}</code>`
}
md.renderer.rules.fence = function (tokens, idx) {
const token = tokens[idx]
const langName = token.info ? ` class="language-${token.info.trim()}"` : ''
const escapedContent = he.encode(token.content, { useNamedReferences: false })
let html = `<pre><code${langName}>${escapedContent}</code></pre>`
html = injectLineNumber(token, html)
return html
}
// Custom task list plugin for markdown-it
function taskListPlugin(md: MarkdownIt, options: TaskListOptions = {}) {
const { label = false } = options
md.core.ruler.after('inline', 'task_list', (state) => {
const tokens = state.tokens
let inside_task_list = false
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (token.type === 'bullet_list_open') {
// Check if this list contains task items
let hasTaskItems = false
for (let j = i + 1; j < tokens.length && tokens[j].type !== 'bullet_list_close'; j++) {
if (tokens[j].type === 'inline' && /^\s*\[[ x]\](\s|$)/.test(tokens[j].content)) {
hasTaskItems = true
break
}
}
if (hasTaskItems) {
inside_task_list = true
token.attrSet('data-type', 'taskList')
token.attrSet('class', 'task-list')
}
} else if (token.type === 'bullet_list_close' && inside_task_list) {
inside_task_list = false
} else if (token.type === 'list_item_open' && inside_task_list) {
token.attrSet('data-type', 'taskItem')
token.attrSet('class', 'task-list-item')
} else if (token.type === 'inline' && inside_task_list) {
const match = token.content.match(/^(\s*)\[([x ])\](\s+(.*))?$/)
if (match) {
const [, , check, , content] = match
const isChecked = check.toLowerCase() === 'x'
// Find the parent list item token
for (let j = i - 1; j >= 0; j--) {
if (tokens[j].type === 'list_item_open') {
tokens[j].attrSet('data-checked', isChecked.toString())
break
}
}
// Find the parent paragraph token and replace it entirely
let paragraphTokenIndex = -1
for (let k = i - 1; k >= 0; k--) {
if (tokens[k].type === 'paragraph_open') {
paragraphTokenIndex = k
break
}
}
// Check if this came from HTML with <div><p> structure
// Empty content typically indicates it came from <div><p></p></div> structure
const shouldUseDivFormat = token.content === '' || state.src.includes('<!-- div-format -->')
if (paragraphTokenIndex >= 0 && label && shouldUseDivFormat) {
// Replace the entire paragraph structure with raw HTML for div format
const htmlToken = new state.Token('html_inline', '', 0)
if (content) {
htmlToken.content = `<label><input type="checkbox"${isChecked ? ' checked' : ''} disabled></label><div><p>${content}</p></div>`
} else {
htmlToken.content = `<label><input type="checkbox"${isChecked ? ' checked' : ''} disabled></label><div><p></p></div>`
}
// Remove the paragraph tokens and replace with our HTML token
tokens.splice(paragraphTokenIndex, 3, htmlToken) // Remove paragraph_open, inline, paragraph_close
i = paragraphTokenIndex // Adjust index after splice
} else {
// Use the standard label format
token.content = content || ''
const checkboxToken = new state.Token('html_inline', '', 0)
if (label) {
if (content) {
checkboxToken.content = `<label><input type="checkbox"${isChecked ? ' checked' : ''} disabled> ${content}</label>`
} else {
checkboxToken.content = `<label><input type="checkbox"${isChecked ? ' checked' : ''} disabled></label>`
}
token.children = [checkboxToken]
} else {
checkboxToken.content = `<input type="checkbox"${isChecked ? ' checked' : ''} disabled>`
if (content) {
const textToken = new state.Token('text', '', 0)
textToken.content = ' ' + content
token.children = [checkboxToken, textToken]
} else {
token.children = [checkboxToken]
}
}
}
}
}
}
})
}
interface TokenLike {
content: string
block?: boolean
map?: [number, number]
}
interface BlockStateLike {
src: string
bMarks: number[]
eMarks: number[]
tShift: number[]
line: number
parentType: string
blkIndent: number
push: (type: string, tag: string, nesting: number) => TokenLike
}
interface InlineStateLike {
src: string
pos: number
posMax: number
push: (type: string, tag: string, nesting: number) => TokenLike & { content?: string }
}
function yamlFrontMatterPlugin(md: MarkdownIt) {
// Parser: recognize YAML front matter
md.block.ruler.before(
'table',
'yaml_front_matter',
(stateLike: unknown, startLine: number, endLine: number, silent: boolean): boolean => {
const state = stateLike as BlockStateLike
// Only check at the very beginning of the document
if (startLine !== 0) {
return false
}
const startPos = state.bMarks[startLine] + state.tShift[startLine]
const maxPos = state.eMarks[startLine]
// Must begin with --- at document start
if (startPos + 3 > maxPos) return false
if (
state.src.charCodeAt(startPos) !== 0x2d /* - */ ||
state.src.charCodeAt(startPos + 1) !== 0x2d /* - */ ||
state.src.charCodeAt(startPos + 2) !== 0x2d /* - */
) {
return false
}
// If requested only to validate existence
if (silent) return true
// Search for closing ---
let nextLine = startLine + 1
let found = false
for (nextLine = startLine + 1; nextLine < endLine; nextLine++) {
const lineStart = state.bMarks[nextLine] + state.tShift[nextLine]
const lineEnd = state.eMarks[nextLine]
const line = state.src.slice(lineStart, lineEnd).trim()
if (line === '---') {
found = true
break
}
}
if (!found) {
return false
}
// Extract YAML content between the --- delimiters, preserving original indentation
const yamlLines: string[] = []
for (let lineIdx = startLine + 1; lineIdx < nextLine; lineIdx++) {
// Use the original line markers without shift to preserve indentation
const lineStart = state.bMarks[lineIdx]
const lineEnd = state.eMarks[lineIdx]
yamlLines.push(state.src.slice(lineStart, lineEnd))
}
// Also capture the closing --- line with its indentation
const closingLineStart = state.bMarks[nextLine]
const closingLineEnd = state.eMarks[nextLine]
const closingLine = state.src.slice(closingLineStart, closingLineEnd)
const yamlContent = yamlLines.join('\n') + '\n' + closingLine
const token = state.push('yaml_front_matter', 'div', 0)
token.block = true
token.map = [startLine, nextLine + 1]
token.content = yamlContent
state.line = nextLine + 1
return true
}
)
// Renderer: output YAML front matter as special HTML element
md.renderer.rules.yaml_front_matter = (tokens: Array<{ content?: string }>, idx: number): string => {
const token = tokens[idx]
const content = token?.content ?? ''
let html = `<div data-type="yaml-front-matter" data-content="${he.encode(content)}">${content}</div>`
html = injectLineNumber(token, html)
return html
}
}
function tipTapKatexPlugin(md: MarkdownIt) {
// 1) Parser: recognize $$ ... $$ as a block math token
md.block.ruler.before(
'fence',
'math_block',
(stateLike: unknown, startLine: number, endLine: number, silent: boolean): boolean => {
const state = stateLike as BlockStateLike
const startPos = state.bMarks[startLine] + state.tShift[startLine]
const maxPos = state.eMarks[startLine]
// Must begin with $$ at line start (after indentation)
if (startPos + 2 > maxPos) return false
if (state.src.charCodeAt(startPos) !== 0x24 /* $ */ || state.src.charCodeAt(startPos + 1) !== 0x24 /* $ */) {
return false
}
// If requested only to validate existence
if (silent) return true
// Search for closing $$
let nextLine = startLine
let content = ''
// Same-line closing? $$ ... $$
const sameLineClose = state.src.indexOf('$$', startPos + 2)
if (sameLineClose !== -1 && sameLineClose <= maxPos - 2) {
content = state.src.slice(startPos + 2, sameLineClose).trim()
nextLine = startLine
} else {
// Multiline: look for closing $$ anywhere
for (nextLine = startLine + 1; nextLine < endLine; nextLine++) {
const lineStart = state.bMarks[nextLine] + state.tShift[nextLine]
const lineEnd = state.eMarks[nextLine]
const line = state.src.slice(lineStart, lineEnd)
// Check if this line contains closing $$
const closingPos = line.indexOf('$$')
if (closingPos !== -1) {
// Found closing $$; extract content between opening and closing
const allLines: string[] = []
// First line: content after opening $$
const firstLineStart = state.bMarks[startLine] + state.tShift[startLine] + 2
const firstLineEnd = state.eMarks[startLine]
const firstLineContent = state.src.slice(firstLineStart, firstLineEnd)
if (firstLineContent.trim()) {
allLines.push(firstLineContent)
}
// Middle lines: full content
for (let lineIdx = startLine + 1; lineIdx < nextLine; lineIdx++) {
const midLineStart = state.bMarks[lineIdx] + state.tShift[lineIdx]
const midLineEnd = state.eMarks[lineIdx]
allLines.push(state.src.slice(midLineStart, midLineEnd))
}
// Last line: content before closing $$
const lastLineContent = line.slice(0, closingPos)
if (lastLineContent.trim()) {
allLines.push(lastLineContent)
}
content = allLines.join('\n').trim()
break
}
// Check if line starts with $$ (alternative closing pattern)
if (
lineStart + 2 <= lineEnd &&
state.src.charCodeAt(lineStart) === 0x24 &&
state.src.charCodeAt(lineStart + 1) === 0x24
) {
// Extract content between start and this line
const firstContentLineStart = state.bMarks[startLine] + state.tShift[startLine] + 2
const lastContentLineEnd = state.bMarks[nextLine]
content = state.src.slice(firstContentLineStart, lastContentLineEnd).trim()
break
}
}
if (nextLine >= endLine) {
// No closing fence -> not a valid block
return false
}
}
const token = state.push('math_block', 'div', 0)
token.block = true
token.map = [startLine, nextLine]
token.content = content
state.line = nextLine + 1
return true
}
)
// 2) Renderer: output TipTap-friendly container
md.renderer.rules.math_block = (tokens: Array<{ content?: string }>, idx: number): string => {
const token = tokens[idx]
const content = token?.content ?? ''
const latexEscaped = he.encode(content, { useNamedReferences: true })
let html = `<div data-latex="${latexEscaped}" data-type="block-math"></div>`
html = injectLineNumber(token, html)
return html
}
// 3) Inline parser: recognize $...$ on a single line as inline math
md.inline.ruler.before('emphasis', 'math_inline', (stateLike: unknown, silent: boolean): boolean => {
const state = stateLike as InlineStateLike
const start = state.pos
// Need starting $
if (start >= state.posMax || state.src.charCodeAt(start) !== 0x24 /* $ */) {
return false
}
// Find the next $ after start+1
const close = state.src.indexOf('$', start + 1)
if (close === -1 || close > state.posMax) {
return false
}
const content = state.src.slice(start + 1, close)
// Inline variant must not contain a newline
if (content.indexOf('\n') !== -1) {
return false
}
if (!silent) {
const token = state.push('math_inline', 'span', 0)
token.content = content.trim()
}
state.pos = close + 1
return true
})
// 4) Inline renderer: output TipTap-friendly inline container
md.renderer.rules.math_inline = (tokens: Array<{ content?: string }>, idx: number): string => {
const content = tokens[idx]?.content ?? ''
const latexEscaped = he.encode(content, { useNamedReferences: true })
return `<span data-latex="${latexEscaped}" data-type="inline-math"></span>`
}
}
md.use(yamlFrontMatterPlugin)
md.use(taskListPlugin, {
label: true
})
md.use(tipTapKatexPlugin)
// Initialize turndown service
const turndownService = new TurndownService({
headingStyle: 'atx', // Use # for headings
@@ -522,395 +65,9 @@ const turndownService = new TurndownService({
codeBlockStyle: 'fenced', // Use ``` for code blocks
fence: '```', // Use ``` for code blocks
emDelimiter: '*', // Use * for emphasis
strongDelimiter: '**', // Use ** for strong
blankReplacement: (_content, node) => {
const el = node as any as HTMLElement
if (el.nodeName === 'DIV' && el.getAttribute?.('data-type') === 'block-math') {
const latex = el.getAttribute?.('data-latex') || ''
const decodedLatex = he.decode(latex, {
isAttributeValue: false,
strict: false
})
return `$$${decodedLatex}$$\n\n`
}
if (el.nodeName === 'SPAN' && el.getAttribute?.('data-type') === 'inline-math') {
const latex = el.getAttribute?.('data-latex') || ''
const decodedLatex = he.decode(latex, {
isAttributeValue: false,
strict: false
})
return `$${decodedLatex}$`
}
// Handle paragraphs containing only math spans
if (el.nodeName === 'P' && el.querySelector?.('[data-type="inline-math"]')) {
const mathSpans = el.querySelectorAll('[data-type="inline-math"]')
if (mathSpans.length === 1 && el.children.length === 1) {
const span = mathSpans[0]
const latex = span.getAttribute('data-latex') || ''
const decodedLatex = he.decode(latex, {
isAttributeValue: false,
strict: false
})
return `$${decodedLatex}$`
}
}
return (node as any).isBlock ? '\n\n' : ''
}
strongDelimiter: '**' // Use ** for strong
})
turndownService.addRule('strikethrough', {
filter: ['del', 's'],
replacement: (content) => `~~${content}~~`
})
turndownService.addRule('underline', {
filter: ['u'],
replacement: (content) => `<u>${content}</u>`
})
// Custom rule to preserve <br> tags as literal text
turndownService.addRule('br', {
filter: 'br',
replacement: () => '<br>'
})
// Custom rule to preserve YAML front matter
turndownService.addRule('yamlFrontMatter', {
filter: (node: Element) => {
return node.nodeName === 'DIV' && node.getAttribute?.('data-type') === 'yaml-front-matter'
},
replacement: (_content: string, node: Node) => {
const element = node as Element
const yamlContent = element.getAttribute?.('data-content') || ''
const decodedContent = he.decode(yamlContent, {
isAttributeValue: false,
strict: false
})
// The decodedContent already includes the complete YAML with closing ---
// We just need to add the opening --- if it's not there
if (decodedContent.startsWith('---')) {
return decodedContent
} else {
return `---\n${decodedContent}`
}
}
})
// Helper function to safely get text content and clean it with LaTeX support
function cleanCellContent(content: string, cellElement?: Element): string {
// First check for math elements in the cell
if (cellElement) {
const blockMath = cellElement.querySelector('[data-type="block-math"]')
if (blockMath) {
const latex = blockMath.getAttribute('data-latex') || ''
const decodedLatex = he.decode(latex, { isAttributeValue: false, strict: false })
return `$$${decodedLatex}$$`
}
const inlineMath = cellElement.querySelector('[data-type="inline-math"]')
if (inlineMath) {
const latex = inlineMath.getAttribute('data-latex') || ''
const decodedLatex = he.decode(latex, { isAttributeValue: false, strict: false })
return `$${decodedLatex}$`
}
}
if (!content) return ' ' // Default empty cell content
// Clean and normalize content
let cleaned = content
.trim()
.replace(/\s+/g, ' ') // Normalize whitespace
.replace(/\|/g, '\\|') // Escape pipes
.replace(/\n+/g, ' ') // Convert newlines to spaces
.replace(/\r+/g, ' ') // Convert carriage returns to spaces
// If content is still empty or only whitespace, provide default
if (!cleaned || cleaned.match(/^\s*$/)) {
return ' '
}
// Ensure minimum width for table readability
if (cleaned.length < 3) {
cleaned += ' '.repeat(3 - cleaned.length)
}
return cleaned
}
// Enhanced cell replacement with LaTeX support
function cellWithLatex(content: string, node: Element, index?: number | null): string {
if (index === null && node && node.parentNode) {
index = Array.prototype.indexOf.call(node.parentNode.childNodes, node)
}
if (index === null) index = 0
let prefix = ' '
if (index === 0) prefix = '| '
const cellContent = cleanCellContent(content, node)
// Handle colspan by adding extra empty cells
let colspan = 1
if (node && node.getAttribute) {
colspan = parseInt(node.getAttribute('colspan') || '1', 10)
if (isNaN(colspan) || colspan < 1) colspan = 1
}
let result = prefix + cellContent + ' |'
// Add empty cells for colspan
for (let i = 1; i < colspan; i++) {
result += ' |'
}
return result
}
const customTablesPlugin: TurndownPlugin = (turndownService) => {
turndownService.addRule('tableCell', {
filter: ['th', 'td'],
replacement: function (content: string, node: Element) {
return cellWithLatex(content, node, null)
}
})
turndownService.addRule('tableRow', {
filter: 'tr',
replacement: function (content: string, node: Element) {
// Skip empty rows
if (!content || !content.trim()) return ''
let borderCells = ''
// Add separator row for heading (simplified version)
const parentNode = node.parentNode
if (parentNode && parentNode.nodeName === 'THEAD') {
const table = node.closest('table')
if (table) {
// Count cells in this row
const cellNodes = Array.from(node.querySelectorAll('th, td'))
const colCount = cellNodes.length
if (colCount > 0) {
for (let i = 0; i < colCount; i++) {
const prefix = i === 0 ? '| ' : ' '
borderCells += prefix + '---' + ' |'
}
}
}
}
return '\n' + content + (borderCells ? '\n' + borderCells : '')
}
})
turndownService.addRule('table', {
filter: 'table',
replacement: function (content: string) {
// Clean up content (remove extra newlines)
content = content.replace(/\n+/g, '\n').trim()
// If no content after cleaning, return empty
if (!content) return ''
// Split into lines and filter out empty lines
const lines = content.split('\n').filter((line) => line.trim())
if (lines.length === 0) return ''
// Check if we need to add a header row
const hasHeaderSeparator = lines.length >= 2 && /\|\s*-+/.test(lines[1])
let result = lines.join('\n')
// If no header separator exists, add a simple one
if (!hasHeaderSeparator && lines.length >= 1) {
const firstLine = lines[0]
const colCount = (firstLine.match(/\|/g) || []).length - 1
if (colCount > 0) {
let separator = '|'
for (let i = 0; i < colCount; i++) {
separator += ' --- |'
}
// Insert separator after first line
const resultLines = [lines[0], separator, ...lines.slice(1)]
result = resultLines.join('\n')
}
}
return '\n\n' + result + '\n\n'
}
})
// Remove table sections but keep content
turndownService.addRule('tableSection', {
filter: ['thead', 'tbody', 'tfoot'],
replacement: function (content: string) {
return content
}
})
}
const taskListItemsPlugin: TurndownPlugin = (turndownService) => {
turndownService.addRule('taskListItems', {
filter: (node: Element) => {
return node.nodeName === 'LI' && node.getAttribute && node.getAttribute('data-type') === 'taskItem'
},
replacement: (_content: string, node: Element) => {
const checkbox = node.querySelector('input[type="checkbox"]') as HTMLInputElement | null
const isChecked = checkbox?.checked || node.getAttribute('data-checked') === 'true'
// Check if this task item uses the div format
const hasDiv = node.querySelector('div p') !== null
const divContent = node.querySelector('div p')?.textContent?.trim() || ''
let textContent = ''
if (hasDiv) {
textContent = divContent
// Add a marker to indicate this came from div format
const marker = '<!-- div-format -->'
return '- ' + (isChecked ? '[x]' : '[ ]') + ' ' + textContent + ' ' + marker + '\n\n'
} else {
textContent = node.textContent?.trim() || ''
return '- ' + (isChecked ? '[x]' : '[ ]') + ' ' + textContent + '\n\n'
}
}
})
turndownService.addRule('taskList', {
filter: (node: Element) => {
return node.nodeName === 'UL' && node.getAttribute && node.getAttribute('data-type') === 'taskList'
},
replacement: (content: string) => {
return content
}
})
}
turndownService.use([customTablesPlugin, taskListItemsPlugin])
/**
* Converts HTML content to Markdown
* @param html - HTML string to convert
* @returns Markdown string
*/
export const htmlToMarkdown = (html: string | null | undefined): string => {
if (!html || typeof html !== 'string') {
return ''
}
try {
const encodedHtml = escapeCustomTags(html)
const turndownResult = turndownService.turndown(encodedHtml)
let finalResult = he.decode(turndownResult)
// Post-process to unescape square brackets that are not part of Markdown link syntax
// This preserves wiki-style double brackets [[foo]] and single brackets [foo]
// but keeps proper Markdown links [text](url) intact
// Use a more sophisticated approach: check for the link pattern first,
// then unescape standalone brackets
// First, protect actual Markdown links by temporarily replacing them
const linkPlaceholders: string[] = []
let linkCounter = 0
// Find and replace all Markdown links with placeholders
finalResult = finalResult.replace(/\\\[([^\]]*)\\\]\([^)]*\)/g, (match) => {
const placeholder = `__MDLINK_${linkCounter++}__`
linkPlaceholders[linkCounter - 1] = match
return placeholder
})
// Now unescape all remaining square brackets
finalResult = finalResult.replace(/\\\[/g, '[').replace(/\\\]/g, ']')
// Restore the Markdown links
for (let i = 0; i < linkPlaceholders.length; i++) {
const placeholder = `__MDLINK_${i}__`
finalResult = finalResult.replace(placeholder, linkPlaceholders[i])
}
return finalResult
} catch (error) {
logger.error('Error converting HTML to Markdown:', error as Error)
return ''
}
}
/**
* Converts Markdown content to HTML
* @param markdown - Markdown string to convert
* @param options - Task list options
* @returns HTML string
*/
export const markdownToHtml = (markdown: string | null | undefined): string => {
if (!markdown || typeof markdown !== 'string') {
return ''
}
try {
// First, convert any standalone markdown images to HTML img tags
// This handles cases where markdown images should be rendered as HTML instead of going through markdown-it
const processedMarkdown = markdown.replace(
/!\[([^\]]*)\]\(([^)]+?)(?:\s+"([^"]*)")?\)/g,
(match, alt, src, title) => {
// Only convert file:// protocol images to HTML img tags
if (src.startsWith('file://')) {
const altText = alt || ''
const srcUrl = src.trim()
const titleAttr = title ? ` title="${title}"` : ''
return `<img src="${srcUrl}" alt="${altText}"${titleAttr} />`
}
return match
}
)
let html = md.render(processedMarkdown)
const trimmedMarkdown = processedMarkdown.trim()
if (html.trim() === trimmedMarkdown) {
const singleTagMatch = trimmedMarkdown.match(/^<([a-zA-Z][^>\s]*)\/?>$/)
if (singleTagMatch) {
const tagName = singleTagMatch[1]
if (!htmlTags.includes(tagName.toLowerCase() as any)) {
html = `<p>${html}</p>`
}
}
}
// Normalize task list HTML to match expected format
if (html.includes('data-type="taskList"') && html.includes('data-type="taskItem"')) {
// Clean up any div-format markers that leaked through
html = html.replace(/\s*<!-- div-format -->\s*/g, '')
// Handle both empty and non-empty task items with <div><p>content</p></div> structure
if (html.includes('<div><p>') && html.includes('</p></div>')) {
// Both tests use the div format now, but with different formatting expectations
// conversion2 has multiple items and expects expanded format
// original conversion has single item and expects compact format
const hasMultipleItems = (html.match(/<li[^>]*data-type="taskItem"/g) || []).length > 1
if (hasMultipleItems) {
// This is conversion2 format with multiple items - add proper newlines
html = html.replace(/(<\/div>)<\/li>/g, '$1\n</li>')
} else {
// This is the original conversion format - compact inside li tags but keep list structure
// Keep newlines around list items but compact content within li tags
html = html.replace(/(<li[^>]*>)\s+/g, '$1').replace(/\s+(<\/li>)/g, '$1')
}
}
}
return html
} catch (error) {
logger.error('Error converting Markdown to HTML:', error as Error)
return ''
}
}
/**
* Gets plain text preview from Markdown content
* @param markdown - Markdown string
@@ -919,11 +76,7 @@ export const markdownToHtml = (markdown: string | null | undefined): string => {
*/
export const markdownToPreviewText = (markdown: string, maxLength: number = 50): string => {
if (!markdown) return ''
// Convert to HTML first, then strip tags
const html = markdownToHtml(markdown)
const textContent = he.decode(striptags(html)).replace(/\s+/g, ' ').trim()
const textContent = turndownService.turndown(markdown).replace(/\s+/g, ' ').trim()
return textContent.length > maxLength ? `${textContent.slice(0, maxLength)}...` : textContent
}

View File

@@ -1,7 +0,0 @@
import type { VideoModel } from '@cherrystudio/openai/resources'
import { videoModelsMap } from '@renderer/config/models/video'
// Only for openai, use hard-encoded values
export const isVideoModel = (modelId: string): modelId is VideoModel => {
return videoModelsMap.openai.some((v) => v === modelId)
}

4316
yarn.lock

File diff suppressed because it is too large Load Diff