Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: tools view #4457

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 2 additions & 5 deletions packages/ai-native/src/browser/chat/chat.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}
}
26 changes: 14 additions & 12 deletions packages/ai-native/src/browser/components/ChatToolRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -64,28 +65,29 @@ export const ChatToolRender = (props: { value: IChatToolContent['content']; mess
toolCallId={value.id}
/>
) : (
<div className={styles['chat-tool-render']}>
<div className={styles['tool-header']} onClick={toggleExpand}>
<div className={styles['tool-name']}>
<Icon iconClass={`codicon codicon-triangle-${isExpanded ? 'down' : 'right'}`} />
{label}
<div className={styles.chat_tool_render}>
<div className={styles.tool_header} onClick={toggleExpand}>
<div className={styles.tool_name}>
<Icon iconClass={`codicon codicon-chevron-${isExpanded ? 'down' : 'right'}`} />
<Icon size='small' iconClass={cls('codicon codicon-tools', styles.tool_icon)} />
<span className={styles.tool_label}>{label}</span>
</div>
{value.state && (
<div className={styles['tool-state']}>
<span className={styles['state-icon']}>{stateInfo.icon}</span>
<div className={styles.tool_state}>
<span className={styles.state_icon}>{stateInfo.icon}</span>
</div>
)}
</div>
<div className={cls(styles['tool-content'], { [styles.expanded]: isExpanded })}>
<div className={cls(styles.tool_content, { [styles.expanded]: isExpanded })}>
{value?.function?.arguments && (
<div className={styles['tool-arguments']}>
<div className={styles['section-label']}>Arguments</div>
<div className={styles.tool_arguments}>
<div className={styles.section_label}>{localize('ai.native.mcp.tool.arguments')}:</div>
<CodeEditorWithHighlight input={value?.function?.arguments} language={'json'} relationId={uuid(4)} />
</div>
)}
{value?.result && (
<div className={styles['tool-result']}>
<div className={styles['section-label']}>Result</div>
<div className={styles.tool_result}>
<div className={styles.section_label}>{localize('ai.native.mcp.tool.results')}:</div>
<CodeEditorWithHighlight input={value.result} language={'json'} relationId={uuid(4)} />
</div>
)}
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-native/src/browser/components/WelcomeMsg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const WelcomeMessage = () => {
return (
<div className={styles.chat_welcome_head}>
<div className={styles.chat_container_des}>
{isMarkdownString(welcomeMessage) ? <ChatMarkdown markdown={welcomeMessage} /> : welcomeMessage}
{isMarkdownString(welcomeMessage) ? <ChatMarkdown key='welcome' markdown={welcomeMessage} /> : welcomeMessage}
</div>
<div className={styles.chat_container_content}>
{allSampleQuestions.map((data: any, index) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,6 +20,7 @@ export const MCPConfigView: React.FC = () => {
const preferenceService = useInjectable<PreferenceService>(PreferenceService);
const sumiMCPServerBackendProxy = useInjectable<ISumiMCPServerBackend>(SumiMCPServerProxyServicePath);
const logger = useInjectable<ILogger>(ILogger);
const messageService = useInjectable<IMessageService>(IMessageService);
const [servers, setServers] = React.useState<MCPServer[]>([]);
const [formVisible, setFormVisible] = React.useState(false);
const [editingServer, setEditingServer] = React.useState<MCPServerFormData | undefined>();
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -108,7 +109,9 @@ const ExpandableFileList: React.FC<ExpandableFileListProps> = ({
return (
<div className={styles.container}>
<div className={styles.header} onClick={() => setIsExpanded(!isExpanded)}>
<span style={{ transform: `rotate(${isExpanded ? '90deg' : '0deg'})` }}>▶</span>
<span style={{ transform: `rotate(${isExpanded ? '90deg' : '0deg'})`, display: 'flex' }}>
<Icon iconClass={'codicon codicon-chevron-right'} />
</span>
<span>
{headerText} · {fileList.length} files
</span>
Expand Down
10 changes: 4 additions & 6 deletions packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -63,19 +63,17 @@ export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps)
<>
<div className={styles.command_title}>
<Icon icon='terminal' />
<span>{localize('ai.native.mcp.terminal.command')}</span>
<span>{localize('ai.native.mcp.terminal.command')}:</span>
</div>
<p className={styles.command_content}>
<code>$ {args.command}</code>
</p>
</>
)}
<div className={styles.command_title}>
<span>{localize('ai.native.mcp.terminal.output')}</span>
</div>
{output ? (
<div className={styles.command_content}>
<code>{stripAnsi(output.text)}</code>
<Icon icon='output' />
<code dangerouslySetInnerHTML={{ __html: computeAnsiLogString(output.text || '') }} />
Comment on lines +75 to +76
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

使用dangerouslySetInnerHTML可能存在安全风险

使用dangerouslySetInnerHTML渲染ANSI日志内容时,需要注意潜在的XSS攻击风险。确保computeAnsiLogString函数对输入进行了充分的安全处理。

建议在computeAnsiLogString函数中添加对输入内容的安全过滤逻辑,或考虑使用更安全的替代方案来渲染富文本内容。

🧰 Tools
🪛 Biome (1.9.4)

[error] 76-76: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

</div>
) : (
''
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import ansiEscapes from 'ansi-escapes';

Check failure on line 1 in packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts

View workflow job for this annotation

GitHub Actions / ubuntu-latest, Node.js 20.x

The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("ansi-escapes")' call instead.

Check failure on line 1 in packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts

View workflow job for this annotation

GitHub Actions / unittest (macos-latest, 18.x, jsdom)

The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("ansi-escapes")' call instead.

Check failure on line 1 in packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts

View workflow job for this annotation

GitHub Actions / unittest (ubuntu-latest, 18.x, node)

The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("ansi-escapes")' call instead.

Check failure on line 1 in packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts

View workflow job for this annotation

GitHub Actions / unittest (ubuntu-latest, 18.x, jsdom)

The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("ansi-escapes")' call instead.

Check failure on line 1 in packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts

View workflow job for this annotation

GitHub Actions / build-windows

The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("ansi-escapes")' call instead.

Check failure on line 1 in packages/ai-native/src/browser/mcp/tools/components/filterEraseMultipleLine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.x)

The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("ansi-escapes")' call instead.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

导入模块的兼容性问题

静态分析工具发现导入 ansi-escapes 模块存在兼容性问题。当前使用 CommonJS 模块导入,但 ansi-escapes 是 ECMAScript 模块,无法通过 require 导入。

请修改为使用动态导入或更新导入方式:

-import ansiEscapes from 'ansi-escapes';
+// 方案1:使用动态导入
+const ansiEscapes = await import('ansi-escapes').then(m => m.default);
+
+// 或方案2:如果项目支持 ES 模块
+import ansiEscapes from 'ansi-escapes';

在模块系统不一致的情况下,建议检查项目配置或更新 ansi-escapes 的导入方式以解决此问题。

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 GitHub Check: build (ubuntu-latest, 20.x)

[failure] 1-1:
The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("ansi-escapes")' call instead.


/**
* 处理过滤清空上行,清空本行逻辑。
*
* 关于清空上 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;
}
Loading
Loading