mirror of
https://github.com/EthanMarti/infio-copilot.git
synced 2026-01-16 16:31:56 +00:00
update chatview
This commit is contained in:
parent
35d1ddc979
commit
c35f884764
@ -70,6 +70,7 @@
|
|||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"@types/mermaid": "^9.2.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -95,6 +96,7 @@
|
|||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"lru-cache": "^10.1.0",
|
"lru-cache": "^10.1.0",
|
||||||
"lucide-react": "^0.447.0",
|
"lucide-react": "^0.447.0",
|
||||||
|
"mermaid": "^11.6.0",
|
||||||
"micromatch": "^4.0.5",
|
"micromatch": "^4.0.5",
|
||||||
"minimatch": "^10.0.1",
|
"minimatch": "^10.0.1",
|
||||||
"neverthrow": "^6.1.0",
|
"neverthrow": "^6.1.0",
|
||||||
@ -115,6 +117,7 @@
|
|||||||
"shell-env": "^4.0.1",
|
"shell-env": "^4.0.1",
|
||||||
"simple-git": "^3.27.0",
|
"simple-git": "^3.27.0",
|
||||||
"string-similarity": "^4.0.4",
|
"string-similarity": "^4.0.4",
|
||||||
|
"styled-components": "^6.1.19",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
}
|
}
|
||||||
|
|||||||
1085
pnpm-lock.yaml
generated
1085
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -16,7 +16,7 @@ import {
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
import { ApplyViewState } from '../../ApplyView'
|
import { ApplyViewState } from '../../ApplyView'
|
||||||
import { APPLY_VIEW_TYPE } from '../../constants'
|
import { APPLY_VIEW_TYPE, PREVIEW_VIEW_TYPE } from '../../constants'
|
||||||
import { useApp } from '../../contexts/AppContext'
|
import { useApp } from '../../contexts/AppContext'
|
||||||
import { useDiffStrategy } from '../../contexts/DiffStrategyContext'
|
import { useDiffStrategy } from '../../contexts/DiffStrategyContext'
|
||||||
import { useLLM } from '../../contexts/LLMContext'
|
import { useLLM } from '../../contexts/LLMContext'
|
||||||
@ -55,6 +55,7 @@ import { openSettingsModalWithError } from '../../utils/open-settings-modal'
|
|||||||
import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator'
|
import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator'
|
||||||
// Removed empty line above, added one below for group separation
|
// Removed empty line above, added one below for group separation
|
||||||
import { fetchUrlsContent, onEnt, webSearch } from '../../utils/web-search'
|
import { fetchUrlsContent, onEnt, webSearch } from '../../utils/web-search'
|
||||||
|
import ErrorBoundary from '../common/ErrorBoundary'
|
||||||
|
|
||||||
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
|
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
|
||||||
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
||||||
@ -873,6 +874,16 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
// Updates the currentFile of the focused message (input or chat history)
|
// Updates the currentFile of the focused message (input or chat history)
|
||||||
// This happens when active file changes or focused message changes
|
// This happens when active file changes or focused message changes
|
||||||
const handleActiveLeafChange = useCallback(() => {
|
const handleActiveLeafChange = useCallback(() => {
|
||||||
|
// 如果当前活动的是PreviewView或ApplyView,不更新状态以避免不必要的重新渲染
|
||||||
|
// @ts-expect-error Obsidian API type mismatch
|
||||||
|
const activeLeaf = app.workspace.getActiveLeaf()
|
||||||
|
if (activeLeaf?.view && (
|
||||||
|
activeLeaf.view.getViewType() === PREVIEW_VIEW_TYPE ||
|
||||||
|
activeLeaf.view.getViewType() === APPLY_VIEW_TYPE
|
||||||
|
)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const activeFile = app.workspace.getActiveFile()
|
const activeFile = app.workspace.getActiveFile()
|
||||||
if (!activeFile) return
|
if (!activeFile) return
|
||||||
|
|
||||||
@ -1127,18 +1138,20 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<UserMessageView
|
<ErrorBoundary>
|
||||||
content={message.content}
|
<UserMessageView
|
||||||
mentionables={message.mentionables}
|
content={message.content}
|
||||||
onEdit={() => {
|
mentionables={message.mentionables}
|
||||||
setEditingMessageId(message.id)
|
onEdit={() => {
|
||||||
setFocusedMessageId(message.id)
|
setEditingMessageId(message.id)
|
||||||
// 延迟聚焦,确保组件已渲染
|
setFocusedMessageId(message.id)
|
||||||
setTimeout(() => {
|
// 延迟聚焦,确保组件已渲染
|
||||||
chatUserInputRefs.current.get(message.id)?.focus()
|
setTimeout(() => {
|
||||||
}, 0)
|
chatUserInputRefs.current.get(message.id)?.focus()
|
||||||
}}
|
}, 0)
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
{message.fileReadResults && (
|
{message.fileReadResults && (
|
||||||
<FileReadResults
|
<FileReadResults
|
||||||
|
|||||||
393
src/components/chat-view/Markdown/MermaidBlock.tsx
Normal file
393
src/components/chat-view/Markdown/MermaidBlock.tsx
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
import mermaid from "mermaid"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import styled from "styled-components"
|
||||||
|
|
||||||
|
import { PREVIEW_VIEW_TYPE } from "../../../constants"
|
||||||
|
import { useApp } from "../../../contexts/AppContext"
|
||||||
|
import { useDarkModeContext } from "../../../contexts/DarkModeContext"
|
||||||
|
import { t } from '../../../lang/helpers'
|
||||||
|
import { PreviewView, PreviewViewState } from "../../../PreviewView"
|
||||||
|
import { useCopyToClipboard } from "../../../utils/clipboard"
|
||||||
|
import { useDebounceEffect } from "../../../utils/useDebounceEffect"
|
||||||
|
|
||||||
|
// Obsidian 暗色主题配置
|
||||||
|
const OBSIDIAN_DARK_THEME = {
|
||||||
|
background: "#202020",
|
||||||
|
textColor: "#dcddde",
|
||||||
|
mainBkg: "#2f3136",
|
||||||
|
nodeBorder: "#484b51",
|
||||||
|
lineColor: "#8e9297",
|
||||||
|
primaryColor: "#7289da",
|
||||||
|
primaryTextColor: "#ffffff",
|
||||||
|
primaryBorderColor: "#7289da",
|
||||||
|
secondaryColor: "#2f3136",
|
||||||
|
tertiaryColor: "#36393f",
|
||||||
|
|
||||||
|
// Class diagram specific
|
||||||
|
classText: "#dcddde",
|
||||||
|
|
||||||
|
// State diagram specific
|
||||||
|
labelColor: "#dcddde",
|
||||||
|
|
||||||
|
// Sequence diagram specific
|
||||||
|
actorLineColor: "#8e9297",
|
||||||
|
actorBkg: "#2f3136",
|
||||||
|
actorBorder: "#484b51",
|
||||||
|
actorTextColor: "#dcddde",
|
||||||
|
|
||||||
|
// Flow diagram specific
|
||||||
|
fillType0: "#2f3136",
|
||||||
|
fillType1: "#36393f",
|
||||||
|
fillType2: "#40444b",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obsidian 亮色主题配置
|
||||||
|
const OBSIDIAN_LIGHT_THEME = {
|
||||||
|
background: "#ffffff",
|
||||||
|
textColor: "#2e3338",
|
||||||
|
mainBkg: "#f6f6f6",
|
||||||
|
nodeBorder: "#d1d9e0",
|
||||||
|
lineColor: "#747f8d",
|
||||||
|
primaryColor: "#5865f2",
|
||||||
|
primaryTextColor: "#ffffff",
|
||||||
|
primaryBorderColor: "#5865f2",
|
||||||
|
secondaryColor: "#f6f6f6",
|
||||||
|
tertiaryColor: "#e3e5e8",
|
||||||
|
|
||||||
|
// Class diagram specific
|
||||||
|
classText: "#2e3338",
|
||||||
|
|
||||||
|
// State diagram specific
|
||||||
|
labelColor: "#2e3338",
|
||||||
|
|
||||||
|
// Sequence diagram specific
|
||||||
|
actorLineColor: "#747f8d",
|
||||||
|
actorBkg: "#f6f6f6",
|
||||||
|
actorBorder: "#d1d9e0",
|
||||||
|
actorTextColor: "#2e3338",
|
||||||
|
|
||||||
|
// Flow diagram specific
|
||||||
|
fillType0: "#f6f6f6",
|
||||||
|
fillType1: "#e3e5e8",
|
||||||
|
fillType2: "#dae0e6",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MermaidBlockProps {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MermaidBlock({ code }: MermaidBlockProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isErrorExpanded, setIsErrorExpanded] = useState(false)
|
||||||
|
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
|
||||||
|
|
||||||
|
const { isDarkMode } = useDarkModeContext()
|
||||||
|
const app = useApp()
|
||||||
|
|
||||||
|
// 根据主题模式初始化Mermaid配置
|
||||||
|
const initializeMermaid = (darkMode: boolean) => {
|
||||||
|
const currentTheme = darkMode ? OBSIDIAN_DARK_THEME : OBSIDIAN_LIGHT_THEME
|
||||||
|
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: "loose",
|
||||||
|
theme: darkMode ? "dark" : "default",
|
||||||
|
themeVariables: {
|
||||||
|
...currentTheme,
|
||||||
|
fontSize: "16px",
|
||||||
|
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
|
||||||
|
// Additional styling
|
||||||
|
noteTextColor: currentTheme.textColor,
|
||||||
|
noteBkgColor: currentTheme.tertiaryColor,
|
||||||
|
noteBorderColor: currentTheme.nodeBorder,
|
||||||
|
|
||||||
|
// Improve contrast for special elements
|
||||||
|
critBorderColor: darkMode ? "#ff9580" : "#dc2626",
|
||||||
|
critBkgColor: darkMode ? "#803d36" : "#fef2f2",
|
||||||
|
|
||||||
|
// Task diagram specific
|
||||||
|
taskTextColor: currentTheme.textColor,
|
||||||
|
taskTextOutsideColor: currentTheme.textColor,
|
||||||
|
taskTextLightColor: currentTheme.textColor,
|
||||||
|
|
||||||
|
// Numbers/sections
|
||||||
|
sectionBkgColor: currentTheme.mainBkg,
|
||||||
|
sectionBkgColor2: currentTheme.secondaryColor,
|
||||||
|
|
||||||
|
// Alt sections in sequence diagrams
|
||||||
|
altBackground: currentTheme.mainBkg,
|
||||||
|
|
||||||
|
// Links
|
||||||
|
linkColor: currentTheme.primaryColor,
|
||||||
|
|
||||||
|
// Borders and lines
|
||||||
|
compositeBackground: currentTheme.mainBkg,
|
||||||
|
compositeBorder: currentTheme.nodeBorder,
|
||||||
|
titleColor: currentTheme.textColor,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Whenever `code` or `isDarkMode` changes, mark that we need to re-render a new chart
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
}, [code, isDarkMode])
|
||||||
|
|
||||||
|
// 2) Debounce the actual parse/render
|
||||||
|
useDebounceEffect(
|
||||||
|
() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据当前主题重新初始化Mermaid
|
||||||
|
initializeMermaid(isDarkMode)
|
||||||
|
|
||||||
|
mermaid
|
||||||
|
.parse(code)
|
||||||
|
.then(() => {
|
||||||
|
const id = `mermaid-${Math.random().toString(36).substring(2)}`
|
||||||
|
return mermaid.render(id, code)
|
||||||
|
})
|
||||||
|
.then(({ svg }) => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = svg
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
console.warn("Mermaid parse/render failed:", err)
|
||||||
|
setError(err.message || "Failed to render Mermaid diagram")
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
500, // Delay 500ms
|
||||||
|
[code, isDarkMode], // Dependencies for scheduling
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when user clicks the rendered diagram.
|
||||||
|
* Opens the Mermaid diagram in a new preview tab.
|
||||||
|
*/
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const svgEl = containerRef.current.querySelector("svg")
|
||||||
|
if (!svgEl) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前主题背景色
|
||||||
|
const backgroundColor = isDarkMode ? OBSIDIAN_DARK_THEME.background : OBSIDIAN_LIGHT_THEME.background
|
||||||
|
|
||||||
|
// 创建一个包装器来包含 SVG 和样式
|
||||||
|
const svgHTML = `
|
||||||
|
<div style="
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: ${backgroundColor};
|
||||||
|
max-width: 100%;
|
||||||
|
">
|
||||||
|
${svgEl.outerHTML}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
// 查找是否已经有相同内容的预览 tab
|
||||||
|
const existingLeaf = app.workspace
|
||||||
|
.getLeavesOfType(PREVIEW_VIEW_TYPE)
|
||||||
|
.find(
|
||||||
|
(leaf) =>
|
||||||
|
leaf.view instanceof PreviewView && leaf.view.state?.title === 'Mermaid 图表预览'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingLeaf) {
|
||||||
|
// 如果已存在,关闭现有的然后重新创建以更新内容
|
||||||
|
existingLeaf.detach()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的预览 tab
|
||||||
|
app.workspace.getLeaf(true).setViewState({
|
||||||
|
type: PREVIEW_VIEW_TYPE,
|
||||||
|
active: true,
|
||||||
|
state: {
|
||||||
|
content: svgHTML,
|
||||||
|
title: 'Mermaid 图表预览',
|
||||||
|
} satisfies PreviewViewState,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error opening Mermaid preview:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy functionality handled directly through the copyWithFeedback utility
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MermaidBlockContainer>
|
||||||
|
{isLoading && <LoadingMessage>{t("common:mermaid.loading")}</LoadingMessage>}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<ErrorContainer>
|
||||||
|
<ErrorHeader
|
||||||
|
$isExpanded={isErrorExpanded}
|
||||||
|
onClick={() => setIsErrorExpanded(!isErrorExpanded)}>
|
||||||
|
<ErrorHeaderContent>
|
||||||
|
<WarningIcon className="codicon codicon-warning" />
|
||||||
|
<ErrorTitle>{t("common:mermaid.render_error")}</ErrorTitle>
|
||||||
|
</ErrorHeaderContent>
|
||||||
|
<ErrorHeaderActions>
|
||||||
|
<CopyButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const combinedContent = `Error: ${error}\n\n\`\`\`mermaid\n${code}\n\`\`\``
|
||||||
|
copyWithFeedback(combinedContent, e)
|
||||||
|
}}>
|
||||||
|
<span className={`codicon codicon-${showCopyFeedback ? "check" : "copy"}`}></span>
|
||||||
|
</CopyButton>
|
||||||
|
<span className={`codicon codicon-chevron-${isErrorExpanded ? "up" : "down"}`}></span>
|
||||||
|
</ErrorHeaderActions>
|
||||||
|
</ErrorHeader>
|
||||||
|
{isErrorExpanded && (
|
||||||
|
<ErrorContent>
|
||||||
|
<ErrorMessage>{error}</ErrorMessage>
|
||||||
|
<code className="language-mermaid">{code}</code>
|
||||||
|
</ErrorContent>
|
||||||
|
)}
|
||||||
|
</ErrorContainer>
|
||||||
|
) : (
|
||||||
|
<SvgContainer onClick={handleClick} ref={containerRef} $isLoading={isLoading} />
|
||||||
|
)}
|
||||||
|
</MermaidBlockContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MermaidBlockContainer = styled.div`
|
||||||
|
position: relative;
|
||||||
|
margin: 8px 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const LoadingMessage = styled.div`
|
||||||
|
padding: 8px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9em;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorContainer = styled.div`
|
||||||
|
margin-top: 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
interface ErrorHeaderProps {
|
||||||
|
$isExpanded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorHeader = styled.div<ErrorHeaderProps>`
|
||||||
|
border-bottom: ${(props) => (props.$isExpanded ? "1px solid var(--background-modifier-border)" : "none")};
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
color: var(--text-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorHeaderContent = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-grow: 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
const WarningIcon = styled.span`
|
||||||
|
color: var(--text-warning);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: -1.5px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorTitle = styled.span`
|
||||||
|
font-weight: bold;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorHeaderActions = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorContent = styled.div`
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border-top: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorMessage = styled.div`
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
`
|
||||||
|
|
||||||
|
const CopyButton = styled.button`
|
||||||
|
padding: 3px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface SvgContainerProps {
|
||||||
|
$isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SvgContainer = styled.div<SvgContainerProps>`
|
||||||
|
opacity: ${(props) => (props.$isLoading ? 0.3 : 1)};
|
||||||
|
min-height: 20px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-height: 600px;
|
||||||
|
|
||||||
|
/* Ensure the SVG fills the container width and maintains aspect ratio */
|
||||||
|
& > svg {
|
||||||
|
display: block; /* Ensure block layout */
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100%; /* Respect container's max-height */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effect to indicate clickability */
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.02);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Click hint overlay */
|
||||||
|
&:hover::after {
|
||||||
|
content: '点击查看大图';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
`
|
||||||
65
src/components/chat-view/Markdown/RawMarkdownBlock.tsx
Normal file
65
src/components/chat-view/Markdown/RawMarkdownBlock.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import rehypeRaw from 'rehype-raw'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
import { useDarkModeContext } from '../../../contexts/DarkModeContext'
|
||||||
|
|
||||||
|
import MermaidBlock from './MermaidBlock'
|
||||||
|
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
|
||||||
|
|
||||||
|
interface RawMarkdownBlockProps {
|
||||||
|
content: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RawMarkdownBlock({
|
||||||
|
content,
|
||||||
|
className = "infio-markdown",
|
||||||
|
}: RawMarkdownBlockProps) {
|
||||||
|
const {isDarkMode} = useDarkModeContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
className={className}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
|
components={{
|
||||||
|
code({ className, children, ...props }) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
|
const language = match ? match[1] : undefined
|
||||||
|
const isInline = !className
|
||||||
|
|
||||||
|
// Mermaid 图表渲染
|
||||||
|
if (!isInline && language === 'mermaid') {
|
||||||
|
const codeText = String(children || "")
|
||||||
|
return (
|
||||||
|
<MermaidBlock
|
||||||
|
code={codeText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码块使用语法高亮
|
||||||
|
if (!isInline && language) {
|
||||||
|
return (
|
||||||
|
<MemoizedSyntaxHighlighterWrapper
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
language={language}
|
||||||
|
hasFilename={false}
|
||||||
|
wrapLines={true}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</MemoizedSyntaxHighlighterWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内联代码使用原生样式
|
||||||
|
return <code {...props}>{children}</code>
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import Markdown from 'react-markdown'
|
|
||||||
|
|
||||||
import { ApplyStatus, ToolArgs } from '../../types/apply'
|
import { ApplyStatus, ToolArgs } from '../../types/apply'
|
||||||
import {
|
import {
|
||||||
@ -11,9 +10,9 @@ import MarkdownApplyDiffBlock from './Markdown/MarkdownApplyDiffBlock'
|
|||||||
import MarkdownEditFileBlock from './Markdown/MarkdownEditFileBlock'
|
import MarkdownEditFileBlock from './Markdown/MarkdownEditFileBlock'
|
||||||
import MarkdownFetchUrlsContentBlock from './Markdown/MarkdownFetchUrlsContentBlock'
|
import MarkdownFetchUrlsContentBlock from './Markdown/MarkdownFetchUrlsContentBlock'
|
||||||
import MarkdownListFilesBlock from './Markdown/MarkdownListFilesBlock'
|
import MarkdownListFilesBlock from './Markdown/MarkdownListFilesBlock'
|
||||||
|
import MarkdownMatchSearchFilesBlock from './Markdown/MarkdownMatchSearchFilesBlock'
|
||||||
import MarkdownReadFileBlock from './Markdown/MarkdownReadFileBlock'
|
import MarkdownReadFileBlock from './Markdown/MarkdownReadFileBlock'
|
||||||
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
||||||
import MarkdownMatchSearchFilesBlock from './Markdown/MarkdownMatchSearchFilesBlock'
|
|
||||||
import MarkdownRegexSearchFilesBlock from './Markdown/MarkdownRegexSearchFilesBlock'
|
import MarkdownRegexSearchFilesBlock from './Markdown/MarkdownRegexSearchFilesBlock'
|
||||||
import MarkdownSearchAndReplace from './Markdown/MarkdownSearchAndReplace'
|
import MarkdownSearchAndReplace from './Markdown/MarkdownSearchAndReplace'
|
||||||
import MarkdownSearchWebBlock from './Markdown/MarkdownSearchWebBlock'
|
import MarkdownSearchWebBlock from './Markdown/MarkdownSearchWebBlock'
|
||||||
@ -21,6 +20,7 @@ import MarkdownSemanticSearchFilesBlock from './Markdown/MarkdownSemanticSearchF
|
|||||||
import MarkdownSwitchModeBlock from './Markdown/MarkdownSwitchModeBlock'
|
import MarkdownSwitchModeBlock from './Markdown/MarkdownSwitchModeBlock'
|
||||||
import MarkdownToolResult from './Markdown/MarkdownToolResult'
|
import MarkdownToolResult from './Markdown/MarkdownToolResult'
|
||||||
import MarkdownWithIcons from './Markdown/MarkdownWithIcon'
|
import MarkdownWithIcons from './Markdown/MarkdownWithIcon'
|
||||||
|
import RawMarkdownBlock from './Markdown/RawMarkdownBlock'
|
||||||
import UseMcpToolBlock from './Markdown/UseMcpToolBlock'
|
import UseMcpToolBlock from './Markdown/UseMcpToolBlock'
|
||||||
|
|
||||||
function ReactMarkdown({
|
function ReactMarkdown({
|
||||||
@ -31,8 +31,8 @@ function ReactMarkdown({
|
|||||||
applyStatus: ApplyStatus
|
applyStatus: ApplyStatus
|
||||||
onApply: (toolArgs: ToolArgs) => void
|
onApply: (toolArgs: ToolArgs) => void
|
||||||
children: string
|
children: string
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const blocks: ParsedMsgBlock[] = useMemo(
|
const blocks: ParsedMsgBlock[] = useMemo(
|
||||||
() => parseMsgBlocks(children),
|
() => parseMsgBlocks(children),
|
||||||
[children],
|
[children],
|
||||||
@ -42,9 +42,11 @@ function ReactMarkdown({
|
|||||||
<>
|
<>
|
||||||
{blocks.map((block, index) =>
|
{blocks.map((block, index) =>
|
||||||
block.type === 'thinking' ? (
|
block.type === 'thinking' ? (
|
||||||
<Markdown key={"markdown-" + index} className="infio-markdown">
|
<RawMarkdownBlock
|
||||||
{block.content}
|
key={"markdown-" + index}
|
||||||
</Markdown>
|
content={block.content}
|
||||||
|
className="infio-markdown"
|
||||||
|
/>
|
||||||
) : block.type === 'think' ? (
|
) : block.type === 'think' ? (
|
||||||
<MarkdownReasoningBlock
|
<MarkdownReasoningBlock
|
||||||
key={"reasoning-" + index}
|
key={"reasoning-" + index}
|
||||||
@ -206,9 +208,11 @@ function ReactMarkdown({
|
|||||||
content={block.content}
|
content={block.content}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Markdown key={"markdown-" + index} className="infio-markdown">
|
<RawMarkdownBlock
|
||||||
{block.content}
|
key={"markdown-" + index}
|
||||||
</Markdown>
|
content={block.content}
|
||||||
|
className="infio-markdown"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -49,25 +49,25 @@ const UserMessageView: React.FC<UserMessageViewProps> = ({
|
|||||||
<span key={index} className="infio-mention-tag">
|
<span key={index} className="infio-mention-tag">
|
||||||
{Icon && <Icon size={12} />}
|
{Icon && <Icon size={12} />}
|
||||||
{mentionable.type === 'current-file' && (
|
{mentionable.type === 'current-file' && (
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Not Found'}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'vault' && (
|
{mentionable.type === 'vault' && (
|
||||||
<span>Vault</span>
|
<span>Vault</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'block' && (
|
{mentionable.type === 'block' && (
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Not Found'}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'file' && (
|
{mentionable.type === 'file' && (
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Not Found'}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'folder' && (
|
{mentionable.type === 'folder' && (
|
||||||
<span>{mentionable.folder.name}</span>
|
<span>{mentionable.folder?.name || 'Not Found'}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'url' && (
|
{mentionable.type === 'url' && (
|
||||||
<span>{mentionable.url}</span>
|
<span>{mentionable.url}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'image' && (
|
{mentionable.type === 'image' && (
|
||||||
<span>{mentionable.name}</span>
|
<span>{mentionable.name || 'Image'}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
139
src/components/common/ErrorBoundary.tsx
Normal file
139
src/components/common/ErrorBoundary.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import React, { Component, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
// 更新 state 使下一次渲染能够显示降级后的 UI
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
// 你同样可以将错误日志上报给服务器
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// 你可以自定义降级后的 UI 并渲染
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="infio-error-boundary">
|
||||||
|
<div className="infio-error-boundary-content">
|
||||||
|
<AlertTriangle size={24} color="var(--text-error)" />
|
||||||
|
<h3>出现了一个错误</h3>
|
||||||
|
<p>渲染此组件时发生了错误。请尝试刷新页面或重新打开聊天窗口。</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<details className="infio-error-details">
|
||||||
|
<summary>错误详情</summary>
|
||||||
|
<pre>{this.state.error.toString()}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||||
|
className="infio-retry-button"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.infio-error-boundary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--size-4-4);
|
||||||
|
background: var(--background-secondary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
margin: var(--size-2-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-boundary-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-boundary-content h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-error);
|
||||||
|
font-size: var(--font-ui-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-boundary-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-normal);
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-details {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--size-2-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-details pre {
|
||||||
|
background: var(--background-primary-alt);
|
||||||
|
padding: var(--size-2-2);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font-size: var(--font-text-small);
|
||||||
|
color: var(--text-error);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin-top: var(--size-2-1);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-retry-button {
|
||||||
|
background: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: var(--size-2-2) var(--size-4-2);
|
||||||
|
font-size: var(--font-ui-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-retry-button:hover {
|
||||||
|
background: var(--interactive-accent-hover);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary
|
||||||
@ -14,19 +14,36 @@ export default function PreviewViewRoot({
|
|||||||
const closeIcon = getIcon('x')
|
const closeIcon = getIcon('x')
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 显示原始文本内容
|
// 显示内容 - 支持 HTML 和纯文本
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contentRef.current && state.content) {
|
if (contentRef.current && state.content) {
|
||||||
// 清空现有内容
|
// 清空现有内容
|
||||||
contentRef.current.empty()
|
contentRef.current.innerHTML = ''
|
||||||
|
|
||||||
// 创建预格式化文本元素
|
// 判断是否为 HTML 内容(包含 SVG)
|
||||||
const preElement = document.createElement('pre')
|
const isHtmlContent = state.content.trim().startsWith('<') &&
|
||||||
preElement.className = 'infio-raw-content'
|
(state.content.includes('<svg') || state.content.includes('<div') ||
|
||||||
preElement.textContent = state.content
|
state.content.includes('<span') || state.content.includes('<pre'))
|
||||||
|
|
||||||
// 添加到容器
|
if (isHtmlContent) {
|
||||||
contentRef.current.appendChild(preElement)
|
// 如果是 HTML 内容,直接渲染
|
||||||
|
contentRef.current.innerHTML = state.content
|
||||||
|
|
||||||
|
// 为 SVG 添加适当的样式
|
||||||
|
const svgElements = contentRef.current.querySelectorAll('svg')
|
||||||
|
svgElements.forEach(svg => {
|
||||||
|
svg.style.maxWidth = '100%'
|
||||||
|
svg.style.height = 'auto'
|
||||||
|
svg.style.display = 'block'
|
||||||
|
svg.style.margin = '0 auto'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 如果是纯文本,创建预格式化文本元素
|
||||||
|
const preElement = document.createElement('pre')
|
||||||
|
preElement.className = 'infio-raw-content'
|
||||||
|
preElement.textContent = state.content
|
||||||
|
contentRef.current.appendChild(preElement)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [state.content, state.file])
|
}, [state.content, state.file])
|
||||||
|
|
||||||
@ -61,7 +78,7 @@ export default function PreviewViewRoot({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="markdown-preview-section"
|
className="markdown-preview-section infio-preview-content"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -92,6 +109,19 @@ export default function PreviewViewRoot({
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-preview-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-preview-content svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.infio-raw-content {
|
.infio-raw-content {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@ -99,6 +129,7 @@ export default function PreviewViewRoot({
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: var(--background-secondary);
|
background-color: var(--background-secondary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,7 +22,14 @@ export function DarkModeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDarkMode = () => {
|
const handleDarkMode = () => {
|
||||||
setIsDarkMode(document.body.classList.contains('theme-dark'))
|
const newIsDarkMode = document.body.classList.contains('theme-dark')
|
||||||
|
// 只在实际发生变化时才更新状态
|
||||||
|
setIsDarkMode(prevIsDarkMode => {
|
||||||
|
if (prevIsDarkMode !== newIsDarkMode) {
|
||||||
|
return newIsDarkMode
|
||||||
|
}
|
||||||
|
return prevIsDarkMode
|
||||||
|
})
|
||||||
}
|
}
|
||||||
handleDarkMode()
|
handleDarkMode()
|
||||||
app.workspace.on('css-change', handleDarkMode)
|
app.workspace.on('css-change', handleDarkMode)
|
||||||
|
|||||||
75
src/utils/clipboard.ts
Normal file
75
src/utils/clipboard.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for copying text to clipboard
|
||||||
|
*/
|
||||||
|
interface CopyOptions {
|
||||||
|
/** Duration in ms to show success feedback (default: 2000) */
|
||||||
|
feedbackDuration?: number
|
||||||
|
/** Optional callback when copy succeeds */
|
||||||
|
onSuccess?: () => void
|
||||||
|
/** Optional callback when copy fails */
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard with error handling
|
||||||
|
*/
|
||||||
|
export const copyToClipboard = async (text: string, options?: CopyOptions): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
options?.onSuccess?.()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error("Failed to copy to clipboard")
|
||||||
|
options?.onError?.(err)
|
||||||
|
console.error("Failed to copy to clipboard:", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook for managing clipboard copy state with feedback
|
||||||
|
*/
|
||||||
|
export const useCopyToClipboard = (feedbackDuration = 2000) => {
|
||||||
|
const [showCopyFeedback, setShowCopyFeedback] = useState(false)
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
const copyWithFeedback = useCallback(
|
||||||
|
async (text: string, e?: React.MouseEvent) => {
|
||||||
|
e?.stopPropagation()
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await copyToClipboard(text, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowCopyFeedback(true)
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setShowCopyFeedback(false)
|
||||||
|
timeoutRef.current = null
|
||||||
|
}, feedbackDuration)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return success
|
||||||
|
},
|
||||||
|
[feedbackDuration],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
showCopyFeedback,
|
||||||
|
copyWithFeedback,
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/utils/useDebounceEffect.ts
Normal file
42
src/utils/useDebounceEffect.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
type VoidFn = () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `effectRef.current()` after `delay` ms whenever any of the `deps` change,
|
||||||
|
* but cancels/re-schedules if they change again before the delay.
|
||||||
|
*/
|
||||||
|
export function useDebounceEffect(effect: VoidFn, delay: number, deps: any[]) {
|
||||||
|
const callbackRef = useRef<VoidFn>(effect)
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Keep callbackRef current
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = effect
|
||||||
|
}, [effect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear any queued call
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a new call
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
// always call the *latest* version of effect
|
||||||
|
callbackRef.current()
|
||||||
|
}, delay)
|
||||||
|
|
||||||
|
// Cleanup on unmount or next effect
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We want to re‐schedule if any item in `deps` changed,
|
||||||
|
// or if `delay` changed.
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [delay, ...deps])
|
||||||
|
}
|
||||||
88
styles.css
88
styles.css
@ -204,6 +204,9 @@
|
|||||||
font-size: var(--code-size);
|
font-size: var(--code-size);
|
||||||
background-color: var(--code-background);
|
background-color: var(--code-background);
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@ -2047,6 +2050,91 @@ button.infio-chat-input-model-select {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mermaid Diagram Styles
|
||||||
|
* - Mermaid 图表渲染样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
.infio-mermaid-loading {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mermaid-error {
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--text-error);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--background-modifier-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mermaid-error-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mermaid-error-message {
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mermaid-error-details {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mermaid-error-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mermaid-error-details pre {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font-size: 0.8em;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mermaid-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mermaid SVG 特定样式 */
|
||||||
|
.infio-mermaid-container svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式下的 Mermaid 样式调整 */
|
||||||
|
.theme-dark .infio-mermaid-container .node rect,
|
||||||
|
.theme-dark .infio-mermaid-container .node circle,
|
||||||
|
.theme-dark .infio-mermaid-container .node ellipse,
|
||||||
|
.theme-dark .infio-mermaid-container .node polygon {
|
||||||
|
fill: var(--background-primary);
|
||||||
|
stroke: var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .infio-mermaid-container .edgePath .path {
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .infio-mermaid-container .edgeLabel {
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* CommandsView Styles
|
* CommandsView Styles
|
||||||
* - 命令管理界面
|
* - 命令管理界面
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user