diff --git a/app/main/handlers/handleStreamWithContext.js b/app/main/handlers/handleStreamWithContext.js index 84f0a4a89c..b013e7a04c 100644 --- a/app/main/handlers/handleStreamWithContext.js +++ b/app/main/handlers/handleStreamWithContext.js @@ -15,20 +15,20 @@ module.exports = { streamMap.set(token, stream) stream.on('data', (data) => { - if (!windows) { + if (!windows || windows.isDestroyed() || windows.webContents.isDestroyed()) { return } windows.webContents.send(`${token}-data`, data) }) stream.on('error', (error) => { - if (!windows) { + if (!windows || windows.isDestroyed() || windows.webContents.isDestroyed()) { return } windows.webContents.send(`${token}-error`, error && error.details) }) stream.on('end', () => { streamMap.delete(token) - if (!windows) { + if (!windows || windows.isDestroyed() || windows.webContents.isDestroyed()) { return } windows.webContents.send(`${token}-end`) diff --git a/app/main/handlers/yakRunnerTerminal.js b/app/main/handlers/yakRunnerTerminal.js index 86d4967cab..3c390ae116 100644 --- a/app/main/handlers/yakRunnerTerminal.js +++ b/app/main/handlers/yakRunnerTerminal.js @@ -55,7 +55,7 @@ module.exports = (win, getClient) => { // 如果有问题,重置 stream.on('error', (e) => { removeStreamRunner(id) - if (win) { + if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) { win.webContents.send('client-listening-terminal-error-yakRunner', { id, path }) } }) @@ -63,7 +63,7 @@ module.exports = (win, getClient) => { // 发送回数据 stream.on('data', (data) => { if (data.control) { - if (win && data.waiting) { + if (win && !win.isDestroyed() && !win.webContents.isDestroyed() && data.waiting) { win.webContents.send(`client-listening-terminal-success-yakRunner`, { id, path, result: data }) } if (win && data.closed) { @@ -72,13 +72,13 @@ module.exports = (win, getClient) => { return } - if (win) { + if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) { win.webContents.send(`client-listening-terminal-data-yakRunner`, { id, path, result: data }) } }) stream.on('end', () => { removeStreamRunner(id) - if (win) { + if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) { win.webContents.send('client-listening-terminal-end-yakRunner', { id, path }) } }) @@ -95,7 +95,7 @@ module.exports = (win, getClient) => { // 如果有问题,重置 stream.on('error', (e) => { removeStreamRunner(id) - if (win) { + if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) { win.webContents.send('client-listening-terminal-error-aiAgent', { id, path }) } }) @@ -103,7 +103,7 @@ module.exports = (win, getClient) => { // 发送回数据 stream.on('data', (data) => { if (data.control) { - if (win && data.waiting) { + if (win && !win.isDestroyed() && !win.webContents.isDestroyed() && data.waiting) { win.webContents.send(`client-listening-terminal-success-aiAgent`, { id, path, result: data }) } if (win && data.closed) { @@ -112,13 +112,13 @@ module.exports = (win, getClient) => { return } - if (win) { + if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) { win.webContents.send(`client-listening-terminal-data-aiAgent`, { id, path, result: data }) } }) stream.on('end', () => { removeStreamRunner(id) - if (win) { + if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) { win.webContents.send('client-listening-terminal-end-aiAgent', { id, path }) } }) diff --git a/app/main/index.js b/app/main/index.js index af230a667c..235e1c6baa 100644 --- a/app/main/index.js +++ b/app/main/index.js @@ -214,14 +214,16 @@ let readyWinShow = false function createWindow() { const minWidth = 900 const minHeight = 650 + const defaultWidth = 1400 + const defaultHeight = 880 const state = windowStateKeeper({ - defaultWidth: minWidth, - defaultHeight: minHeight, + defaultWidth: defaultWidth, + defaultHeight: defaultHeight, path: getWindowStatePath(), file: 'yakit-window-state.json', }) - const width = Math.max(state.width ?? minWidth, minWidth) - const height = Math.max(state.height ?? minHeight, minHeight) + const width = Math.max(state.width ?? defaultWidth, minWidth) + const height = Math.max(state.height ?? defaultHeight, minHeight) win = new BrowserWindow({ x: state.x, diff --git a/app/renderer/engine-link-startup/src/utils/xtermUtils.ts b/app/renderer/engine-link-startup/src/utils/xtermUtils.ts index 0db52eff88..eab09a0b09 100644 --- a/app/renderer/engine-link-startup/src/utils/xtermUtils.ts +++ b/app/renderer/engine-link-startup/src/utils/xtermUtils.ts @@ -22,13 +22,38 @@ export const writeExecResultXTerm = (xterm: any, result: ExecResult, encoding?: } export const xtermFit = (xtermRef: any, columns?: number, rows?: number) => { - if (xtermRef && xtermRef?.current && xtermRef.current.terminal) { - xtermRef.current.terminal.resize(columns || 100, rows || 10) + if (xtermRef && xtermRef?.current && isTerminalReady(xtermRef.current.terminal)) { + try { + xtermRef.current.terminal.resize(columns || 100, rows || 10) + } catch (e) { + // 关键词: xterm resize error swallowed, 防止 _renderService.dimensions 崩溃冒泡到 UI + } } } export const xtermClear = (xtermRef: any, columns?: number, rows?: number) => { - if (xtermRef && xtermRef?.current && xtermRef.current.terminal) { - xtermRef.current.terminal.reset() + if (xtermRef && xtermRef?.current && isTerminalReady(xtermRef.current.terminal)) { + try { + xtermRef.current.terminal.reset() + } catch (e) { + // 关键词: xterm reset error swallowed, 防止 Viewport.syncScrollArea 在 renderer 未就绪时崩溃 + } } } + +// 关键词: xterm dimensions crash, _renderService undefined, terminal.reset 报错 +// xterm 的 RenderService.dimensions getter 在 renderer 未就绪/已 dispose 时会抛 +// "Cannot read properties of undefined (reading 'dimensions')"。 +// 该校验用于在 open 尚未完成、或组件已被 dispose 时,避免 reset/resize 触发 Viewport.syncScrollArea 崩溃。 +export const isTerminalReady = (terminal: any): boolean => { + if (!terminal) return false + // terminal.element 仅在 Terminal.open 成功后被赋值 + if (!terminal.element) return false + // 兜底:能拿到内部 RenderService 时,用其公开的 hasRenderer 判定 + const renderService = terminal._core?._renderService + if (renderService && typeof renderService.hasRenderer === 'function') { + return renderService.hasRenderer() + } + // 拿不到内部结构时,退化成仅依赖 element 是否存在 + return true +} diff --git a/app/renderer/src/main/public/locales/zh/aiAgent.json b/app/renderer/src/main/public/locales/zh/aiAgent.json index dfa550f3e7..7b8503d626 100644 --- a/app/renderer/src/main/public/locales/zh/aiAgent.json +++ b/app/renderer/src/main/public/locales/zh/aiAgent.json @@ -234,7 +234,7 @@ "getGlobalKnowledgeBaseFailed": "获取全局知识库失败: {{error}}" }, "AIAgentTabs": { - "historyChat": "历史会话", + "historyChat": "历史", "config": "配置", "skill": "技能", "tool": "工具" @@ -432,7 +432,7 @@ "focusModeTooltip": "复杂场景限定使用框架,限制AI思考范围" }, "HistoryChat": { - "title": "历史会话", + "title": "历史", "noChatsToClear": "暂无可清除的会话", "allChatsCleared": "已清空全部会话", "clearFailed": "清除会话失败:{{error}}", diff --git a/app/renderer/src/main/src/components/yakitUI/YakitXterm/YakitXterm.tsx b/app/renderer/src/main/src/components/yakitUI/YakitXterm/YakitXterm.tsx index 2a8c72f814..ec5c5e50d1 100644 --- a/app/renderer/src/main/src/components/yakitUI/YakitXterm/YakitXterm.tsx +++ b/app/renderer/src/main/src/components/yakitUI/YakitXterm/YakitXterm.tsx @@ -18,8 +18,8 @@ import useListenWidth from '@/pages/pluginHub/hooks/useListenWidth' import { System, SystemInfo, handleFetchSystem } from '@/constants/hardware' import { getClipboardText, setClipboardText } from '@/utils/clipboard' import { useXTermOptions } from '@/hook/useXTermOptions/useXTermOptions' -import { useCampare } from '@/hook/useCompare/useCompare' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import { isTerminalReady } from '@/utils/xtermUtils' export interface YakitXtermRefProps { terminal: Terminal @@ -182,6 +182,15 @@ const YakitXterm: React.FC = forwardRef((props, ref) => { getTerminal: () => terminalRef.current, }) + const safeFit = useMemoizedFn(() => { + if (!fitAddonRef.current || !isTerminalReady(terminalRef.current)) return + try { + fitAddonRef.current.fit() + } catch (e) { + // 关键词: xterm fit error swallowed, 防止 _renderService.dimensions 崩溃冒泡到 UI + } + }) + useEffect(() => { if (!systemRef.current) { handleFetchSystem(() => (systemRef.current = SystemInfo.system)) @@ -214,7 +223,17 @@ const YakitXterm: React.FC = forwardRef((props, ref) => { if (terminalRef.current && terminalDivRef.current) { // Creates the terminal within the container element. terminalRef.current.open(terminalDivRef.current) - fitAddonRef.current.fit() + // 关键词: xterm open 后 fit 延后一帧, 避免 RenderService.dimensions 未就绪崩溃 + // Terminal.open 内部 Viewport 构造会异步(100ms 内)首次 syncScrollArea, + // 同步立刻 fit() 在某些时序下会触发 _renderService.dimensions 抛错。 + const rafId = requestAnimationFrame(() => { + safeFit() + }) + return () => { + cancelAnimationFrame(rafId) + terminalRef.current.dispose() + fitAddonRef.current.dispose() + } } return () => { terminalRef.current.dispose() @@ -222,8 +241,11 @@ const YakitXterm: React.FC = forwardRef((props, ref) => { } }, []) useEffect(() => { - fitAddonRef.current.fit() - }, [terminalDivHeight, terminalDivWidth]) + const rafId = requestAnimationFrame(() => { + safeFit() + }) + return () => cancelAnimationFrame(rafId) + }, [terminalDivHeight, terminalDivWidth, safeFit]) const customKeyEventHandler = (e: KeyboardEvent) => { if (props.customKeyEventHandler) { return props.customKeyEventHandler(e) diff --git a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/AIAgentChatTemplate.module.scss b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/AIAgentChatTemplate.module.scss index 5f366676e6..069874d2b9 100644 --- a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/AIAgentChatTemplate.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/AIAgentChatTemplate.module.scss @@ -189,7 +189,7 @@ // } // } .task-loading-footer { - padding: 0 12px; + padding: 0 12px 60px; } } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.module.scss index 1c00cb49fc..3b7a6259c1 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.module.scss @@ -108,56 +108,199 @@ .file-system { width: 100%; margin-top: 8px; - --title-bg-color: var(--Colors-Use-Neutral-Border); - --content-color: var(--Colors-Use-Neutral-Text-1-Title); + --status-border-color: var(--Colors-Use-Neutral-Border); &-success { - --title-bg-color: var(--Colors-Use-Success-Focus); - --content-color: var(--Colors-Use-Neutral-Text-4-Help-text); + --status-border-color: var(--Colors-Use-Success-Focus); } &-failed { - --title-bg-color: var(--Colors-Use-Error-Focus); - --content-color: var(--Colors-Use-Error-Primary); + --status-border-color: var(--Colors-Use-Error-Focus); } &-cancel { - --title-bg-color: var(--Colors-Use-Neutral-Border); - --content-color: var(--Colors-Use-Neutral-Text-4-Help-text); + --status-border-color: var(--Colors-Use-Neutral-Border); } &-purple { - --title-bg-color: var(--Colors-Use-Purple-Bg-Hover); - --content-color: var(--Colors-Use-Neutral-Text-4-Help-text); + --status-border-color: var(--Colors-Use-Purple-Bg-Hover); } - &-title { - height: 32px; - padding: 6px 8px; - display: flex; + &-neutral { + --status-border-color: var(--Colors-Use-Neutral-Border); + } + &-content { + background: var(--Colors-Use-Neutral-Bg); + padding: 0; + border-radius: 8px; + border: 1px solid var(--status-border-color); + font-family: 'Menlo', 'Monaco', 'Consolas', monospace; + font-size: 12px; + line-height: 18px; + } +} + +.terminal-params-line { + display: flex; + align-items: center; + padding: 6px 10px; + border-bottom: 1px solid var(--Colors-Use-Neutral-Border); + color: var(--Colors-Use-Neutral-Text-4-Help-text); + gap: 6px; + .params-label { + flex-shrink: 0; + color: var(--Colors-Use-Main-Primary); + font-weight: 500; + } + .params-json { + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--Colors-Use-Neutral-Text-3-Secondary); + cursor: pointer; + border-radius: 3px; + padding: 1px 4px; + &:hover { + background: var(--Colors-Use-Neutral-Bg-Hover); + color: var(--Colors-Use-Neutral-Text-1-Title); + } + } +} + +.terminal-output-section { + padding: 6px 10px; + .output-content { + color: var(--Colors-Use-Neutral-Text-3-Secondary); + max-height: 400px; + overflow: hidden; + white-space: pre-wrap; + word-break: break-all; + position: relative; + padding: 6px 0; + &.output-scrollable { + overflow: auto; + } + &.output-fade-top::before, + &.output-fade-bottom::after { + content: ''; + position: sticky; + left: 0; + right: 0; + display: block; + height: 18px; + pointer-events: none; + z-index: 1; + } + &.output-fade-top::before { + top: 0; + background: linear-gradient(to bottom, var(--Colors-Use-Neutral-Bg), transparent); + margin-bottom: -18px; + } + &.output-fade-bottom::after { + bottom: 0; + background: linear-gradient(to top, var(--Colors-Use-Neutral-Bg), transparent); + margin-top: -18px; + } + &:hover { + .terminal-copy-btn { + display: flex; + } + } + } + .terminal-copy-btn { + display: none; + position: absolute; + top: 6px; + right: 0; align-items: center; - justify-content: space-between; - border-radius: 8px 8px 0 0; - background-color: var(--title-bg-color); - color: var(--Colors-Use-Neutral-Text-1-Title); - font-size: 14px; - font-weight: 400; - line-height: 20px; + border-radius: 4px; + background: var(--Colors-Use-Neutral-Bg); + border: 1px solid var(--Colors-Use-Neutral-Border); + color: var(--Colors-Use-Neutral-Text-3-Secondary); + cursor: pointer; + &:hover { + color: var(--Colors-Use-Neutral-Text-1-Title); + } + } + .terminal-copy-icon { + margin: 0; + height: 14px; + width: 14px; svg { - width: 20px; - height: 20px; + height: 14px; + width: 14px; } } - &-content { - background: var(--Colors-Use-Basic-Background); - padding: 8px; - border-radius: 0 0 8px 8px; - border: 1px solid var(--title-bg-color); - div { - color: var(--content-color); +} + +.params-modal-body { + max-height: 60vh; + overflow: auto; + .params-modal-json { + background: var(--Colors-Use-Neutral-Bg); + border-radius: 8px; + padding: 12px 28px 12px 12px; + font-family: 'Menlo', 'Monaco', 'Consolas', monospace; + font-size: 12px; + line-height: 18px; + color: var(--Colors-Use-Neutral-Text-3-Secondary); + white-space: pre-wrap; + word-break: break-all; + position: relative; + &:hover { + .params-modal-copy { + display: flex; + } + } + } + .params-modal-copy { + display: none; + position: absolute; + top: 8px; + right: 24px; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + border-radius: 4px; + background: var(--Colors-Use-Neutral-Bg-Hover); + border: 1px solid var(--Colors-Use-Neutral-Border); + color: var(--Colors-Use-Neutral-Text-3-Secondary); + cursor: pointer; + padding: 0; + &:hover { + color: var(--Colors-Use-Neutral-Text-1-Title); + background: var(--Colors-Use-Neutral-Bg); } - .summary { - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 3; - line-clamp: 3; - -webkit-box-orient: vertical; + svg { + width: 13px; + height: 13px; + } + } + .params-field-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid var(--Colors-Use-Neutral-Border); + &:last-child { + border-bottom: none; + } + .field-key { + flex-shrink: 0; + color: var(--Colors-Use-Main-Primary); + font-weight: 500; + } + .field-value { + flex: 1; + min-width: 0; + color: var(--Colors-Use-Neutral-Text-2-Primary); + word-break: break-all; + white-space: pre-wrap; + } + .field-copy { + flex-shrink: 0; + cursor: pointer; + color: var(--Colors-Use-Neutral-Text-4-Help-text); + &:hover { + color: var(--Colors-Use-Neutral-Text-1-Title); + } } } } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.tsx index ef57e35c8b..aa60deace6 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.tsx @@ -2,8 +2,7 @@ import { FC, memo, ReactNode, useEffect, useMemo, useRef, useState } from 'react import ChatCard from './ChatCard' import styles from './ToolInvokerCard.module.scss' import classNames from 'classnames' -import { CopyComponents, YakitTag } from '@/components/yakitUI/YakitTag/YakitTag' -import type { YakitTagColor } from '@/components/yakitUI/YakitTag/YakitTagType' +import { CopyComponents } from '@/components/yakitUI/YakitTag/YakitTag' import { grpcQueryAIToolDetails } from '../grpc' import { AIChatQSData, @@ -24,6 +23,7 @@ import { OutlineChevronsDownUpIcon, OutlineChevronsUpDownIcon, OutlineClockIcon, + OutlineDocumentduplicateIcon, OutlineRefreshIcon, OutlineWrenchIcon1, } from '@/assets/icon/outline' @@ -38,10 +38,11 @@ import { YakitPopconfirm } from '@/components/yakitUI/YakitPopconfirm/YakitPopco import { AIChatIPCSendParams } from '../useContext/ChatIPCContent/ChatIPCContent' import { AIReferenceNode } from '@/pages/ai-re-act/aiReActChatContents/AIReActChatContents' import { useStreamingChatContent } from './aiChatListItem/StreamingChatContent/hooks/useStreamingChatContent' -import { YakitRadioButtons } from '@/components/yakitUI/YakitRadioButtons/YakitRadioButtons' -import { AIReviewParams } from './aiReviewResult/AIReviewResult' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { isAuxOrChildWindow } from '@/utils/isAuxOrChildWindow' +import { YakitModal } from '@/components/yakitUI/YakitModal/YakitModal' +import { setClipboardText } from '@/utils/clipboard' +import { success } from '@/utils/notification' /** @name AI工具按钮对应图标 */ const AIToolToIconMap: Record = { @@ -64,8 +65,7 @@ interface PreWrapperProps { style?: React.CSSProperties } interface ToolStatusCardProps { - status: AIToolResult['tool']['status'] | 'purple' - title: ReactNode + status: AIToolResult['tool']['status'] | 'purple' | 'neutral' children?: ReactNode } interface ToolStdoutCardProps extends ToolInvokerCardProps { @@ -217,12 +217,9 @@ const ToolStdoutCard: React.FC = memo((props) => { } footer={} > - {data.toolName}}> -
- {stream?.data?.content && ( - - )} -
+ + + {referenceNode} {!!fileList?.length && } @@ -238,7 +235,6 @@ const ToolResultCard: React.FC = memo((props) => { const { fetchChatDataStore } = useChatIPCDispatcher().chatIPCEvents const [loading, setLoading] = useState(false) - const [type, setType] = useState<'outInput' | 'params'>('outInput') const [expand, setExpand] = useState(false) const expandToggle = useMemoizedFn(() => { @@ -273,12 +269,6 @@ const ToolResultCard: React.FC = memo((props) => { return data?.tool?.status }, [data?.tool?.status]) - const [statusColor, statusText] = useMemo(() => { - if (status === 'success') return ['success', t('ToolInvokerCard.success')] - if (status === 'failed') return ['danger', t('ToolInvokerCard.failed')] - return ['white', t('ToolInvokerCard.cancelled')] - }, [status, i18n.language]) - const params = useCreation(() => { return data?.callToolId }, [data?.callToolId]) @@ -346,42 +336,16 @@ const ToolResultCard: React.FC = memo((props) => { return desc.join('\n') }) - const renderContent = useMemoizedFn(() => { - switch (type) { - case 'params': - return - default: - return ( - <> -
- {summary} -
- {!!resultDetails ? ( - <> - - - ) : ( - <>{content && } - )} - - ) - } - }) + const outputText = useCreation(() => { + return resultDetails || content || '' + }, [resultDetails, content]) + return ( } titleMore={
- {/*
*/} - {/* {!!startTime && ( -
- {t('ToolInvokerCard.startTime')}:{startTime} -
- )} */} - {/*
*/} - - {/*
*/} {!!riskFlowDataCount && ( <> )} - {/*
*/} {isChildWindow || ( } onClick={getListToolList} /> @@ -448,39 +411,10 @@ const ToolResultCard: React.FC = memo((props) => { } > {expand && ( - -
-
{data.toolName}
- - {statusText} - -
- { - setType(e.target.value) - }} - /> -
- } - > + -
{renderContent()}
+ +
)} @@ -490,11 +424,137 @@ const ToolResultCard: React.FC = memo((props) => { }) const ToolStatusCard: React.FC = memo((props) => { - const { status, title, children } = props + const { status, children } = props return (
-
{title}
- {children} +
{children}
+
+ ) +}) + +/** @name 伪终端参数行:单行 JSON 预览,点击弹框展示完整 JSON */ +const ToolParamsLine: FC<{ params?: Record }> = memo(({ params }) => { + const [open, setOpen] = useState(false) + const { t } = useI18nNamespaces(['yakitUi']) + + if (!params || Object.keys(params).length === 0) return null + + const jsonStr = JSON.stringify(params) + const jsonPretty = JSON.stringify(params, null, 2) + + const onCopyAll = () => { + setClipboardText(jsonPretty, { + hiddenHint: true, + finalCallback: () => setTimeout(() => success(t('YakitNotification.copySuccess')), 200), + }) + } + const onCopyField = (value: any) => { + const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + setClipboardText(text, { + hiddenHint: true, + finalCallback: () => setTimeout(() => success(t('YakitNotification.copySuccess')), 200), + }) + } + + return ( + <> +
+ Params: + setOpen(true)} title={jsonStr}> + {jsonStr} + +
+ setOpen(false)} + cancelButtonProps={{ style: { display: 'none' } }} + onOk={() => setOpen(false)} + width={560} + > +
+
+ {jsonPretty} +
+ +
+
+ {Object.entries(params).map(([key, value]) => ( +
+ {key}: + + {typeof value === 'string' ? value : JSON.stringify(value, null, 2)} + + + onCopyField(value)}> + + + +
+ ))} +
+
+ + ) +}) + +/** @name 伪终端输出区域 */ +const ToolTerminalOutput: FC<{ + content: string + autoScrollBottom?: boolean +}> = memo(({ content, autoScrollBottom = false }) => { + const containerRef = useRef(null) + const [isAtBottom, setIsAtBottom] = useState(true) + const [isAtTop, setIsAtTop] = useState(true) + const [isScroll, setIsScroll] = useState(false) + + useClickAway(() => { + setIsScroll(false) + }, containerRef) + + useEffect(() => { + if (!autoScrollBottom) return + const el = containerRef.current + if (!el) return + const handleScroll = () => { + const threshold = 20 + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold + const atTop = el.scrollTop < threshold + setIsAtBottom(atBottom) + setIsAtTop(atTop) + } + handleScroll() + el.addEventListener('scroll', handleScroll) + return () => el.removeEventListener('scroll', handleScroll) + }, [autoScrollBottom]) + + useEffect(() => { + if (!autoScrollBottom) return + const el = containerRef.current + if (!el) return + if (isAtBottom) { + el.scrollTop = el.scrollHeight + } + }, [content, isAtBottom, autoScrollBottom]) + + if (!content) return null + + return ( +
+
setIsScroll(true)} + > + {content} +
+ +
+
) }) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.tsx index b434ece0f1..158c4365cc 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.tsx @@ -174,81 +174,85 @@ const AIGroupStreamCard: FC<{ } }, [hasNext]) if (!stream) return null + const collapseTooltip = !expand && elements.length > 1 ? `折叠${elements.length}条信息` : '' + return ( -
+
{ - setExpand(!expand) - }} - > -
- {/* */} - {nodeLabel} -
-
- {shouldShowMask &&
} -

- {content} -

-
- - } - className={classNames(styles['expand-btn'], { - [styles['hidden-expand-btn']]: expand, - })} - /> - - - } - className={classNames(styles['expand-btn'], { - [styles['hidden-expand-btn']]: !expand, - })} - /> - -
-
setIsScroll(true)} - className={styles['content-inner']} - style={{ - overflow: isScroll ? 'overlay' : 'hidden', + className={styles.title} + onClick={() => { + setExpand(!expand) }} > - {elements.map((el, index) => ( - + {/* */} + {nodeLabel} +
+
+ {shouldShowMask &&
} +

+ {content} +

+
+ + } + className={classNames(styles['expand-btn'], { + [styles['hidden-expand-btn']]: expand, + })} /> - ))} + + + } + className={classNames(styles['expand-btn'], { + [styles['hidden-expand-btn']]: !expand, + })} + /> + +
+
+
setIsScroll(true)} + className={styles['content-inner']} + style={{ + overflow: isScroll ? 'overlay' : 'hidden', + }} + > + {elements.map((el, index) => ( + + ))} +
-
+
) } export default AIGroupStreamCard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/utils.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/utils.ts index 7a7cedbdbc..36ff93cc7a 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/utils.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/utils.ts @@ -106,6 +106,9 @@ export const parseMentions = (markdown: string): Mention[] => { export const setEditorValue = (editor: EditorMilkdownProps, value: string) => { editor?.action((ctx) => { const view = ctx.get(editorViewCtx) + // 关键词: prosemirror view isDestroyed guard, 防止对已销毁 view dispatch + // 编辑器卸载后 view 仍可能被外部引用(例如 ref 持有), 此时 dispatch 会抛错或污染内部状态. + if (view?.isDestroyed) return const parser = ctx.get(parserCtx) const doc = parser(value) diff --git a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.module.scss b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.module.scss index 0530007e9f..402375a7df 100644 --- a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.module.scss @@ -28,6 +28,13 @@ font-size: 12px; font-weight: 500; + .title-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .history-count-tag { min-width: 24px; justify-content: center; diff --git a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.tsx b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.tsx index 7d15b85ce0..564e8b7503 100644 --- a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.tsx @@ -315,7 +315,7 @@ const HistoryChat = memo(({ aiSource, embedded }: HistoryChatProps) => {
- {t('HistoryChat.title')} + {t('HistoryChat.title')} {visibleSessions.length} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.tsx index a47d2efdd3..34b01612b0 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.tsx +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.tsx @@ -101,7 +101,22 @@ export const AIReActChat: React.FC = React.memo( }, []) useEffect(() => { if (!!activeChat?.SessionID) { - aiChatTextareaRef.current.setValue('') + // 关键词: flushSync warning, Milkdown ReactRenderer, prosemirror-adapter flushSync + // setEditorValue 内部会 view.dispatch(tr), 经 ProseMirror updateState -> updatePluginViews + // 触发 @prosemirror-adapter/react 的 ReactRenderer.update, 内部会 flushSync 重新渲染 portal. + // 而本段处于 useEffect(passive mount) 阶段, React 已经在渲染中, 同步 dispatch 会让第三方库的 + // flushSync 撞上 "React cannot flush when React is already rendering" 警告. + // 用 queueMicrotask 把 dispatch 推迟到当前渲染周期之后, 时序最接近原行为. + // cancelled 守卫: 防止微任务执行前组件已卸载, 避免对已销毁的 editor 做无意义写入. + let cancelled = false + queueMicrotask(() => { + if (!cancelled) { + aiChatTextareaRef.current?.setValue('') + } + }) + return () => { + cancelled = true + } } }, [activeChat?.SessionID]) // #region 问题相关逻辑 diff --git a/app/renderer/src/main/src/pages/yakRunner/BottomEditorDetails/TerminalBox/TerminalBox.tsx b/app/renderer/src/main/src/pages/yakRunner/BottomEditorDetails/TerminalBox/TerminalBox.tsx index 5a6a7e93df..7fbdcfc6e0 100644 --- a/app/renderer/src/main/src/pages/yakRunner/BottomEditorDetails/TerminalBox/TerminalBox.tsx +++ b/app/renderer/src/main/src/pages/yakRunner/BottomEditorDetails/TerminalBox/TerminalBox.tsx @@ -246,7 +246,7 @@ export const useTerminalHook = (props: useTerminalHookProps) => { setTerminalRunnerId('') setReloadTerminal(true) setRefreshList(!refreshList) - terminalRef.current.terminal.reset() + xtermClear(terminalRef) const main = document.getElementById('yakit-runnner-main-box-id') if (main) { main.focus() @@ -429,7 +429,7 @@ export const useTerminalHook = (props: useTerminalHookProps) => { try { if (terminalCache && terminalRef.current) { const cache: TerminalDetailsProps = JSON.parse(terminalCache) - terminalRef.current.terminal.reset() + xtermClear(terminalRef) writeXTerm(terminalRef, cache.content) terminalRef.current.terminal.scrollToBottom() } diff --git a/app/renderer/src/main/src/utils/xtermUtils.ts b/app/renderer/src/main/src/utils/xtermUtils.ts index 1fa63cf47e..bd888d4e2b 100644 --- a/app/renderer/src/main/src/utils/xtermUtils.ts +++ b/app/renderer/src/main/src/utils/xtermUtils.ts @@ -12,14 +12,39 @@ export const writeExecResultXTerm = (xterm: any, result: ExecResult, encoding?: } } +// 关键词: xterm dimensions crash, _renderService undefined, terminal.reset 报错 +// xterm 的 RenderService.dimensions getter 在 renderer 未就绪/已 dispose 时会抛 +// "Cannot read properties of undefined (reading 'dimensions')"。 +// 该校验用于在 open 尚未完成、或组件已被 dispose 时,避免 reset/resize 触发 Viewport.syncScrollArea 崩溃。 +export const isTerminalReady = (terminal: any): boolean => { + if (!terminal) return false + // terminal.element 仅在 Terminal.open 成功后被赋值 + if (!terminal.element) return false + // 兜底:能拿到内部 RenderService 时,用其公开的 hasRenderer 判定 + const renderService = terminal._core?._renderService + if (renderService && typeof renderService.hasRenderer === 'function') { + return renderService.hasRenderer() + } + // 拿不到内部结构时,退化成仅依赖 element 是否存在 + return true +} + export const xtermFit = (xtermRef: any, columns?: number, rows?: number) => { - if (xtermRef && xtermRef?.current && xtermRef.current.terminal) { - xtermRef.current.terminal.resize(columns || 100, rows || 10) + if (xtermRef && xtermRef?.current && isTerminalReady(xtermRef.current.terminal)) { + try { + xtermRef.current.terminal.resize(columns || 100, rows || 10) + } catch (e) { + // 关键词: xterm resize error swallowed, 防止 _renderService.dimensions 崩溃冒泡到 UI + } } } export const xtermClear = (xtermRef: any, columns?: number, rows?: number) => { - if (xtermRef && xtermRef?.current && xtermRef.current.terminal) { - xtermRef.current.terminal.reset() + if (xtermRef && xtermRef?.current && isTerminalReady(xtermRef.current.terminal)) { + try { + xtermRef.current.terminal.reset() + } catch (e) { + // 关键词: xterm reset error swallowed, 防止 Viewport.syncScrollArea 在 renderer 未就绪时崩溃 + } } }