diff --git a/packages/ai-native/package.json b/packages/ai-native/package.json index f8b8852042..757325d6aa 100644 --- a/packages/ai-native/package.json +++ b/packages/ai-native/package.json @@ -46,7 +46,9 @@ "@opensumi/ide-workspace": "workspace:*", "@xterm/xterm": "5.5.0", "ai": "^4.1.45", + "ansi-escapes": "5.0.0", "ansi-regex": "^2.0.0", + "ansi_up": "^5.1.0", "diff": "^7.0.0", "dom-align": "^1.7.0", "eventsource": "^3.0.5", diff --git a/packages/ai-native/src/browser/chat/chat.view.tsx b/packages/ai-native/src/browser/chat/chat.view.tsx index 25ecc40986..3fc43f661c 100644 --- a/packages/ai-native/src/browser/chat/chat.view.tsx +++ b/packages/ai-native/src/browser/chat/chat.view.tsx @@ -782,11 +782,8 @@ export function DefaultChatViewHeader({ React.useEffect(() => { const getHistoryList = () => { const currentMessages = aiChatService.sessionModel.history.getMessages(); - setCurrentTitle( - currentMessages.length > 0 - ? currentMessages[currentMessages.length - 1].content.slice(0, MAX_TITLE_LENGTH) - : '', - ); + const latestUserMessage = currentMessages.findLast((m) => m.role === ChatMessageRole.User); + setCurrentTitle(latestUserMessage ? latestUserMessage.content.slice(0, MAX_TITLE_LENGTH) : ''); setHistoryList( aiChatService.getSessions().map((session) => { const history = session.history; diff --git a/packages/ai-native/src/browser/components/ChatToolRender.module.less b/packages/ai-native/src/browser/components/ChatToolRender.module.less index d9c23d4523..f5a1437525 100644 --- a/packages/ai-native/src/browser/components/ChatToolRender.module.less +++ b/packages/ai-native/src/browser/components/ChatToolRender.module.less @@ -1,14 +1,14 @@ -.chat-tool-render { +.chat_tool_render { margin: 8px 0; border: 1px solid var(--design-borderColor); border-radius: 6px; overflow: hidden; - .tool-header { + .tool_header { display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; + padding: 4px; background-color: var(--design-block-background); cursor: pointer; user-select: none; @@ -18,14 +18,25 @@ } } - .tool-name { + .tool_name { + font-size: 12px; display: flex; align-items: center; font-weight: 500; color: var(--design-text-foreground); } - .expand-icon { + .tool_icon { + font-size: 12px !important; + } + + .tool_label { + margin-left: 5px; + font-size: 11px; + color: var(--descriptionForeground); + } + + .expand_icon { display: inline-block; margin-right: 8px; transition: transform 0.2s; @@ -36,29 +47,29 @@ } } - .tool-state { + .tool_state { display: flex; align-items: center; font-size: 12px; color: var(--design-text-placeholderForeground); } - .state-icon { + .state_icon { display: flex; align-items: center; margin-right: 6px; } - .loading-icon { + .loading_icon { width: 12px; height: 12px; } - .state-label { + .state_label { margin-left: 4px; } - .tool-content { + .tool_content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; @@ -69,18 +80,19 @@ } } - .tool-arguments, - .tool-result { - padding: 12px; + .tool_arguments, + .tool_result { + font-size: 11px; + padding: 5px; } - .section-label { + .section_label { font-size: 12px; color: var(--design-text-placeholderForeground); margin-bottom: 8px; } - .tool-result { + .tool_result { border-top: 1px solid var(--design-borderColor); } } diff --git a/packages/ai-native/src/browser/components/ChatToolRender.tsx b/packages/ai-native/src/browser/components/ChatToolRender.tsx index b8004da8c7..2dea719547 100644 --- a/packages/ai-native/src/browser/components/ChatToolRender.tsx +++ b/packages/ai-native/src/browser/components/ChatToolRender.tsx @@ -5,6 +5,7 @@ import { useInjectable } from '@opensumi/ide-core-browser'; import { Icon } from '@opensumi/ide-core-browser/lib/components'; import { Loading } from '@opensumi/ide-core-browser/lib/components/ai-native'; import { IChatToolContent, uuid } from '@opensumi/ide-core-common'; +import { localize } from '@opensumi/ide-core-common/lib/localize'; import { IMCPServerRegistry, TokenMCPServerRegistry } from '../types'; @@ -64,28 +65,29 @@ export const ChatToolRender = (props: { value: IChatToolContent['content']; mess toolCallId={value.id} /> ) : ( -
-
-
- - {label} +
+
+
+ + + {label}
{value.state && ( -
- {stateInfo.icon} +
+ {stateInfo.icon}
)}
-
+
{value?.function?.arguments && ( -
-
Arguments
+
+
{localize('ai.native.mcp.tool.arguments')}:
)} {value?.result && ( -
-
Result
+
+
{localize('ai.native.mcp.tool.results')}:
)} diff --git a/packages/ai-native/src/browser/components/WelcomeMsg.tsx b/packages/ai-native/src/browser/components/WelcomeMsg.tsx index c52b961799..c07a853781 100644 --- a/packages/ai-native/src/browser/components/WelcomeMsg.tsx +++ b/packages/ai-native/src/browser/components/WelcomeMsg.tsx @@ -79,7 +79,7 @@ export const WelcomeMessage = () => { return (
- {isMarkdownString(welcomeMessage) ? : welcomeMessage} + {isMarkdownString(welcomeMessage) ? : welcomeMessage}
{allSampleQuestions.map((data: any, index) => { diff --git a/packages/ai-native/src/browser/mcp/config/components/mcp-config.view.tsx b/packages/ai-native/src/browser/mcp/config/components/mcp-config.view.tsx index 27fe5c70cd..c17c74edd7 100644 --- a/packages/ai-native/src/browser/mcp/config/components/mcp-config.view.tsx +++ b/packages/ai-native/src/browser/mcp/config/components/mcp-config.view.tsx @@ -5,6 +5,7 @@ import { Badge } from '@opensumi/ide-components'; import { AINativeSettingSectionsId, ILogger, useInjectable } from '@opensumi/ide-core-browser'; import { PreferenceService } from '@opensumi/ide-core-browser/lib/preferences'; import { localize } from '@opensumi/ide-core-common'; +import { IMessageService } from '@opensumi/ide-overlay/lib/common'; import { BUILTIN_MCP_SERVER_NAME, ISumiMCPServerBackend, SumiMCPServerProxyServicePath } from '../../../../common'; import { MCPServerDescription } from '../../../../common/mcp-server-manager'; @@ -19,6 +20,7 @@ export const MCPConfigView: React.FC = () => { const preferenceService = useInjectable(PreferenceService); const sumiMCPServerBackendProxy = useInjectable(SumiMCPServerProxyServicePath); const logger = useInjectable(ILogger); + const messageService = useInjectable(IMessageService); const [servers, setServers] = React.useState([]); const [formVisible, setFormVisible] = React.useState(false); const [editingServer, setEditingServer] = React.useState(); @@ -88,7 +90,9 @@ export const MCPConfigView: React.FC = () => { await preferenceService.set(AINativeSettingSectionsId.MCPServers, updatedServers); await loadServers(); } catch (error) { + const msg = error.message || error; logger.error(`Failed to ${start ? 'start' : 'stop'} server ${serverName}:`, error); + messageService.error(`Failed to ${start ? 'start' : 'stop'} server ${serverName}:` + msg); } }, [mcpServerProxyService, preferenceService, sumiMCPServerBackendProxy, loadServers], diff --git a/packages/ai-native/src/browser/mcp/config/mcp-config.contribution.ts b/packages/ai-native/src/browser/mcp/config/mcp-config.contribution.ts index 311b8f28fc..8344e12054 100644 --- a/packages/ai-native/src/browser/mcp/config/mcp-config.contribution.ts +++ b/packages/ai-native/src/browser/mcp/config/mcp-config.contribution.ts @@ -1,6 +1,6 @@ import { Autowired } from '@opensumi/di'; import { LabelService } from '@opensumi/ide-core-browser/lib/services'; -import { Domain, Schemes, URI } from '@opensumi/ide-core-common'; +import { Domain, URI } from '@opensumi/ide-core-common'; import { BrowserEditorContribution, EditorComponentRegistry, diff --git a/packages/ai-native/src/browser/mcp/tools/components/ExpandableFileList.tsx b/packages/ai-native/src/browser/mcp/tools/components/ExpandableFileList.tsx index c82fcaeeb3..f2904b97e0 100644 --- a/packages/ai-native/src/browser/mcp/tools/components/ExpandableFileList.tsx +++ b/packages/ai-native/src/browser/mcp/tools/components/ExpandableFileList.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { Icon } from '@opensumi/ide-components/lib/icon/icon'; import { CommandService, LabelService, URI, path, useInjectable } from '@opensumi/ide-core-browser'; import { WorkbenchEditorService } from '@opensumi/ide-editor'; import { IWorkspaceService } from '@opensumi/ide-workspace'; @@ -108,7 +109,9 @@ const ExpandableFileList: React.FC = ({ return (
setIsExpanded(!isExpanded)}> - + + + {headerText} · {fileList.length} files diff --git a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx index c6d560148d..bb469d016d 100644 --- a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx +++ b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx @@ -3,11 +3,11 @@ import React, { memo, useCallback, useMemo, useState } from 'react'; import { useInjectable } from '@opensumi/ide-core-browser'; import { Button, Icon } from '@opensumi/ide-core-browser/lib/components'; import { localize } from '@opensumi/ide-core-common'; -import { stripAnsi } from '@opensumi/ide-utils/lib/ansi'; import { IMCPServerToolComponentProps } from '../../../types'; import { RunCommandHandler } from '../handlers/RunCommand'; +import { computeAnsiLogString } from './computeAnsiLogString'; import styles from './index.module.less'; function getResult(raw: string) { @@ -63,19 +63,17 @@ export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps) <>
- {localize('ai.native.mcp.terminal.command')} + {localize('ai.native.mcp.terminal.command')}:

$ {args.command}

)} -
- {localize('ai.native.mcp.terminal.output')} -
{output ? (
- {stripAnsi(output.text)} + +
) : ( '' diff --git a/packages/ai-native/src/browser/mcp/tools/components/computeAnsiLogString.ts b/packages/ai-native/src/browser/mcp/tools/components/computeAnsiLogString.ts new file mode 100644 index 0000000000..664d2d5004 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/components/computeAnsiLogString.ts @@ -0,0 +1,24 @@ +import AnsiUp from 'ansi_up'; + +import filterEraseMultipleLine from './filterEraseMultipleLine'; + +type LogContent = string; + +const ansiUp = new AnsiUp(); + +export function computeAnsiLogString(logs: LogContent, enableEraseLineFilter = true, hideEmptyLine = false): string { + const splittedLogs = logs.split('\n'); + // 处理清空上行逻辑 + // 上移 cursor + 清空整行 + let filteredLogs = enableEraseLineFilter ? filterEraseMultipleLine(splittedLogs) : splittedLogs; + if (hideEmptyLine) { + filteredLogs = filteredLogs.map((line) => line.replace('\r', '')).filter((line) => !!line); + } + + const htmlLogLines = filteredLogs.map((line) => { + const htmlLog = ansiUp.ansi_to_html(line); + + return htmlLog; + }); + return htmlLogLines.join('\n'); +} diff --git a/packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts b/packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts new file mode 100644 index 0000000000..168991eaa2 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts @@ -0,0 +1,67 @@ +import ansiEscapes from 'ansi-escapes'; + +/** + * 处理过滤清空上行,清空本行逻辑。 + * + * 关于清空上 n 行: + * 一般在日志中,出现覆盖上行的情况,ascii 编码为 2K [1A 2K ...] 1G 的样式。 如 \u001b[2K\u001b[1A\u001b[2K\u001b[1A\u001b[2K\u001b[G\r\n,代表清空上两行。 + * 其中,2K 代表清空整行,1A 代表光标上移,配合下一个 2K 则最终效果为清空上行,而 1G 是移动光标到本行开始(位置 1)。 + * 在日志过滤过程中,可以只处理 1A 2K 这个序列,遇到后把该日志的上一行删掉即可。 + * + * 关于清空本行,按顺序执行: + * 1. 在当前行没有 Cursor 操作符(如上移时),匹配最后一个 [2K (清空本行)或 \r[K(指针回 0,再清空本行到末尾,相当于清空本行),只输出 [2K 后的内容, + * 2. 在当前行没有 Cursor 操作符时且有多个 \r (carriage return charactor,移动光标到行首)时,reduce 按 \r \x1b[G 或 \x1b[1G 切分的片断,不段用后一 part 的部分从头覆盖得出结果。 + */ +export default function filterEraseMultipleLine(logs: string[]) { + // 上移 cursor + 清空整行 + const eraseLastLine = ansiEscapes.cursorUp(1) + ansiEscapes.eraseLine; + const eraseCurrentLine = ansiEscapes.eraseLine; + const eraseCurrentLine2 = `\r${ansiEscapes.eraseEndLine}`; + + const moveCursorToLeftRegStrs = ['\\r', '\\u001b\\[G', '\\u001b\\[1G']; + const moveCursorToLeftRegStr = new RegExp(`${moveCursorToLeftRegStrs.join('|')}`); + + const filteredLogs = logs.reduce((acc: string[], nowLine) => { + // 当前清空上行搜索指针 + let pos = 0; + const step = eraseLastLine.length; + + while (true) { + pos = nowLine.indexOf(eraseLastLine, pos); + // 出现清空上行 + if (pos >= 0) { + pos += step; + acc.pop(); + } else { + break; + } + } + + // 对单行日志的重写做处理 + // 简单处理,不去解析真正的 Cursor 所在行,否则逻辑过于麻烦 + // 处理 [2K + let lastErasePos = nowLine.lastIndexOf(eraseCurrentLine); + if (lastErasePos < 0) { + // 处理 \r[K + lastErasePos = nowLine.lastIndexOf(eraseCurrentLine2); + } + if (lastErasePos > 0) { + // 从后向前搜索最后一个清行操作 + nowLine = nowLine.slice(lastErasePos); + } + + // 处理多 \r 情况,当 \r 连续时,切分出的空字段无用,过滤掉 + const carriageRewrites = nowLine.split(moveCursorToLeftRegStr).filter((part) => !!part); + if (carriageRewrites.length > 1) { + nowLine = carriageRewrites.reduce((nextNowLine, nowPart) => { + const leftPart = nextNowLine.slice(nowPart.length); + return nowPart + leftPart; + }, ''); + } + + acc.push(nowLine); + return acc; + }, []); + + return filteredLogs; +} diff --git a/packages/ai-native/src/browser/mcp/tools/components/index.module.less b/packages/ai-native/src/browser/mcp/tools/components/index.module.less index c85fd99c4d..438e23ab1e 100644 --- a/packages/ai-native/src/browser/mcp/tools/components/index.module.less +++ b/packages/ai-native/src/browser/mcp/tools/components/index.module.less @@ -96,7 +96,7 @@ } .header { - padding: 8px 12px; + padding: 4px; background-color: var(--design-block-background); border-bottom: 1px solid var(--vscode-commandCenter-inactiveBorder); cursor: pointer; @@ -104,7 +104,7 @@ align-items: center; gap: 4px; color: var(--design-text-foreground); - font-size: 12px; + font-size: 11px; } .fileList { @@ -162,14 +162,17 @@ .command_title { display: flex; align-items: center; + font-size: 11px; span { margin-left: 5px; } } .command_content { - padding: 4px; - font-size: 12px; + max-height: 200px; + overflow-y: auto; + padding: 2px 4px; + font-size: 11px; color: var(--design-text-foreground); margin: 0px; background-color: var(--terminal-background); @@ -178,7 +181,7 @@ overflow: auto; code { - font-size: 12px; + font-size: 11px; white-space: pre; } } diff --git a/packages/components/src/markdown-react/parse.tsx b/packages/components/src/markdown-react/parse.tsx index 61ba08bc68..1bb71e50fb 100644 --- a/packages/components/src/markdown-react/parse.tsx +++ b/packages/components/src/markdown-react/parse.tsx @@ -59,9 +59,12 @@ export class MarkdownReactParser extends marked.Renderer { listItemChildren.push(this.parse(item.tokens)); - return React.cloneElement(this.renderer.listItem(listItemChildren) as React.ReactElement, { - key: `list-item-${itemIndex}`, - }); + return React.cloneElement( + this.renderer.listItem(listItemChildren) as React.ReactElement, + { + key: `list-item-${itemIndex}`, + }, + ); }); return this.renderer.list(children, token.ordered); @@ -83,9 +86,12 @@ export class MarkdownReactParser extends marked.Renderer { ), ); - const headerRow = React.cloneElement(this.renderer.tableRow(headerCells) as React.ReactElement, { - key: 'header-row', - }); + const headerRow = React.cloneElement( + this.renderer.tableRow(headerCells) as React.ReactElement, + { + key: 'header-row', + }, + ); const header = this.renderer.tableHeader(headerRow); const bodyChilren = tableToken.rows.map((row, rowIndex) => { @@ -99,9 +105,12 @@ export class MarkdownReactParser extends marked.Renderer { ), ); - return React.cloneElement(this.renderer.tableRow(rowChildren) as React.ReactElement, { - key: `body-row-${rowIndex}`, - }); + return React.cloneElement( + this.renderer.tableRow(rowChildren) as React.ReactElement, + { + key: `body-row-${rowIndex}`, + }, + ); }); const body = this.renderer.tableBody(bodyChilren); @@ -128,20 +137,11 @@ export class MarkdownReactParser extends marked.Renderer { }); } - private unescapeInfo = new Map([ - ['"', '"'], - [' ', ' '], - ['&', '&'], - [''', "'"], - ['<', '<'], - ['>', '>'], - ]); - parseInline(tokens: marked.Token[] = []): ReactNode[] { return tokens.map((token) => { switch (token.type) { case 'text': { - const text = token.text.replace(/&(#\d+|[a-zA-Z]+);/g, (m) => this.unescapeInfo.get(m) ?? m); + const text = htmlUnescape(token.text); return this.renderer.text(text); } @@ -190,3 +190,11 @@ export class MarkdownReactParser extends marked.Renderer { }); } } + +function htmlUnescape(htmlStr) { + return htmlStr + .replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec)) + .replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => + String.fromCharCode(parseInt(hex, 16)), + ); +} diff --git a/packages/connection/src/common/connection/drivers/frame-decoder.ts b/packages/connection/src/common/connection/drivers/frame-decoder.ts index 5a2505d36d..3010bbdcde 100644 --- a/packages/connection/src/common/connection/drivers/frame-decoder.ts +++ b/packages/connection/src/common/connection/drivers/frame-decoder.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import { BinaryWriter } from '@furyjs/fury/dist/lib/writer'; -import { MaybeNull, readUInt32LE } from '@opensumi/ide-core-common'; +import { MaybeNull, readUInt32LE, setImmediate } from '@opensumi/ide-core-common'; import { Buffers } from '../../buffers/buffers'; diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index 3db5645ad4..017c1305c5 100644 --- a/packages/i18n/src/common/en-US.lang.ts +++ b/packages/i18n/src/common/en-US.lang.ts @@ -1615,5 +1615,9 @@ export const localizationBundle = { 'ai.native.mcp.name.isRequired': 'Server name is required', 'ai.native.mcp.command.isRequired': 'Command is required', 'ai.native.mcp.serverHost.isRequired': 'SSE URL is required', + + // MCP View + 'ai.native.mcp.tool.arguments': 'Arguments', + 'ai.native.mcp.tool.results': 'Result', }, }; diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index 3d0fc90336..550674943b 100644 --- a/packages/i18n/src/common/zh-CN.lang.ts +++ b/packages/i18n/src/common/zh-CN.lang.ts @@ -1378,5 +1378,9 @@ export const localizationBundle = { 'ai.native.mcp.name.isRequired': '服务名称不能为空', 'ai.native.mcp.command.isRequired': '命令不能为空', 'ai.native.mcp.serverHost.isRequired': 'SSE URL 不能为空', + + // MCP View + 'ai.native.mcp.tool.arguments': '参数', + 'ai.native.mcp.tool.results': '返回', }, }; diff --git a/yarn.lock b/yarn.lock index 8f97ec1ee2..dcf937a0d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3418,7 +3418,9 @@ __metadata: "@opensumi/ide-workspace": "workspace:*" "@xterm/xterm": "npm:5.5.0" ai: "npm:^4.1.45" + ansi-escapes: "npm:5.0.0" ansi-regex: "npm:^2.0.0" + ansi_up: "npm:^5.1.0" diff: "npm:^7.0.0" dom-align: "npm:^1.7.0" eventsource: "npm:^3.0.5" @@ -6991,6 +6993,15 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:5.0.0": + version: 5.0.0 + resolution: "ansi-escapes@npm:5.0.0" + dependencies: + type-fest: "npm:^1.0.2" + checksum: 10/cbfb95f9f6d8a1ffc89f50fcda3313effae2d9ac2f357f89f626815b4d95fdc3f10f74e0887614ff850d01f805b7505eb1e7ebfdd26144bbfc26c5de08e19195 + languageName: node + linkType: hard + "ansi-escapes@npm:^4.2.1": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" @@ -7076,6 +7087,13 @@ __metadata: languageName: node linkType: hard +"ansi_up@npm:^5.1.0": + version: 5.2.1 + resolution: "ansi_up@npm:5.2.1" + checksum: 10/abf4f2e4abc1f54ef41d5668b22967c33117faf31308211858ff3bb898f4839f97a7bde081d6eb988532a90a1ccd37f1ef5c0f0f3e409d1b96ed4c5bd41d11c7 + languageName: node + linkType: hard + "antd@npm:^5.21.4": version: 5.21.4 resolution: "antd@npm:5.21.4" @@ -24094,6 +24112,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^1.0.2": + version: 1.4.0 + resolution: "type-fest@npm:1.4.0" + checksum: 10/89875c247564601c2650bacad5ff80b859007fbdb6c9e43713ae3ffa3f584552eea60f33711dd762e16496a1ab4debd409822627be14097d9a17e39c49db591a + languageName: node + linkType: hard + "type-fest@npm:^4.4.0": version: 4.12.0 resolution: "type-fest@npm:4.12.0"