同步前端页面与工作台能力

This commit is contained in:
baiyanyun
2026-07-13 09:12:31 +08:00
parent d08d30a7ab
commit 1148bf9213
553 changed files with 78232 additions and 2270 deletions

View File

@@ -51,7 +51,7 @@
"@ant-design/pro-components": "2.8.10",
"@ant-design/x": "^1.0.5",
"@antv/g6": "^4.8.25",
"@antv/x6": "^2.18.1",
"@antv/x6": "^3.1.7",
"@antv/x6-plugin-clipboard": "^2.1.6",
"@antv/x6-plugin-history": "^2.2.4",
"@antv/x6-plugin-keyboard": "^2.2.3",
@@ -59,12 +59,14 @@
"@antv/x6-plugin-snapline": "^2.1.7",
"@antv/x6-plugin-stencil": "^2.1.5",
"@antv/x6-plugin-transform": "^2.1.8",
"@antv/x6-react-shape": "^2.2.3",
"@antv/x6-react-shape": "^3.0.0",
"@babel/standalone": "^7.28.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@git-diff-view/file": "^0.1.5",
"@git-diff-view/react": "^0.1.5",
"@js-preview/docx": "^1.6.4",
"@js-preview/excel": "^1.7.14",
"@js-preview/pdf": "^2.0.10",
@@ -78,6 +80,16 @@
"@types/markdown-it": "^14.1.2",
"@umijs/max": "^4.4.2",
"@umijs/utils": "^4.4.11",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-image": "^0.8.0",
"@xterm/addon-ligatures": "^0.9.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "5.5.0",
"ahooks": "^3.8.4",
"antd": "^5.4.0",
"bignumber.js": "^11.1.1",

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14a3 3 0 0 0 3-3V5a3 3 0 1 0-6 0v6a3 3 0 0 0 3 3Z" fill="currentColor"/>
<path d="M6 11a1 1 0 1 1 2 0 4 4 0 0 0 8 0 1 1 0 1 1 2 0 6 6 0 0 1-5 5.917V20h2a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h2v-3.083A6 6 0 0 1 6 11Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 16C16.5523 16 17 16.4477 17 17C17 17.5523 16.5523 18 16 18H8C7.44772 18 7 17.5523 7 17C7 16.4477 7.44772 16 8 16H16ZM16 12C16.5523 12 17 12.4477 17 13C17 13.5523 16.5523 14 16 14H8C7.44772 14 7 13.5523 7 13C7 12.4477 7.44772 12 8 12H16ZM10 8C10.5523 8 11 8.44772 11 9C11 9.55229 10.5523 10 10 10H8C7.44772 10 7 9.55229 7 9C7 8.44772 7.44772 8 8 8H10ZM15 7H17.5859L15 4.41406V7ZM21 20C21 20.7957 20.6837 21.5585 20.1211 22.1211C19.5585 22.6837 18.7957 23 18 23H6C5.20435 23 4.44151 22.6837 3.87891 22.1211C3.3163 21.5585 3 20.7957 3 20V4C3 3.20435 3.3163 2.44152 3.87891 1.87891C4.44152 1.3163 5.20435 1 6 1H14L14.0986 1.00488C14.3276 1.02757 14.5429 1.12883 14.707 1.29297L20.707 7.29297C20.8946 7.4805 21 7.73478 21 8V20ZM5 20C5 20.2652 5.10543 20.5195 5.29297 20.707C5.48051 20.8946 5.73478 21 6 21H18C18.2652 21 18.5195 20.8946 18.707 20.707C18.8946 20.5195 19 20.2652 19 20V9H14C13.4477 9 13 8.55228 13 8V3H6C5.73478 3 5.4805 3.10543 5.29297 3.29297C5.10543 3.4805 5 3.73478 5 4V20Z" fill="#828894"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6C3 5.44772 3.44772 5 4 5H16C16.5523 5 17 5.44772 17 6C17 6.55228 16.5523 7 16 7H4C3.44772 7 3 6.55228 3 6Z" fill="#828894"/>
<path d="M3 10.5C3 9.94772 3.44772 9.5 4 9.5H14C14.5523 9.5 15 9.94772 15 10.5C15 11.0523 14.5523 11.5 14 11.5H4C3.44772 11.5 3 11.0523 3 10.5Z" fill="#828894"/>
<path d="M3 15C3 14.4477 3.44772 14 4 14H17C17.5523 14 18 14.4477 18 15C18 15.5523 17.5523 16 17 16H4C3.44772 16 3 15.5523 3 15Z" fill="#828894"/>
<path d="M3 19.5C3 18.9477 3.44772 18.5 4 18.5H12C12.5523 18.5 13 18.9477 13 19.5C13 20.0523 12.5523 20.5 12 20.5H4C3.44772 20.5 3 20.0523 3 19.5Z" fill="#828894"/>
<path d="M18.5 2L19.1778 4.07295L21.25 4.23607L19.625 5.67795L20.1056 7.75L18.5 6.61803L16.8944 7.75L17.375 5.67795L15.75 4.23607L17.8222 4.07295L18.5 2Z" fill="#828894"/>
</svg>

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.7007 11.1979C13.8804 10.1217 14.8117 9.33301 15.9028 9.33301H22.6116C23.486 9.33301 24.2799 9.84347 24.6428 10.639L25.0495 11.5306C25.4124 12.3262 26.2063 12.8366 27.0807 12.8366H41.2968C42.6835 12.8366 43.7348 14.0876 43.496 15.4537L39.8002 36.5942C39.6134 37.6627 38.6857 38.4423 37.601 38.4423H11.7886C10.4088 38.4423 9.35933 37.2031 9.58654 35.8421L13.7007 11.1979Z" fill="#5147FF"/>
<path d="M32.5602 18.8086C33.5739 18.8086 34.4608 19.4915 34.7194 20.4717L39.2614 37.6982C38.8496 38.1573 38.2549 38.4423 37.6012 38.4424H10.8971C9.88341 38.4424 8.99649 37.7595 8.73793 36.7793L4.73891 21.6104C4.36559 20.1945 5.43296 18.8087 6.89711 18.8086H32.5602Z" fill="#c8c6ff"/>
</svg>

After

Width:  |  Height:  |  Size: 789 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" version="1.1" width="20" height="20" viewBox="0 0 20 20"><defs><clipPath id="master_agentflow_agent"><rect x="0" y="0" width="20" height="20" rx="5"/></clipPath></defs><g clip-path="url(#master_agentflow_agent)"><rect x="0" y="0" width="20" height="20" rx="5" fill="#6366F1" fill-opacity="1"/><g><rect x="5" y="6" width="10" height="8" rx="2" stroke="#FFFFFF" stroke-width="1.5" fill="none"/><circle cx="7.5" cy="10" r="1" fill="#FFFFFF"/><circle cx="12.5" cy="10" r="1" fill="#FFFFFF"/><path d="M8 12.5Q10 13.5 12 12.5" stroke="#FFFFFF" stroke-width="1.2" stroke-linecap="round" fill="none"/><path d="M10 4L10 6" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round"/><circle cx="10" cy="3.5" r="0.8" fill="#FFFFFF"/></g></g></svg>

After

Width:  |  Height:  |  Size: 790 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" version="1.1" width="20" height="20" viewBox="0 0 20 20"><defs><clipPath id="master_agentflow_human_ask"><rect x="0" y="0" width="20" height="20" rx="5"/></clipPath></defs><g clip-path="url(#master_agentflow_human_ask)"><rect x="0" y="0" width="20" height="20" rx="5" fill="#8B5CF6" fill-opacity="1"/><g><circle cx="10" cy="7" r="2.5" stroke="#FFFFFF" stroke-width="1.5" fill="none"/><path d="M5.5 15.5C5.5 13 7.5 11.5 10 11.5C12.5 11.5 14.5 13 14.5 15.5" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round" fill="none"/><path d="M9 8.5C9 7.5 9.5 7 10 7C10.5 7 11 7.5 11 8C11 8.5 10.5 8.5 10 9L10 10" stroke="#FFFFFF" stroke-width="1.2" stroke-linecap="round" fill="none"/><circle cx="10" cy="11" r="0.5" fill="#FFFFFF"/></g></g></svg>

After

Width:  |  Height:  |  Size: 795 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" version="1.1" width="20" height="20" viewBox="0 0 20 20"><defs><clipPath id="ki_clip"><rect x="0" y="0" width="20" height="20" rx="5"/></clipPath></defs><g clip-path="url(#ki_clip)"><rect x="0" y="0" width="20" height="20" rx="5" fill="#FF9A2E"/><path d="M4.5 5.5h7v6h-7z" stroke="#FFFFFF" stroke-width="1.1" fill="none"/><path d="M6 8h4M6 10h2.5" stroke="#FFFFFF" stroke-width="1.1" stroke-linecap="round"/><path d="M12.5 8.2v4.1" stroke="#FFFFFF" stroke-width="1.1" stroke-linecap="round"/><path d="M10.9 10.9l1.6 1.6 1.6-1.6" stroke="#FFFFFF" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" fill="none"/></g></svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" version="1.1" width="20" height="20" viewBox="0 0 20 20"><defs><clipPath id="master_agentflow_route"><rect x="0" y="0" width="20" height="20" rx="5"/></clipPath></defs><g clip-path="url(#master_agentflow_route)"><rect x="0" y="0" width="20" height="20" rx="5" fill="#FF6B35" fill-opacity="1"/><g><path d="M10 4.5L10 8M10 8L6.5 11.5M10 8L13.5 11.5M6.5 11.5L6.5 15.5M13.5 11.5L13.5 15.5" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="10" cy="4.5" r="1.2" fill="#FFFFFF"/><circle cx="6.5" cy="15.5" r="1.2" fill="#FFFFFF"/><circle cx="13.5" cy="15.5" r="1.2" fill="#FFFFFF"/></g></g></svg>

After

Width:  |  Height:  |  Size: 689 B

View File

@@ -0,0 +1,94 @@
@import '@/styles/color.less';
@import '@/styles/token.less';
.card {
margin-top: @paddingSm;
padding: @padding;
border: @lineWidth solid @colorBorderSecondary;
border-radius: @borderRadius;
background: @colorBgContainer;
box-shadow: @boxShadowTertiary;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: @paddingSm;
margin-bottom: @paddingSm;
}
.eyebrow {
color: @colorTextTertiary;
font-size: @fontSizeSm;
line-height: @lineHeightSm;
}
.title {
margin-top: @paddingXxs;
color: @colorTextBase;
font-size: @fontSize;
font-weight: @fontWeightStrong;
line-height: @lineHeight;
}
.meta {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: @paddingXs;
align-items: center;
margin-bottom: @paddingSm;
}
.rawInput {
margin-bottom: @paddingSm;
pre {
max-height: 180px;
margin: @paddingXxs 0 0;
padding: @paddingSm;
overflow: auto;
color: @colorText;
font-size: @fontSizeSm;
line-height: @lineHeightSm;
white-space: pre-wrap;
word-break: break-word;
background: @colorFillQuaternary;
border-radius: @borderRadiusSm;
}
}
.sectionTitle {
color: @colorTextSecondary;
font-size: @fontSizeSm;
}
.saveRule {
margin-bottom: @paddingSm;
}
.ruleDescription {
margin-top: @paddingXxs;
color: @colorTextTertiary;
font-size: @fontSizeSm;
line-height: @lineHeightSm;
word-break: break-word;
}
.options {
display: flex;
flex-wrap: wrap;
gap: @paddingXs;
}
.error {
margin-top: @paddingSm;
color: @colorError;
font-size: @fontSizeSm;
}
.submitted {
margin-top: @paddingSm;
color: @colorSuccess;
font-size: @fontSizeSm;
}

View File

@@ -0,0 +1,141 @@
import { t } from '@/services/i18nRuntime';
import type {
RcoderAcpPermissionInteraction,
RcoderRequestPermissionResponse,
} from '@/types/interfaces/acpPermission';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, Tag, Typography } from 'antd';
import React, { useMemo } from 'react';
import styles from './index.less';
const { Text } = Typography;
interface AcpPermissionCardProps {
interaction: RcoderAcpPermissionInteraction;
onRespond?: (response: RcoderRequestPermissionResponse) => void;
}
const formatJson = (value: unknown): string => {
if (value === undefined || value === null) {
return t('PC.Common.Global.noData');
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const AcpPermissionCard: React.FC<AcpPermissionCardProps> = ({
interaction,
onRespond,
}) => {
const request = interaction.intervention.acp.request;
const toolCall = request.toolCall || {};
const isSubmitting = interaction.responseStatus === 'submitting';
const isSubmitted = interaction.responseStatus === 'submitted';
const disabled = isSubmitting || isSubmitted || !onRespond;
const rawInputText = useMemo(
() => formatJson(toolCall.rawInput),
[toolCall.rawInput],
);
const handleSelect = (optionId: string) => {
onRespond?.({
outcome: {
outcome: 'selected',
optionId,
},
});
};
const handleCancel = () => {
onRespond?.({
outcome: {
outcome: 'cancelled',
},
});
};
const toolCallId = toolCall.toolCallId || '';
const toolCallKind = toolCall.kind || '';
const options = request.options || [];
return (
<div className={styles.card}>
<div className={styles.header}>
<div>
<div className={styles.eyebrow}>
{t('PC.Components.AcpPermissionCard.eyebrow')}
</div>
<div className={styles.title}>
{toolCall.title?.trim() ||
t('PC.Components.AcpPermissionCard.defaultTitle')}
</div>
</div>
{toolCallKind && <Tag>{toolCallKind}</Tag>}
</div>
<div className={styles.meta}>
<Text type="secondary">
{t('PC.Components.AcpPermissionCard.toolCallId')}
</Text>
<Text code copyable>
{toolCallId || '-'}
</Text>
</div>
<div className={styles.rawInput}>
<div className={styles.sectionTitle}>
{t('PC.Components.AcpPermissionCard.rawInput')}
</div>
<pre>{rawInputText}</pre>
</div>
<div className={styles.options}>
{options.map((option) => {
const optionId = option.optionId || '';
const kind = option.kind || '';
const name = option.name || optionId;
const isAllow = kind.startsWith('allow');
const isReject = kind.startsWith('reject');
return (
<Button
key={optionId}
type={isAllow ? 'primary' : 'default'}
danger={isReject}
icon={isAllow ? <CheckOutlined /> : <CloseOutlined />}
loading={
isSubmitting && interaction.selectedOptionId === optionId
}
disabled={disabled}
onClick={() => handleSelect(optionId)}
>
{name}
</Button>
);
})}
<Button disabled={disabled} onClick={handleCancel}>
{t('PC.Common.Global.cancel')}
</Button>
</div>
{interaction.responseStatus === 'failed' && (
<div className={styles.error}>{interaction.errorMessage}</div>
)}
{isSubmitted && (
<div className={styles.submitted}>
{t('PC.Components.AcpPermissionCard.submitted')}
</div>
)}
</div>
);
};
export default AcpPermissionCard;

View File

@@ -1,5 +1,6 @@
import agentImage from '@/assets/images/agent_image.png';
import type { AgentChatEmptyProps } from '@/types/interfaces/agentConfig';
import { getUploadedIconOrDefault } from '@/utils/defaultIcon';
import classNames from 'classnames';
import React from 'react';
import styles from './index.less';
@@ -27,8 +28,12 @@ const AgentChatEmpty: React.FC<AgentChatEmptyProps> = ({
>
<img
className={cx(styles.avatar)}
src={icon || (agentImage as string)}
src={getUploadedIconOrDefault(icon, agentImage as string)}
alt=""
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = agentImage as string;
}}
/>
<h3 className={cx('w-full', 'text-ellipsis', styles.nickname)}>{name}</h3>
{extra}

View File

@@ -3,6 +3,7 @@ import { SvgIcon } from '@/components/base';
import ConditionRender from '@/components/ConditionRender';
import { dict } from '@/services/i18nRuntime';
import { AgentContentProps } from '@/types/interfaces/agentTask';
import { getUploadedIconOrDefault } from '@/utils/defaultIcon';
import { Typography } from 'antd';
import classNames from 'classnames';
import React, { useRef } from 'react';
@@ -24,13 +25,13 @@ const AgentContent: React.FC<AgentContentProps> = ({ agentDetail }) => {
<div className={cx(styles.container, 'flex', 'flex-col', 'items-center')}>
<img
className={styles.avatar}
src={agentDetail?.icon || agentImage}
src={getUploadedIconOrDefault(agentDetail?.icon, agentImage as string)}
onError={(e) => {
const imgEl = e.currentTarget;
// 仅在首次失败时回退到默认头像,避免无限重试
if (!hasRetriedRef.current) {
hasRetriedRef.current = true;
imgEl.src = agentImage;
imgEl.src = agentImage as string;
} else {
// 第二次依旧失败时,移除 onError防止死循环
imgEl.onerror = null;

View File

@@ -0,0 +1,114 @@
@import '@/styles/token';
.space-selector-container {
display: flex;
align-items: flex-start;
}
.space-selector {
display: inline-flex;
align-items: center;
margin-top: 3px;
height: 28px;
.selector-btn {
padding: @paddingXxs @paddingXs;
border-radius: @borderRadius;
box-sizing: border-box;
color: @colorTextSecondary;
font-size: @fontSizeSm;
transition: background-color ease-in-out 0.3s;
word-wrap: normal;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
span {
display: inline-block;
max-width: 82px;
color: @colorTextSecondary;
font-size: @fontSizeSm;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
background: @colorFillTertiary;
}
.selector-arrow {
transition: transform @motionDurationFast ease;
}
&.open .selector-arrow {
transform: rotate(180deg);
}
}
}
.space-menu {
:global {
.ant-dropdown-menu {
max-height: 240px;
overflow-y: auto;
overflow-x: hidden;
}
.ant-dropdown-menu-item {
background: transparent !important;
&:hover {
background: @colorFillTertiary !important;
}
&.ant-dropdown-menu-item-selected {
background: transparent !important;
&:hover {
background: @colorFillTertiary !important;
}
}
}
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 200px;
.item-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
.item-name {
color: @colorTextSecondary;
font-size: @fontSizeSm;
font-weight: 500;
line-height: 28px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-desc {
font-size: 11px;
color: @colorTextQuaternary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.item-check {
margin-left: @marginSm;
color: @colorPrimary;
font-size: @fontSizeSm;
}
}
}

View File

@@ -0,0 +1,155 @@
import { SvgIcon } from '@/components/base';
import { SUCCESS_CODE } from '@/constants/codes.constants';
import { t } from '@/services/i18nRuntime';
import { apiSpaceList } from '@/services/workspace';
import type { SpaceInfo } from '@/types/interfaces/workspace';
import { CheckOutlined } from '@ant-design/icons';
import { Dropdown, MenuProps, Typography } from 'antd';
import classNames from 'classnames';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styles from './index.less';
const cx = classNames.bind(styles);
interface SpaceSelectorProps {
selectedSpaceId?: number;
onSpaceSelect?: (spaceId: number) => void;
className?: string;
}
const SpaceSelector: React.FC<SpaceSelectorProps> = ({
selectedSpaceId,
onSpaceSelect,
className,
}) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [initialized, setInitialized] = useState(false);
const [spaceList, setSpaceList] = useState<SpaceInfo[]>([]);
const fetchSpaceList = useCallback(async () => {
setLoading(true);
try {
const result = await apiSpaceList();
if (result.code === SUCCESS_CODE) {
setSpaceList(result.data || []);
} else {
setSpaceList([]);
}
} catch (error) {
console.error('Failed to get space list:', error);
setSpaceList([]);
} finally {
setInitialized(true);
setLoading(false);
}
}, []);
useEffect(() => {
fetchSpaceList();
}, [fetchSpaceList]);
useEffect(() => {
if (!initialized || !spaceList.length) return;
const isSelectedInList = spaceList.some(
(space) => space.id === selectedSpaceId,
);
if (!selectedSpaceId || !isSelectedInList) {
onSpaceSelect?.(spaceList[0].id);
}
}, [initialized, onSpaceSelect, selectedSpaceId, spaceList]);
const selectedSpace = useMemo(() => {
if (selectedSpaceId) {
const found = spaceList.find((space) => space.id === selectedSpaceId);
if (found) return found;
}
return spaceList[0] || null;
}, [selectedSpaceId, spaceList]);
const handleSelect = useCallback(
(space: SpaceInfo) => {
if (space.id !== selectedSpaceId) {
onSpaceSelect?.(space.id);
}
setOpen(false);
},
[onSpaceSelect, selectedSpaceId],
);
const menuItems: MenuProps['items'] = useMemo(() => {
if (!spaceList.length && initialized) {
return [
{
key: 'empty',
label: (
<div className={cx(styles['menu-item'])}>
<span className={cx(styles['item-name'])}>
{t('PC.Components.ChatInputHomeSpaceSelector.noAvailableSpace')}
</span>
</div>
),
disabled: true,
},
];
}
return spaceList.map((space) => {
const isSelected = space.id === selectedSpace?.id;
return {
key: space.id,
label: (
<div className={cx(styles['menu-item'])}>
<div className={cx(styles['item-content'])}>
<span className={cx(styles['item-name'])}>{space.name}</span>
{space.description && (
<span className={cx(styles['item-desc'])}>
{space.description}
</span>
)}
</div>
{isSelected && (
<CheckOutlined className={cx(styles['item-check'])} />
)}
</div>
),
onClick: () => handleSelect(space),
};
});
}, [handleSelect, initialized, selectedSpace?.id, spaceList]);
if (!initialized || loading || !spaceList.length) {
return null;
}
return (
<div className={cx(styles['space-selector-container'], className)}>
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
placement="topLeft"
open={open}
onOpenChange={setOpen}
overlayClassName={styles['space-menu']}
>
<span className={cx(styles['space-selector'])}>
<span
className={cx(styles['selector-btn'], {
[styles.open]: open,
})}
>
<Typography.Text>{selectedSpace?.name || '--'}</Typography.Text>
<SvgIcon
name="icons-common-caret_down"
style={{ fontSize: 14 }}
className={cx(styles['selector-arrow'])}
/>
</span>
</span>
</Dropdown>
</div>
);
};
export default SpaceSelector;

View File

@@ -0,0 +1,37 @@
.diff-editor-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
}
.diff-editor-container {
width: 100%;
height: 100%;
:global(.monaco-editor) {
padding: 0 !important;
margin: 0 !important;
}
:global(.monaco-editor .view-lines) {
padding: 0 !important;
}
:global(.monaco-editor .margin) {
padding: 0 !important;
}
:global(.monaco-editor .overflow-guard) {
padding: 0 !important;
}
// diff 高亮样式优化
:global(.monaco-diff-editor .diffOverview) {
cursor: pointer;
}
:global(.monaco-editor .insert-sign),
:global(.monaco-editor .delete-sign) {
font-size: 11px !important;
}
}

View File

@@ -0,0 +1,107 @@
import { getLanguageFromFile } from '@/utils/monacoConfig';
import * as monaco from 'monaco-editor';
import React, { useEffect, useRef, useState } from 'react';
import styles from './index.less';
interface MonacoDiffEditorProps {
/** 文件ID */
fileId: string;
/** 文件名(用于语法高亮) */
fileName: string;
/** 原始文件内容(左侧) */
originalContent: string;
/** 修改后的文件内容(右侧) */
modifiedContent: string;
/** 类名 */
className?: string;
}
/**
* Monaco DiffEditor 组件
* 使用 monaco.editor.createDiffEditor 展示文件修改前后的 diff 对比
* 左侧为原始内容(只读),右侧为修改后内容(只读)
*/
const MonacoDiffEditor: React.FC<MonacoDiffEditorProps> = ({
fileId,
fileName,
originalContent,
modifiedContent,
className,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const diffEditorRef = useRef<monaco.editor.IStandaloneDiffEditor | null>(
null,
);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (!containerRef.current) return;
const language = getLanguageFromFile(fileName);
// 创建 DiffEditor 实例
const diffEditor = monaco.editor.createDiffEditor(containerRef.current, {
automaticLayout: true,
readOnly: true,
renderSideBySide: true,
originalEditable: false,
scrollBeyondLastLine: false,
fontSize: 12,
fontFamily:
"'JetBrains Mono', monospace, 'Fira Code','Consolas', 'Monaco', 'Courier New'",
lineHeight: 20,
tabSize: 2,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
renderLineHighlight: 'all',
folding: true,
contextmenu: false,
// diff 特有配置
enableSplitViewResizing: true,
renderOverviewRuler: true,
renderMarginRevertIcon: false,
// 禁用验证装饰
renderValidationDecorations: 'off',
});
// 设置 diff model
const originalModel = monaco.editor.createModel(originalContent, language);
const modifiedModel = monaco.editor.createModel(modifiedContent, language);
diffEditor.setModel({
original: originalModel,
modified: modifiedModel,
});
diffEditorRef.current = diffEditor;
setIsReady(true);
return () => {
// 清理
diffEditor.dispose();
originalModel.dispose();
modifiedModel.dispose();
diffEditorRef.current = null;
};
}, [fileId]);
// 内容变化时更新 model
useEffect(() => {
if (!diffEditorRef.current || !isReady) return;
const model = diffEditorRef.current.getModel();
if (model) {
model.original.setValue(originalContent);
model.modified.setValue(modifiedContent);
}
}, [originalContent, modifiedContent, isReady]);
return (
<div className={`${styles['diff-editor-wrapper']} ${className || ''}`}>
<div ref={containerRef} className={styles['diff-editor-container']} />
</div>
);
};
export default MonacoDiffEditor;

View File

@@ -0,0 +1,55 @@
/**
* 命中测试API服务
*/
import { request } from 'umi';
/**
* 获取知识库文档列表
*/
export async function getAccuracyTestDocuments(knowledgeBaseId: number) {
return request('/api/knowledge/accuracytest/documents', {
method: 'GET',
params: { knowledgeBaseId },
});
}
/**
* 获取测试历史记录
*/
export async function getAccuracyTestHistory(knowledgeBaseId: number) {
return request('/api/knowledge/accuracytest/history', {
method: 'GET',
params: { knowledgeBaseId },
});
}
/**
* 执行命中搜索
*/
export async function performAccuracySearch(data: {
knowledgeBaseId: number;
query: string;
searchStrategy: 'SEMANTIC' | 'FULL_TEXT' | 'MIXED';
topK: number;
matchingDegree: number;
}) {
return request('/api/knowledge/accuracytest/search', {
method: 'POST',
data,
});
}
/**
* 保存测试记录
*/
export async function saveAccuracyTestRecord(data: {
knowledgeBaseId: number;
query: string;
searchStrategy: string;
results: any[];
}) {
return request('/api/knowledge/accuracytest/record', {
method: 'POST',
data,
});
}

View File

@@ -0,0 +1,44 @@
/**
* 命中测试类型定义
*/
// 搜索策略类型
export type SearchStrategy = 'SEMANTIC' | 'FULL_TEXT' | 'MIXED';
// 文档项
export interface DocumentItem {
id: number;
name: string;
fileType: string;
knowledgeBaseId: number;
}
// 测试历史项
export interface TestHistoryItem {
id: number;
knowledgeBaseId: number;
query: string;
searchStrategy: string;
results: string;
createTime: string;
}
// 召回结果项
export interface RecallResultItem {
docId: number;
docName: string;
score: number;
content: string;
rank: number;
metadata?: any;
isExpanded?: boolean;
}
// 命中测试请求参数
export interface AccuracySearchParams {
knowledgeBaseId: number;
query: string;
searchStrategy: SearchStrategy;
topK: number;
matchingDegree: number;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export const TaskResultContext = React.createContext<{
onTaskResultClick?: (fileId: string) => boolean | void;
}>({});

View File

@@ -0,0 +1,141 @@
/**
* MarkdownRenderer utils 单元测试
*
* 重点覆盖 e179d3ae9 引入的性能优化路径:
* - replaceMathBracketbase64 data URL 占位符 round-trip + 数学定界符替换
* - groupMarkdownProcesses快速短路不含过程标签时直接返回
*/
import { describe, expect, it } from 'vitest';
import { groupMarkdownProcesses, replaceMathBracket } from '../utils';
/** 构造一个合法的 base64 data URL仅长度可配置内容不要求是真实图片 */
const makeDataUrl = (bodyLen = 100) =>
`data:image/png;base64,${'A'.repeat(bodyLen)}`;
describe('replaceMathBracket', () => {
it('空输入返回空串', () => {
expect(replaceMathBracket('')).toBe('');
});
it('无定界符文本原样返回', () => {
const text = 'hello world没有数学公式';
expect(replaceMathBracket(text)).toBe(text);
});
it('替换行内定界符 \\( \\) 为 $...$', () => {
expect(replaceMathBracket('公式 \\(a + b\\) 结束')).toBe(
'公式 $a + b$ 结束',
);
});
it('替换块级定界符 \\[ \\] 为 $$...$$', () => {
expect(replaceMathBracket('块 \\[\\int_0^1 x\\,dx\\] 尾')).toBe(
'块 $$\\int_0^1 x\\,dx$$ 尾',
);
});
it('base64 data URL 原样还原,不被当作定界符扫描', () => {
const url = makeDataUrl(200);
const text = `前缀 ![img](${url}) 后缀`;
expect(replaceMathBracket(text)).toBe(text);
});
it('单个 data URL 内部即便含有形似定界符的片段也不被替换', () => {
// base64 正文只用 A-Za-z0-9+/=,不会出现反斜杠,因此 \\/\\[ 这类定界符
// 不可能落在 data URL 内部。这里构造一段不含反斜杠、但形似数学符号的正文,
// 验证它被当作 data URL 整段跳过,内部的 ( ) 不被误判为行内定界符。
const url = `data:image/png;base64,abc(def)ghi==`;
const text = `${url}`;
expect(replaceMathBracket(text)).toBe(text);
});
it('多个 data URL 共存:每个都完整还原,互不吞并(验证 \\s 被移除)', () => {
// 旧正则字符类含 \s会把 "URL1 + 空格 + URL2 的 data 前缀" 一起吞进第一个匹配,
// 导致第二个 URL 丢失 "data" 前缀。这里用空格分隔两个 URL 来回归这一情况。
const url1 = makeDataUrl(50);
const url2 = makeDataUrl(60);
const text = `${url1} ${url2}`;
const result = replaceMathBracket(text);
expect(result).toContain(url1);
expect(result).toContain(url2);
// 第二个 URL 必须仍以 "data:" 开头出现在结果里
expect(result).toContain(' data:image/png;base64,');
});
it('data URL 与数学公式混合:两边各得其所', () => {
const url = makeDataUrl(80);
const text = `![img](${url}) 然后 \\(c = a + b\\)`;
expect(replaceMathBracket(text)).toBe(`![img](${url}) 然后 $c = a + b$`);
});
});
describe('groupMarkdownProcesses', () => {
it('空输入返回空串', () => {
expect(groupMarkdownProcesses('')).toBe('');
});
it('不含 markdown-custom-process 标签时原样返回(短路)', () => {
const text = `![img](${makeDataUrl(500)}) 普通文本,没有过程标签`;
// 短路返回的是同一个引用,既验证行为也验证未进入正则扫描
expect(groupMarkdownProcesses(text)).toBe(text);
});
it('含 base64 data URL 但无过程标签时不进入正则扫描', () => {
const url = makeDataUrl(2000);
const text = `![big](${url}) 文本`;
// 大字符串下若误入正则扫描会明显变慢甚至改变内容,这里断言原样返回
expect(groupMarkdownProcesses(text)).toBe(text);
});
it('单个过程标签被 div 包装保留', () => {
const tag =
'<markdown-custom-process executeId="e1" name="step1" type="Action"></markdown-custom-process>';
const result = groupMarkdownProcesses(`前文\n${tag}\n后文`);
expect(result).toContain('<div>');
expect(result).toContain('<markdown-custom-process executeId="e1"');
// 单个标签不合并为 group
expect(result).not.toContain('markdown-custom-process-group');
});
it('连续多个过程标签合并为 markdown-custom-process-group', () => {
const t1 =
'<markdown-custom-process executeId="e1" name="a" type="Action"></markdown-custom-process>';
const t2 =
'<markdown-custom-process executeId="e2" name="b" type="Action"></markdown-custom-process>';
const result = groupMarkdownProcesses(`${t1}\n${t2}`);
expect(result).toContain('markdown-custom-process-group');
});
it('type=Plan 标签单独成块type 排在 name 之前时不并入 group', () => {
const plan =
'<markdown-custom-process executeId="p1" type="Plan" name="plan"></markdown-custom-process>';
const action =
'<markdown-custom-process executeId="a1" type="Action" name="act"></markdown-custom-process>';
const result = groupMarkdownProcesses(`${plan}\n${action}`);
// Plan 单独成块,仅剩 1 个 Action 也单独成块,不形成 group
expect(result).not.toContain('markdown-custom-process-group');
});
it('type=Plan 标签单独成块type 排在 name 之后时也不并入 group', () => {
// 回归:归一化把 name 之后的属性编码进 name 值,
// 若 isPlan 在归一化后才判断,会因 type 被吞入 name 而漏判,导致 Plan 被误并入 group。
const plan =
'<markdown-custom-process executeId="p1" name="plan" type="Plan"></markdown-custom-process>';
const action =
'<markdown-custom-process executeId="a1" name="act" type="Action"></markdown-custom-process>';
const result = groupMarkdownProcesses(`${plan}\n${action}`);
// Plan 单独成块,仅剩 1 个 Action 也单独成块,不形成 group
expect(result).not.toContain('markdown-custom-process-group');
});
it('同一 executeId 的重复标签只保留最后一个(去重)', () => {
// SSE 流式追加可能产生同 executeId 的重复标签,实现应只保留最新一项
const dup1 =
'<markdown-custom-process executeId="dup" name="v1"></markdown-custom-process>';
const dup2 =
'<markdown-custom-process executeId="dup" name="v2"></markdown-custom-process>';
const result = groupMarkdownProcesses(`${dup1}\n${dup2}`);
expect(result).toContain('name="v2"');
expect(result).not.toContain('name="v1"');
});
});

View File

@@ -15,6 +15,33 @@ import { useIntl } from 'umi';
import { enhanceColumnsForLightFilterEnterConfirm } from './enhanceLightFilterEnterConfirm';
import { getProIntlMessage } from './i18n';
const DEFAULT_AUTO_SCROLL_COLUMN_WIDTH = 160;
const getColumnWidthNumber = (width: unknown): number => {
if (typeof width === 'number' && Number.isFinite(width)) {
return width;
}
if (typeof width === 'string') {
const parsed = Number.parseFloat(width);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return DEFAULT_AUTO_SCROLL_COLUMN_WIDTH;
};
const flattenVisibleColumns = (columns: readonly any[] = []): any[] => {
return columns.flatMap((column) => {
if (column?.hideInTable) {
return [];
}
if (Array.isArray(column?.children) && column.children.length > 0) {
return flattenVisibleColumns(column.children);
}
return [column];
});
};
/**
* XProTable - 预配置的 ProTable 封装组件
*
@@ -67,13 +94,6 @@ function XProTable<
*/
const currentLang = intl.locale || getCurrentLang();
// 合并 scroll 配置
// 如果开启了 fullHeight则使用计算出的 scrollY
const scroll = {
...restProps.scroll,
y: fullHeight ? scrollY : restProps.scroll?.y,
} as ProTableProps<DataType, Params, ValueType>['scroll'];
// 使用传入的 formRef/actionRef 或内部创建的
const internalFormRef = useRef<FormInstance>();
const internalActionRef = useRef<ActionType>();
@@ -128,6 +148,35 @@ function XProTable<
return cols;
}, [restProps.columns, showIndex, restProps.search, currentLang]);
const autoScrollX = useMemo(() => {
if (restProps.scroll?.x !== undefined) {
return restProps.scroll.x;
}
const visibleColumns = flattenVisibleColumns(mergedColumns);
const hasFixedColumn = visibleColumns.some((column) => column?.fixed);
if (!hasFixedColumn) {
return undefined;
}
const totalWidth = visibleColumns.reduce(
(sum, column) => sum + getColumnWidthNumber(column?.width),
0,
);
return Math.max(totalWidth, 960);
}, [mergedColumns, restProps.scroll?.x]);
// 合并 scroll 配置:固定列必须带横向滚动宽度,否则窄容器下会覆盖普通列文本。
const scroll = useMemo(
() =>
({
...restProps.scroll,
x: restProps.scroll?.x ?? autoScrollX,
y: fullHeight ? scrollY : restProps.scroll?.y,
} as ProTableProps<DataType, Params, ValueType>['scroll']),
[autoScrollX, fullHeight, restProps.scroll, scrollY],
);
// 合并 toolBarRender添加查询/重置按钮
const mergedToolBarRender = useMemo(() => {
return (action: any, rows: any) => {

View File

@@ -0,0 +1,218 @@
@import '@/styles/token.less';
@default-max-lines: 1;
@margin-name-bottom: 2px;
// 局部 Less 变量定义,以 100% 还原重构前视觉效果且严格满足不硬编码规范
@selectionContainerBorderRadius: @borderRadiusLg;
@selectionContainerBorderColor: #e4e6eb;
@selectionContainerGap: @marginSm; // 增加间距到 12px
@selectionHeaderPadding: @padding @paddingMd;
@selectionHeaderFontSize: @fontSizeLg;
@selectionHeaderColor: #1d2129;
@selectionHeaderBorderBottom: 1px solid #f2f3f5;
@selectionItemPadding: @paddingSm @paddingSm;
@selectionItemBorderRadius: @borderRadius;
@selectionItemHoverBg: #f7f8fa;
@selectionItemActiveBg: #f0f5ff;
@selectionItemActiveBorderColor: #3370ff;
@selectionItemActiveBoxShadow: 0 4px 12px rgba(51, 112, 255, 0.12);
@actionsGap: @marginXxs;
@selectionIconSize: 44px;
@selectionIconMarginRight: 14px;
@selectionAllIconBg: #666;
@selectionAllIconColor: #fff;
@selectionAllIconFontSize: 18px;
@selectionNameFontSize: 15px;
@selectionNameColor: #1d2129;
@selectionNameActiveColor: #3370ff;
@selectionDescFontSize: 13px;
@selectionDescColor: #86909c;
@selectionItemTransition: background-color 0.2s ease, border-color 0.2s ease,
box-shadow 0.2s ease;
.container {
height: 100%;
display: flex;
flex-direction: column;
background-color: @colorBgContainer;
border-radius: @selectionContainerBorderRadius;
border: @lineWidth solid @selectionContainerBorderColor;
overflow: hidden;
.header {
padding: @selectionHeaderPadding;
font-size: @selectionHeaderFontSize;
font-weight: 600;
color: @selectionHeaderColor;
background-color: @colorBgContainer;
border-bottom: @selectionHeaderBorderBottom;
flex-shrink: 0;
}
.list-wrapper {
flex: 1;
padding: @paddingSm;
display: flex;
flex-direction: column;
gap: @selectionContainerGap;
overflow: hidden;
&:hover {
overflow-y: auto;
}
}
}
.item {
display: flex;
align-items: center;
padding: @selectionItemPadding;
border-radius: @selectionItemBorderRadius;
cursor: pointer;
transition: @selectionItemTransition;
border: @lineWidth solid @colorBorderSecondary;
background-color: @colorFillAlter;
position: relative;
z-index: 1;
&:hover {
background-color: @selectionItemHoverBg;
}
&.active {
background-color: @selectionItemActiveBg;
border-color: @selectionItemActiveBorderColor;
box-shadow: @selectionItemActiveBoxShadow;
.info {
.name {
color: @selectionNameActiveColor !important;
transition: color 0.2s ease;
}
}
}
.icon {
width: @selectionIconSize;
height: @selectionIconSize;
border-radius: @selectionItemBorderRadius;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: @selectionIconMarginRight;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
.all-icon {
width: 100%;
height: 100%;
background-color: @selectionAllIconBg;
color: @selectionAllIconColor;
display: flex;
align-items: center;
justify-content: center;
font-size: @selectionAllIconFontSize;
font-weight: 500;
}
}
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
.name-wrap {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: @margin-name-bottom;
gap: @marginXs;
.name {
font-size: @selectionNameFontSize;
font-weight: 500;
color: @selectionNameColor !important;
line-height: @lineHeightLg;
transition: color 0.2s;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.extra {
font-size: @selectionDescFontSize;
color: @selectionDescColor;
flex-shrink: 0;
}
}
.desc-wrap {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: @marginSm;
.description {
font-size: @selectionDescFontSize;
color: @selectionDescColor;
line-height: @lineHeight;
word-break: break-all;
margin-bottom: 0 !important;
flex: 1;
min-width: 0;
}
.more-action-wrap {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
padding: 2px 4px;
border-radius: @borderRadiusSm;
transition: background-color 0.2s;
&:hover {
background-color: @colorFillAlter;
.more-icon {
color: @colorPrimaryHover;
}
}
.more-icon {
font-size: 16px;
color: @selectionDescColor;
transition: color 0.2s;
}
}
}
}
}
.empty-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: @padding;
}

View File

@@ -0,0 +1,153 @@
import { dict } from '@/services/i18nRuntime';
import {
DeleteOutlined,
EditOutlined,
EllipsisOutlined,
} from '@ant-design/icons';
import { Dropdown, Empty, Typography } from 'antd';
import classNames from 'classnames';
import React from 'react';
import styles from './index.less';
const cx = classNames.bind(styles);
export interface SelectionListItem<T = any> {
icon: string | React.ReactNode;
label: string | React.ReactNode;
description?: string | React.ReactNode;
extra?: React.ReactNode;
value: T;
allowEdit?: boolean;
allowDelete?: boolean;
}
export interface SelectionListProps<T = any> {
title: React.ReactNode;
list: SelectionListItem<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
onEdit?: (item: SelectionListItem<T>, e: React.MouseEvent) => void;
onDelete?: (item: SelectionListItem<T>, e: React.MouseEvent) => void;
emptyText?: React.ReactNode;
maxDescriptionLines?: number;
}
const SelectionList: React.FC<SelectionListProps> = ({
title,
list = [],
value,
onChange,
className,
onEdit,
onDelete,
emptyText,
maxDescriptionLines = 1,
}) => {
return (
<div className={cx(styles.container, className)}>
<div className={cx(styles.header)}>{title}</div>
<div className={cx(styles['list-wrapper'])}>
{list.length > 0 ? (
list.map((item) => {
const isActive = value === item.value;
const menuItems = [];
if (item.allowEdit && onEdit) {
menuItems.push({
key: 'edit',
label: dict('PC.Common.Global.edit'),
icon: <EditOutlined />,
onClick: (info: any) => {
info.domEvent.stopPropagation();
onEdit(item, info.domEvent);
},
});
}
if (item.allowDelete && onDelete) {
menuItems.push({
key: 'delete',
label: dict('PC.Common.Global.delete'),
danger: true,
icon: <DeleteOutlined />,
onClick: (info: any) => {
info.domEvent.stopPropagation();
onDelete(item, info.domEvent);
},
});
}
return (
<div
key={String(item.value)}
data-value={String(item.value)}
className={cx(styles.item, { [styles.active]: isActive })}
onClick={() => onChange(item.value)}
>
<div className={cx(styles.icon)}>
{typeof item.icon === 'string' ? (
<img src={item.icon} alt={String(item.label)} />
) : (
item.icon
)}
</div>
<div className={cx(styles.info)}>
<div className={cx(styles['name-wrap'])}>
<Typography.Text
className={cx(styles.name)}
ellipsis={{
tooltip: item.label,
}}
>
{item.label}
</Typography.Text>
{item.extra && (
<div className={cx(styles.extra)}>{item.extra}</div>
)}
</div>
{(item.description || menuItems.length > 0) && (
<div className={cx(styles['desc-wrap'])}>
<Typography.Paragraph
className={cx(styles.description)}
ellipsis={{
rows: maxDescriptionLines,
tooltip: item.description,
}}
>
{item.description || ' '}
</Typography.Paragraph>
{menuItems.length > 0 && (
<Dropdown
menu={{ items: menuItems }}
trigger={['hover']}
placement="bottom"
>
<div
className={cx(styles['more-action-wrap'])}
onClick={(e) => e.stopPropagation()}
>
<EllipsisOutlined
className={cx(styles['more-icon'])}
/>
</div>
</Dropdown>
)}
</div>
)}
</div>
</div>
);
})
) : (
<div className={cx(styles['empty-wrapper'])}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={emptyText}
/>
</div>
)}
</div>
</div>
);
};
export default SelectionList;

View File

@@ -1,4 +1,3 @@
import { dict } from '@/services/i18nRuntime';
import { SiteFooterProps } from '@/types/interfaces/common';
import classNames from 'classnames';
import React from 'react';
@@ -15,13 +14,10 @@ const SiteFooter: React.FC<SiteFooterProps> = ({ text, className }) => {
__html: text || '',
}}
/>
<a
href={dict('PC.Components.SiteFooter.homeLink')}
target="_blank"
rel="noopener noreferrer"
>
{dict('PC.Components.SiteFooter.poweredBy')}
<a href="https://qiming.com/" target="_blank" rel="noopener noreferrer">
Powered by{' '}
</a>
<span></span>
</footer>
);
};

View File

@@ -0,0 +1,41 @@
@import '@/styles/color.less';
@import '@/styles/token.less';
.header {
height: 56px;
min-height: 56px;
padding: 0 16px;
border-bottom: 1px solid @colorBorderSecondary;
background-color: @colorBgContainer;
display: flex;
align-items: center;
gap: 12px;
.avatar {
width: 32px;
height: 32px;
border-radius: @borderRadius;
flex-shrink: 0;
}
.header-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
.title {
max-width: 300px;
font-size: 16px;
font-weight: @fontWeightStrong;
color: @colorText;
white-space: nowrap;
margin: 0;
}
}
.status-tag {
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,88 @@
import agentImage from '@/assets/images/agent_image.png';
import { t } from '@/services/i18nRuntime';
import type { SkillDetailInfo } from '@/types/interfaces/skill';
import { CheckCircleFilled, LeftOutlined } from '@ant-design/icons';
import { Button, Tag } from 'antd';
import classNames from 'classnames';
import React from 'react';
import { history } from 'umi';
import styles from './index.less';
const cx = classNames.bind(styles);
/** 详情页模式apply — 待审核 / published — 已发布 */
export type SkillDetailViewMode = 'apply' | 'published';
export interface SkillHeaderProps {
skillInfo?: SkillDetailInfo | null;
mode: SkillDetailViewMode;
}
/** mode 对应的配置 */
const modeConfig: Record<
SkillDetailViewMode,
{
backPath: string;
tagColor: string;
tagIcon?: React.ReactNode;
tagText: string;
}
> = {
apply: {
backPath: '/system/publish/audit',
tagColor: 'processing',
tagText: t('PC.Pages.PublishAudit.statusApplying'),
},
published: {
backPath: '/system/published/manage',
tagColor: 'success',
tagIcon: <CheckCircleFilled />,
tagText: t('PC.Pages.SkillDetailsHeader.published'),
},
};
/**
* 技能详情顶部 header 组件(待审核 / 已发布 共用)
*/
const SkillHeader: React.FC<SkillHeaderProps> = ({ skillInfo, mode }) => {
const config = modeConfig[mode];
return (
<header className={cx('flex', 'items-center', styles['header'])}>
<Button
type="text"
icon={<LeftOutlined />}
onClick={() => {
if (window.history.length > 1) {
history.back();
} else {
history.push(config.backPath);
}
}}
/>
<img
className={cx(styles['avatar'])}
src={skillInfo?.icon || (agentImage as string)}
alt=""
/>
<div className={cx(styles['header-info'])}>
{!!skillInfo && (
<h3 className={cx(styles['title'], 'text-ellipsis')}>
{skillInfo?.name}
</h3>
)}
</div>
<Tag
className={cx(styles['status-tag'])}
color={config.tagColor}
bordered={false}
icon={config.tagIcon}
>
{config.tagText}
</Tag>
</header>
);
};
export default SkillHeader;

View File

@@ -0,0 +1,25 @@
@import '@/styles/token.less';
.page-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.layout-wrapper {
display: flex;
width: 100%;
overflow: hidden;
flex: 1;
min-height: 0;
.detail-section {
flex: 1;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
}

View File

@@ -0,0 +1,133 @@
import { FileTreeViewPanel } from '@/components/business-component';
import {
apiPublishedSkillDetail,
apiPublishSkillDetail,
} from '@/services/skill';
import type { SkillDetailInfo } from '@/types/interfaces/skill';
import classNames from 'classnames';
import React, { useCallback, useEffect, useState } from 'react';
import { useRequest } from 'umi';
import SkillHeader, { type SkillDetailViewMode } from './SkillHeader';
import styles from './index.less';
const cx = classNames.bind(styles);
export type { SkillDetailViewMode };
interface SkillDetailViewProps {
spaceId: number;
skillId: number;
/** apply — 待审核 / published — 已发布 */
mode: SkillDetailViewMode;
}
/** mode 对应的 API 函数映射 */
const apiMap: Record<SkillDetailViewMode, (skillId: number) => any> = {
apply: apiPublishSkillDetail,
published: apiPublishedSkillDetail,
};
/**
* 技能详情公共组件
* - 根据 mode 自动调用对应接口(待审核 / 已发布)
* - 只读查看,不提供编辑/发布/文件操作等能力
*/
const SkillDetailView: React.FC<SkillDetailViewProps> = ({ skillId, mode }) => {
const [skillInfo, setSkillInfo] = useState<SkillDetailInfo | null>(null);
// 文件树数据加载状态
const [fileTreeDataLoading, setFileTreeDataLoading] =
useState<boolean>(false);
// 根据 mode 选择对应的 API
const apiFn = apiMap[mode];
// 查询技能信息
const { run: runSkillInfo } = useRequest(apiFn, {
manual: true,
debounceInterval: 300,
onSuccess: async (result: SkillDetailInfo) => {
setFileTreeDataLoading(false);
const { files } = result || {};
if (Array.isArray(files) && files.length > 0) {
setSkillInfo(() => ({
...result,
files: files.map((item) => ({
...item,
fileId: item.name,
})),
}));
} else {
setSkillInfo(result);
}
},
onError: () => {
setFileTreeDataLoading(false);
},
});
useEffect(() => {
if (skillId) {
setFileTreeDataLoading(true);
runSkillInfo(skillId);
}
}, [skillId]);
/** 刷新文件树:重新拉取技能详情并更新 files 列表 */
const handleRefreshFileTree = useCallback(async () => {
if (!skillId) {
return;
}
setFileTreeDataLoading(true);
try {
await runSkillInfo(skillId);
} catch {
setFileTreeDataLoading(false);
}
}, [skillId, runSkillInfo]);
return (
<div className={cx(styles['page-container'])}>
{/* 技能头部 */}
<SkillHeader skillInfo={skillInfo} mode={mode} />
<div className={cx(styles['layout-wrapper'])}>
{/* 详情内容区域 */}
<div className={cx(styles['detail-section'])}>
<div
className={cx(
'flex',
'h-full',
'flex-col',
'overflow-hide',
'relative',
)}
>
<div className={cx('flex', 'flex-1', 'overflow-y')}>
{/* 文件树视图(只读模式) */}
<FileTreeViewPanel
readOnly={true}
taskAgentSelectedFileId={'SKILL.md'}
initViewFileType={'code'}
isProjectSkill={true}
fileTreeDataLoading={fileTreeDataLoading}
originalFiles={skillInfo?.files || []}
showMoreActions={false}
isFullscreenPreview={false}
showFullscreenIcon={false}
isFileTreePinned={true}
showRefreshButton={true}
onRefreshFileTree={handleRefreshFileTree}
isShowShare={false}
isShowDownloadButton={false}
isShowExportPdfButton={false}
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default SkillDetailView;

View File

@@ -0,0 +1,415 @@
@import '@/styles/token.less';
@button-max-width: 160px;
/* ─── Card Shell ─────────────────────────────────────── */
.card {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
border: 1px solid @colorBorderSecondary;
border-radius: @borderRadiusLg;
background: @colorBgContainer;
overflow: hidden;
&.cardDocked {
height: auto;
max-height: none;
.title {
white-space: normal;
overflow: hidden;
display: box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
/* ─── Header ─────────────────────────────────────────── */
.header {
flex-shrink: 0;
display: flex;
align-items: flex-start;
gap: @marginXs;
padding: @paddingSm @paddingSm @paddingXs;
// background: @colorBgContainer;
.icon {
display: none;
}
.iconWrap {
display: none;
}
.headerMain {
flex: 1;
min-width: 0;
.eyebrow {
display: block;
margin-bottom: @marginXxs;
color: @colorTextTertiary;
font-size: @fontSizeSm;
font-weight: 500;
line-height: 1.3;
}
.title {
display: block;
min-width: 0;
color: @colorTextBase;
font-size: @fontSize;
font-weight: 600;
line-height: @lineHeight;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.kindTag {
display: none;
}
.statusTag {
flex-shrink: 0;
align-self: flex-start;
margin: 2px 0 0;
font-size: @fontSizeSm;
line-height: 1.4;
}
}
&:not(.cardDocked) .header {
padding: @paddingXs @paddingSm;
border-bottom: none;
}
/* ─── Body / Options List ─────────────────────────────── */
.body {
padding: 0;
.filePreview {
display: flex;
flex-direction: column;
gap: @marginXs;
padding: 0 @paddingSm @paddingXs;
}
.filePreviewItem {
min-width: 0;
border: 1px solid @colorBorderSecondary;
border-radius: @borderRadius;
overflow: hidden;
background: @colorBgContainer;
}
.filePath {
display: block;
padding: @paddingXxs @paddingXs;
border-bottom: 1px solid @colorBorderSecondary;
color: @colorTextSecondary;
font-family: @fontFamilyCode;
font-size: @fontSizeSm;
line-height: 1.4;
background: @colorFillQuaternary;
overflow: hidden;
}
.diffPreview {
max-height: 220px;
min-height: 80px;
overflow: auto;
}
.actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: 0 @paddingSm @paddingXs; // 增加左右边距
gap: 6px; // 每一项之间增加微小的间距
.actionBtn {
width: 100%;
height: auto;
min-height: 30px; // 减少高度
padding: 2px @paddingXs; // 减少内边距
border: none;
border-radius: 10px !important;
background: transparent;
font-size: @fontSize;
font-weight: 400;
text-align: left;
display: flex;
align-items: center;
box-shadow: none !important;
&:hover {
background: @colorFillTertiary !important;
}
.buttonLabel {
display: inline-flex;
align-items: center;
font-size: inherit;
width: 100%;
gap: @marginXxs;
.option-index {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: @colorFill;
color: @colorTextSecondary;
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.button-text {
display: inline-block;
flex: 1;
min-width: 0;
max-width: @button-max-width;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
color: inherit !important;
}
.arrow-indicators {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
color: @colorTextTertiary;
font-size: 11px;
flex-shrink: 0;
.arrowIcon {
font-size: 10px;
}
}
.shortcutGroup {
display: inline-flex;
align-items: center;
gap: @marginXxs;
margin-left: auto;
flex-shrink: 0;
}
.multiCheck {
display: inline-flex;
align-items: center;
margin-right: 2px;
.multiCheckItem {
font-size: 11px;
&:not(:first-child) {
margin-left: -6px;
}
}
}
}
&.allowBtn {
background-color: transparent !important;
border-color: transparent !important;
color: @colorText !important;
&:hover {
background-color: @colorFillTertiary !important;
}
.buttonLabel .shortcutGroup .shortcut {
border: none;
background: rgba(0, 0, 0, 8%);
color: @colorTextSecondary;
}
}
&.rejectBtn {
background-color: transparent !important;
border-color: transparent !important;
color: @colorTextSecondary !important;
&:hover {
background: @colorFillTertiary !important;
color: @colorText !important;
}
.buttonLabel .shortcutGroup .shortcut {
border: 1px solid @colorBorderSecondary;
background: @colorBgContainer;
color: @colorTextTertiary;
}
}
&.active-btn {
border-radius: 10px !important; // 胶囊形状
background-color: @colorFillSecondary !important; // 浅灰背景色
color: @colorText !important;
border-color: transparent !important;
&:hover {
background-color: @colorFill !important;
}
}
}
}
}
.active-btn {
.buttonLabel .option-index {
background: @colorTextBase !important;
color: @colorBgContainer !important;
}
}
&:not(.cardDocked) {
.body {
display: none;
}
.actions {
flex-flow: row wrap;
gap: @marginXs;
padding: @paddingXs @paddingSm;
border-top: none;
.actionBtn {
width: auto;
height: @controlHeightSm;
padding: 0 @paddingSm;
border-radius: @borderRadius;
font-weight: 500;
font-size: @fontSizeSm;
border: 1px solid @colorBorder;
&.allowBtn {
background-color: @colorBgSolid !important;
border-color: @colorBgSolid !important;
color: @colorTextLightSolid !important;
&:hover {
background-color: @colorBgSolidHover !important;
border-color: @colorBgSolidHover !important;
}
.buttonLabel .shortcutGroup .shortcut {
border: none;
background: rgba(255, 255, 255, 20%);
color: rgba(255, 255, 255, 80%);
}
}
&.rejectBtn {
color: @colorError !important;
border-color: @colorErrorBorder !important;
background-color: @colorBgContainer !important;
&:hover {
background-color: @colorErrorBg !important;
border-color: @colorErrorBorderHover !important;
}
}
}
}
}
/* ─── Footer ─────────────────────────────────────────── */
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: @marginXs;
padding: @paddingXs @paddingSm;
border-top: 1px solid @colorBorderSecondary;
background: @colorBgContainer;
border-radius: 0 0 @borderRadiusLg @borderRadiusLg;
.buttonLabel {
display: inline-flex;
align-items: center;
min-width: 0;
gap: @marginXxs;
}
.submit-btn {
background: @colorBgSolid !important;
border-color: @colorBgSolid !important;
color: @colorTextLightSolid !important;
border-radius: @borderRadiusLg !important;
font-weight: 500;
&:hover {
background: @colorBgSolidHover !important;
border-color: @colorBgSolidHover !important;
}
&:disabled {
opacity: 0.5;
}
.shortcut {
border: 1px solid rgba(255, 255, 255, 25%) !important;
background: rgba(255, 255, 255, 15%) !important;
color: rgba(255, 255, 255, 75%) !important;
}
}
.cancel-btn {
border: none !important;
background: transparent !important;
box-shadow: none !important;
color: @colorTextSecondary !important;
padding-left: @paddingSm !important; // 增加内边距
padding-right: @paddingSm !important; // 增加内边距
margin-right: @marginXs; // 增加右侧外边距以和确定按钮拉开距离
border-radius: @borderRadiusLg !important;
&:hover {
color: @colorText !important;
background: @colorFillTertiary !important;
}
.shortcut {
border: 1px solid @colorBorderSecondary !important;
background: @colorFillSecondary !important;
color: @colorTextSecondary !important;
}
}
}
/* ─── Error ─────────────────────────────────────────── */
.error {
display: block;
padding: 0 @paddingSm @paddingXs;
font-size: @fontSizeSm;
}
}
.shortcut {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 @paddingXxs;
border-radius: @borderRadiusSm;
font-size: 11px;
font-family: inherit;
font-weight: 600;
line-height: 1;
pointer-events: none;
}

View File

@@ -0,0 +1,183 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { AcpPermissionInteraction } from '../types/acpIntervention';
import AcpPermissionCard from './index';
vi.mock('@/services/i18nRuntime', () => ({
t: (key: string, ...args: string[]) => {
const dict: Record<string, string> = {
'PC.Components.AcpPermissionCard.defaultTitle': '权限审批',
'PC.Components.AcpPermissionCard.eyebrow': '安全确认',
'PC.Components.AcpPermissionCard.submitted': '已提交',
'PC.Components.AcpPermissionCard.shortcutHint': '{0} ({1})',
};
const template = dict[key] ?? key;
return args.reduce(
(text, item, index) => text.replace(`{${index}}`, item),
template,
);
},
}));
vi.mock('./index.less', () => ({
default: new Proxy({}, { get: (_: any, key: string) => String(key) }),
}));
vi.mock('./useAcpPermissionShortcuts', () => ({
useAcpPermissionShortcuts: vi.fn(),
getAcpPermissionShortcutHint: vi.fn(() => ''),
}));
function createInteraction(
overrides: Partial<AcpPermissionInteraction> = {},
): AcpPermissionInteraction {
return {
intervention: {
id: 'itv-001',
revision: 1,
kind: 'approval',
status: 'pending',
sessionId: 'sess-001',
source: 'acp_permission',
engine: 'claude-code',
protocol: 'acp',
callbackTarget: { kind: 'electron', targetId: 'tgt-1' },
schemaRef: 'acp/permission/v1',
acp: {
method: 'session/request_permission',
request: {
sessionId: 'sess-001',
toolCall: {
toolCallId: 'tc-001',
title: '执行 bash 命令',
kind: 'bash',
},
options: [
{ optionId: 'opt-1', kind: 'allow_once', name: '允许一次' },
{ optionId: 'opt-2', kind: 'allow_always', name: '始终允许' },
{ optionId: 'opt-3', kind: 'reject_once', name: '拒绝一次' },
{ optionId: 'opt-4', kind: 'reject_always', name: '始终拒绝' },
],
},
},
createdAt: Date.now(),
},
responseStatus: 'pending',
selectedOptionId: undefined,
errorMessage: undefined,
...overrides,
} as AcpPermissionInteraction;
}
describe('AcpPermissionCard', () => {
it('renders only visible options (excludes reject_always)', () => {
const onRespond = vi.fn();
render(
<AcpPermissionCard
interaction={createInteraction()}
onRespond={onRespond}
keyboardShortcutsEnabled={false}
/>,
);
expect(screen.getByText('允许一次')).toBeTruthy();
expect(screen.getByText('始终允许')).toBeTruthy();
expect(screen.getByText('拒绝一次')).toBeTruthy();
expect(screen.queryByText('始终拒绝')).toBeNull();
});
it('calls onRespond with correct payload when an option is clicked', () => {
const onRespond = vi.fn();
render(
<AcpPermissionCard
interaction={createInteraction()}
onRespond={onRespond}
keyboardShortcutsEnabled={false}
/>,
);
fireEvent.click(screen.getByText('允许一次'));
expect(onRespond).toHaveBeenCalledTimes(1);
expect(onRespond).toHaveBeenCalledWith({
outcome: { outcome: 'selected', optionId: 'opt-1' },
});
});
it('shows submitted tag and disables buttons when submitted', () => {
const onRespond = vi.fn();
render(
<AcpPermissionCard
interaction={createInteraction({ responseStatus: 'submitted' })}
onRespond={onRespond}
keyboardShortcutsEnabled={false}
/>,
);
expect(screen.getByText('已提交')).toBeTruthy();
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => {
expect(btn).toBeDisabled();
});
});
it('shows loading on the selected option when submitting', () => {
render(
<AcpPermissionCard
interaction={createInteraction({
responseStatus: 'submitting',
selectedOptionId: 'opt-1',
})}
onRespond={vi.fn()}
keyboardShortcutsEnabled={false}
/>,
);
const buttons = screen.getAllByRole('button');
// antd applies "ant-btn-loading" class when loading
const allowOnceBtn = buttons[0];
expect(allowOnceBtn.className).toContain('loading');
});
it('shows error message when failed', () => {
render(
<AcpPermissionCard
interaction={createInteraction({
responseStatus: 'failed',
errorMessage: '网络错误',
})}
onRespond={vi.fn()}
keyboardShortcutsEnabled={false}
/>,
);
expect(screen.getByText('网络错误')).toBeTruthy();
});
it('disables all buttons when onRespond is undefined', () => {
render(
<AcpPermissionCard
interaction={createInteraction()}
keyboardShortcutsEnabled={false}
/>,
);
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => {
expect(btn).toBeDisabled();
});
});
it('renders eyebrow text when docked', () => {
render(
<AcpPermissionCard
interaction={createInteraction()}
docked
onRespond={vi.fn()}
keyboardShortcutsEnabled={false}
/>,
);
expect(screen.getByText('安全确认')).toBeTruthy();
});
});

View File

@@ -0,0 +1,313 @@
import ChangeFileGitDiffView, {
DiffModeEnum,
} from '@/components/business-component/ChangeFileGitDiffView';
import { EllipsisTooltip } from '@/components/custom/EllipsisTooltip';
import { t } from '@/services/i18nRuntime';
import { normalizeFileDiffItems } from '@/utils/fileChangeDiff';
import {
ArrowDownOutlined,
ArrowUpOutlined,
SafetyOutlined,
} from '@ant-design/icons';
import { Button, Tag, Typography } from 'antd';
import classNames from 'classnames';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
AcpPermissionInteraction,
AcpRequestPermissionResponse,
} from '../types/acpIntervention';
import styles from './index.less';
import {
getAcpPermissionShortcutHint,
useAcpPermissionShortcuts,
} from './useAcpPermissionShortcuts';
const { Text } = Typography;
const HIDDEN_OPTION_KINDS = new Set(['reject_always']);
interface AcpPermissionCardProps {
interaction: AcpPermissionInteraction;
docked?: boolean;
dockShellClassName?: string;
keyboardShortcutsEnabled?: boolean;
onRespond?: (response: AcpRequestPermissionResponse) => void;
}
const AcpPermissionCard: React.FC<AcpPermissionCardProps> = ({
interaction,
docked = false,
dockShellClassName,
keyboardShortcutsEnabled = true,
onRespond,
}) => {
const request = interaction.intervention.acp.request;
const toolCall = request.toolCall || {};
const isSubmitting = interaction.responseStatus === 'submitting';
const isSubmitted = interaction.responseStatus === 'submitted';
const isFailed = interaction.responseStatus === 'failed';
const disabled = isSubmitting || isSubmitted || !onRespond;
const [activeIndex, setActiveIndex] = useState(0);
const [submitType, setSubmitType] = useState<'confirm' | 'cancel' | null>(
null,
);
const cardRef = useRef<HTMLDivElement>(null);
// 卡片弹出后自动获取焦点
useEffect(() => {
if (!disabled) {
cardRef.current?.focus();
}
}, [disabled]);
// 保持焦点始终在卡片上,防止失焦导致快捷键失效
const handleBlur = (e: React.FocusEvent) => {
if (
!disabled &&
cardRef.current &&
!cardRef.current.contains(e.relatedTarget)
) {
e.preventDefault();
cardRef.current.focus();
}
};
const handleSelect = useCallback(
(optionId: string, type: 'confirm' | 'cancel' = 'confirm') => {
if (isSubmitting || isSubmitted || !onRespond) {
return;
}
setSubmitType(type);
onRespond?.({
outcome: {
outcome: 'selected',
optionId,
},
});
},
[onRespond, isSubmitting, isSubmitted],
);
useEffect(() => {
if (!isSubmitting) {
setSubmitType(null);
}
}, [isSubmitting]);
const title =
toolCall.title?.trim() || t('PC.Components.AcpPermissionCard.defaultTitle');
const rawCommand = (toolCall.rawInput as any)?.command;
const displayTitle = title === 'bash' && rawCommand ? rawCommand : title;
const fileDiffItems = useMemo(
() =>
normalizeFileDiffItems({
input: toolCall.rawInput,
locations: toolCall.locations,
}),
[toolCall.rawInput, toolCall.locations],
);
const visibleOptions = useMemo(
() =>
(request.options ?? [])
.filter((option) => !HIDDEN_OPTION_KINDS.has(option.kind))
.sort((a, b) => {
const orderA = getAcpPermissionShortcutHint(a.kind) || '9';
const orderB = getAcpPermissionShortcutHint(b.kind) || '9';
return orderA.localeCompare(orderB);
}),
[request.options],
);
const rejectOption = useMemo(
() => (request.options ?? []).find((o) => o.kind.startsWith('reject')),
[request.options],
);
useEffect(() => {
setActiveIndex(0);
}, [visibleOptions]);
const handleCancel = useCallback(() => {
if (rejectOption) {
handleSelect(rejectOption.optionId, 'cancel');
}
}, [rejectOption, handleSelect]);
const isSubmitLoading =
isSubmitting && (submitType === 'confirm' || !submitType);
const isCancelLoading = isSubmitting && submitType === 'cancel';
useAcpPermissionShortcuts({
enabled: !disabled && keyboardShortcutsEnabled,
options: visibleOptions,
onSelect: handleSelect,
onCancel: handleCancel,
activeIndex,
setActiveIndex,
});
return (
<div
ref={cardRef}
className={classNames(styles.card, dockShellClassName, {
[styles.cardDocked]: docked,
})}
tabIndex={-1}
role="group"
aria-label={displayTitle}
onBlur={handleBlur}
>
<header className={styles.header}>
{docked ? (
<div className={styles.iconWrap} aria-hidden>
<SafetyOutlined />
</div>
) : (
<SafetyOutlined className={styles.icon} />
)}
<div className={styles.headerMain}>
{docked ? (
<span className={styles.eyebrow}>
{t('PC.Components.AcpPermissionCard.eyebrow')}
</span>
) : null}
<Text strong className={styles.title}>
{displayTitle}
</Text>
</div>
{toolCall.kind ? (
<Tag color="processing" className={styles.kindTag}>
{toolCall.kind}
</Tag>
) : null}
{isSubmitted ? (
<Tag color="success" className={styles.statusTag}>
{t('PC.Components.AcpPermissionCard.submitted')}
</Tag>
) : null}
</header>
<div className={styles.body}>
{fileDiffItems.length ? (
<div className={styles.filePreview}>
{fileDiffItems.map((item) => (
<div key={item.path} className={styles.filePreviewItem}>
<div className={styles.filePath}>
<Text
style={{ width: '100%', color: 'inherit', margin: 0 }}
ellipsis={{ tooltip: item.path }}
>
{item.path}
</Text>
</div>
<div className={styles.diffPreview}>
<ChangeFileGitDiffView
fileId={`${toolCall.toolCallId || item.path}-${item.path}`}
fileName={item.path}
originalContent={item.oldText}
modifiedContent={item.newText}
diffViewMode={DiffModeEnum.Unified}
/>
</div>
</div>
))}
</div>
) : null}
<div className={styles.actions}>
{visibleOptions.map((option, index) => {
const isAllow = option.kind.startsWith('allow');
const isActive = index === activeIndex;
const camelCaseKind = option.kind.replace(
/_([a-z])/g,
(_, letter) => letter.toUpperCase(),
);
const label =
t(`PC.Components.AcpPermissionCard.${camelCaseKind}` as any) ||
option.name ||
option.optionId;
return (
<Button
key={option.optionId}
className={classNames(styles.actionBtn, {
[styles.allowBtn]: isAllow && !isActive,
[styles.rejectBtn]:
option.kind.startsWith('reject') && !isActive,
[styles['active-btn']]: isActive,
})}
// loading={
// isSubmitting &&
// interaction.selectedOptionId === option.optionId
// }
disabled={disabled}
onClick={() => setActiveIndex(index)}
onDoubleClick={() => handleSelect(option.optionId)}
>
<span className={styles.buttonLabel}>
<span className={styles['option-index']}>{index + 1}</span>
<EllipsisTooltip
text={label}
className={styles['button-text']}
/>
{isActive && !isSubmitted && (
<span className={styles['arrow-indicators']}>
<ArrowUpOutlined className={styles.arrowIcon} />
<ArrowDownOutlined className={styles.arrowIcon} />
</span>
)}
</span>
</Button>
);
})}
</div>
</div>
<footer className={styles.footer}>
<Button
className={styles['cancel-btn']}
loading={isCancelLoading}
disabled={disabled || !rejectOption}
onClick={() =>
rejectOption && handleSelect(rejectOption.optionId, 'cancel')
}
>
<span className={styles.buttonLabel}>
{t('PC.Components.McpAskQuestionCard.skip')}
<kbd className={styles.shortcut}>Esc</kbd>
</span>
</Button>
<Button
className={styles['submit-btn']}
loading={isSubmitLoading}
disabled={disabled}
onClick={() => {
const activeOption = visibleOptions[activeIndex];
if (activeOption) {
handleSelect(activeOption.optionId, 'confirm');
}
}}
>
<span className={styles.buttonLabel}>
{t('PC.Common.Global.confirm')}
<kbd className={styles.shortcut}></kbd>
</span>
</Button>
</footer>
{isFailed && interaction.errorMessage ? (
<Text type="danger" className={styles.error}>
{interaction.errorMessage}
</Text>
) : null}
</div>
);
};
export default AcpPermissionCard;

View File

@@ -0,0 +1,40 @@
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useAcpPermissionShortcuts } from './useAcpPermissionShortcuts';
function ShortcutHarness({
onSelect,
onCancel,
}: {
onSelect: (optionId: string) => void;
onCancel: () => void;
}) {
useAcpPermissionShortcuts({
enabled: true,
options: [
{ optionId: 'allow-once', kind: 'allow_once', name: 'Allow once' },
{ optionId: 'allow-always', kind: 'allow_always', name: 'Allow always' },
{ optionId: 'reject-once', kind: 'reject_once', name: 'Reject once' },
],
onSelect,
onCancel,
});
return <div>shortcuts</div>;
}
describe('useAcpPermissionShortcuts', () => {
it('selects only explicit permission options', () => {
const onSelect = vi.fn();
const onCancel = vi.fn();
render(<ShortcutHarness onSelect={onSelect} onCancel={onCancel} />);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: '3' }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(onSelect).toHaveBeenNthCalledWith(1, 'allow-once');
expect(onSelect).toHaveBeenNthCalledWith(2, 'reject-once');
expect(onSelect).toHaveBeenCalledTimes(2);
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,100 @@
import { useEffect } from 'react';
import {
isFormFieldTarget,
useInterventionEscapeKey,
} from '../hooks/useInterventionEscapeKey';
import type { AcpPermissionOption } from '../types/acpIntervention';
const KIND_DIGIT_KEY: Partial<Record<string, string>> = {
allow_once: '1',
allow_always: '2',
reject_once: '3',
};
interface UseAcpPermissionShortcutsOptions {
enabled: boolean;
options: AcpPermissionOption[];
onSelect: (optionId: string) => void;
onCancel: () => void;
activeIndex: number;
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
}
export function useAcpPermissionShortcuts({
enabled,
options,
onSelect,
onCancel,
activeIndex,
setActiveIndex,
}: UseAcpPermissionShortcutsOptions): void {
useInterventionEscapeKey({
enabled,
onEscape: onCancel,
respectFormFieldFocus: true,
});
useEffect(() => {
if (!enabled || !options.length) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (isFormFieldTarget(event.target)) {
return;
}
if (event.key === 'Escape') {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
setActiveIndex((prev) => {
const count = options.length;
if (count === 0) return prev;
if (event.key === 'ArrowDown') {
return (prev + 1) % count;
} else {
return (prev - 1 + count) % count;
}
});
return;
}
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey
) {
event.preventDefault();
const activeOption = options[activeIndex];
if (activeOption) {
onSelect(activeOption.optionId);
}
return;
}
const digit = event.key.length === 1 ? event.key : '';
if (!/^[1-9]$/.test(digit)) {
return;
}
const matched = options.find(
(option) => KIND_DIGIT_KEY[option.kind] === digit,
);
if (matched) {
event.preventDefault();
onSelect(matched.optionId);
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [enabled, onSelect, options, activeIndex, setActiveIndex]);
}
export function getAcpPermissionShortcutHint(kind: string): string | undefined {
return KIND_DIGIT_KEY[kind];
}

View File

@@ -0,0 +1,6 @@
@import '@/styles/token.less';
.host {
box-sizing: border-box;
width: 100%;
}

View File

@@ -0,0 +1,65 @@
@import '@/styles/token.less';
.cardSlot {
display: block;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
}
/* ─── 多干预卡片堆叠 ─────────────────────────────── */
.stackRoot {
position: relative;
width: 100%;
}
.stackBadge {
position: absolute;
top: 5px;
right: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: @colorPrimary;
color: @colorTextLightSolid;
font-size: 11px;
font-weight: 600;
line-height: 1;
box-shadow: 0 2px 5px rgba(0, 0, 0, 10%);
pointer-events: none;
}
.stack {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 100%;
padding-top: var(--stack-offset-total, 0);
padding-right: var(--stack-offset-total, 0);
overflow: visible;
}
.stackLayer {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
}
.stackLayerFront {
position: relative;
flex-shrink: 0;
}
.stackLayerBack {
position: absolute;
left: 0;
right: 0;
pointer-events: none;
}

View File

@@ -0,0 +1,138 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import AcpPermissionCard from '../AcpPermissionCard';
import type { InterventionQueueItem } from '../hooks/useActiveInterventionQueue';
import McpAskQuestionCard from '../McpAskQuestionCard';
import type {
AcpPermissionInteraction,
AcpRequestPermissionResponse,
} from '../types/acpIntervention';
import type {
McpAskInteraction,
McpAskRespondPayload,
} from '../types/mcpAskIntervention';
import styles from './DockPanel.less';
import dockCardStyles from './intervention-dock-card.module.less';
const STACK_OFFSET_PX = 5;
interface InterventionDockPanelProps {
items: InterventionQueueItem[];
onRespondAcpPermission: (
interaction: AcpPermissionInteraction,
response: AcpRequestPermissionResponse,
) => void | Promise<void>;
onRespondMcpAsk: (
interaction: McpAskInteraction,
payload: McpAskRespondPayload,
) => void | Promise<void>;
}
function getItemKey(item: InterventionQueueItem): string {
if (item.kind === 'acp_permission') {
return `acp-${item.interaction.intervention.id}`;
}
return `ask-${item.interaction.input.requestId}`;
}
const DockPanel: React.FC<InterventionDockPanelProps> = ({
items,
onRespondAcpPermission,
onRespondMcpAsk,
}) => {
const renderCard = useCallback(
(item: InterventionQueueItem, keyboardShortcutsEnabled: boolean) => {
const dockShellClassName = dockCardStyles.root;
if (item.kind === 'acp_permission') {
return (
<AcpPermissionCard
interaction={item.interaction}
docked
dockShellClassName={dockShellClassName}
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
onRespond={(response) =>
onRespondAcpPermission(item.interaction, response)
}
/>
);
}
return (
<McpAskQuestionCard
interaction={item.interaction}
dockShellClassName={dockShellClassName}
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
onRespond={(payload) => onRespondMcpAsk(item.interaction, payload)}
/>
);
},
[onRespondAcpPermission, onRespondMcpAsk],
);
if (!items.length) {
return null;
}
const stackDepth = items.length;
if (stackDepth === 1) {
return (
<div className={styles.cardSlot} aria-label="Agent intervention">
{renderCard(items[0], true)}
</div>
);
}
const offsetTotal = (stackDepth - 1) * STACK_OFFSET_PX;
// FIFOitems 已按 sortKey 升序,最早触发的在 frontindex 0逐个审批
return (
<div
className={styles.stackRoot}
aria-label="Agent interventions"
style={{
['--stack-offset-total' as string]: `${offsetTotal}px`,
}}
>
{stackDepth > 2 ? (
<span
className={styles.stackBadge}
style={{ zIndex: stackDepth + 10 }}
aria-hidden
>
{stackDepth - 1}
</span>
) : null}
<div className={styles.stack}>
{items.map((item, index) => {
const isFront = index === 0;
return (
<div
key={getItemKey(item)}
className={classNames(styles.stackLayer, {
[styles.stackLayerFront]: isFront,
[styles.stackLayerBack]: !isFront,
})}
style={{
zIndex: stackDepth - index,
...(isFront
? undefined
: {
bottom: index * STACK_OFFSET_PX,
transform: `translate(${index * STACK_OFFSET_PX}px, -${
index * STACK_OFFSET_PX
}px)`,
}),
}}
aria-hidden={!isFront}
>
<div className={styles.cardSlot}>{renderCard(item, isFront)}</div>
</div>
);
})}
</div>
</div>
);
};
export default DockPanel;

View File

@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import AgentInterventionChatLayer from './index';
vi.mock('../AcpPermissionCard', () => ({
__esModule: true,
default: ({ interaction }: any) => (
<div>acp:{interaction.intervention.id}</div>
),
}));
describe('AgentInterventionChatLayer', () => {
it('renders nothing when no pending interventions', () => {
const { container } = render(
<AgentInterventionChatLayer
messageList={[]}
onRespondAcpPermission={vi.fn()}
onRespondMcpAsk={vi.fn()}
/>,
);
expect(container.innerHTML).toBe('');
});
it('shows stacked approvals and a remaining-count badge when more than two are pending', () => {
const createInteraction = (id: string, createdAt: number) => ({
intervention: {
id,
createdAt,
acp: {
request: {
toolCall: {
toolCallId: `${id}-call`,
rawInput: null,
},
},
},
},
responseStatus: 'pending',
});
render(
<AgentInterventionChatLayer
messageList={
[
{
id: 'assistant-1',
index: 1,
acpPermissionInteractions: [createInteraction('itv-1', 100)],
},
{
id: 'assistant-2',
index: 2,
acpPermissionInteractions: [createInteraction('itv-2', 200)],
},
{
id: 'assistant-3',
index: 3,
acpPermissionInteractions: [createInteraction('itv-3', 300)],
},
] as any
}
onRespondAcpPermission={vi.fn()}
onRespondMcpAsk={vi.fn()}
/>,
);
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('acp:itv-1')).toBeInTheDocument();
expect(screen.getByText('acp:itv-2')).toBeInTheDocument();
expect(screen.getByText('acp:itv-3')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,103 @@
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import classNames from 'classnames';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useActiveInterventionQueue } from '../hooks/useActiveInterventionQueue';
import type {
AcpPermissionInteraction,
AcpRequestPermissionResponse,
} from '../types/acpIntervention';
import type {
McpAskInteraction,
McpAskRespondPayload,
} from '../types/mcpAskIntervention';
import styles from './AgentInterventionChatLayer.less';
import DockPanel from './DockPanel';
export interface AgentInterventionChatLayerProps {
className?: string;
messageList: MessageInfo[];
onRespondAcpPermission: (
interaction: AcpPermissionInteraction,
response: AcpRequestPermissionResponse,
) => void | Promise<void>;
onRespondMcpAsk: (
interaction: McpAskInteraction,
payload: McpAskRespondPayload,
) => void | Promise<void>;
}
const AgentInterventionChatLayer: React.FC<AgentInterventionChatLayerProps> = ({
className,
messageList,
onRespondAcpPermission,
onRespondMcpAsk,
}) => {
const [dismissedMcpAskRequestIds, setDismissedMcpAskRequestIds] = useState<
Set<string>
>(() => new Set());
const activeQueueItems = useActiveInterventionQueue(messageList);
const queueItems = useMemo(
() =>
activeQueueItems.filter(
(item) =>
item.kind !== 'mcp_ask' ||
!dismissedMcpAskRequestIds.has(item.interaction.input.requestId),
),
[activeQueueItems, dismissedMcpAskRequestIds],
);
useEffect(() => {
if (!dismissedMcpAskRequestIds.size) {
return;
}
const activeRequestIds = new Set(
activeQueueItems
.filter((item) => item.kind === 'mcp_ask')
.map((item) => item.interaction.input.requestId),
);
setDismissedMcpAskRequestIds((prev) => {
const next = new Set(
[...prev].filter((requestId) => activeRequestIds.has(requestId)),
);
return next.size === prev.size ? prev : next;
});
}, [activeQueueItems, dismissedMcpAskRequestIds.size]);
const handleRespondMcpAsk = useCallback(
async (interaction: McpAskInteraction, payload: McpAskRespondPayload) => {
const requestId = interaction.input.requestId;
setDismissedMcpAskRequestIds((prev) => new Set(prev).add(requestId));
try {
await onRespondMcpAsk(interaction, payload);
} catch (error) {
setDismissedMcpAskRequestIds((prev) => {
const next = new Set(prev);
next.delete(requestId);
return next;
});
console.error('[agentIntervention] Failed to respond MCP ask', error);
}
},
[onRespondMcpAsk],
);
if (!queueItems.length) {
return null;
}
return (
<div
className={classNames(styles.host, className)}
data-agent-intervention-dock
>
<DockPanel
items={queueItems}
onRespondAcpPermission={onRespondAcpPermission}
onRespondMcpAsk={handleRespondMcpAsk}
/>
</div>
);
};
export default AgentInterventionChatLayer;

View File

@@ -0,0 +1,23 @@
@import '@/styles/color.less';
@import '@/styles/token.less';
/**
* 固定栏干预卡片统一外壳ACP / MCP Ask 根节点共用同一 class
*/
.root {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: visible;
border: 1px solid rgba(0, 0, 0, 8%);
border-radius: 14px;
background: @colorBgContainer;
box-shadow: 0 2px 8px -3px rgba(0, 0, 0, 6%), 0 1px 2px -1px rgba(0, 0, 0, 4%);
}
/* 与 Chat 页 intervention-dock 配合:内容区占满 dock 宽度 */
.host {
box-sizing: border-box;
width: 100%;
}

View File

@@ -0,0 +1,84 @@
import ChatUploadFile from '@/components/ChatUploadFile';
import { UPLOAD_FILE_ACTION } from '@/constants/common.constants';
import { ACCESS_TOKEN } from '@/constants/home.constants';
import { t } from '@/services/i18nRuntime';
import type { UploadFileInfo } from '@/types/interfaces/common';
import { handleUploadFileList } from '@/utils/upload';
import { PaperClipOutlined } from '@ant-design/icons';
import { Button, Upload, type UploadProps } from 'antd';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { limitMcpAskUploadFileList } from '../utils/normalizeMcpAskFormData';
import styles from './McpAskFormField.less';
const cx = classNames.bind(styles);
export interface McpAskFileUploadProps {
/** Ant Design Form 注入的 fileList */
value?: UploadFileInfo[];
onChange?: (fileList: UploadFileInfo[]) => void;
disabled?: boolean;
multiple?: boolean;
accept?: string;
}
/**
* MCP Ask 文件字段上传对齐聊天输入框附件上传UPLOAD_FILE_ACTION + ChatUploadFile 列表)。
*/
const McpAskFileUpload: React.FC<McpAskFileUploadProps> = ({
value = [],
onChange,
disabled,
multiple,
accept,
}) => {
const token = localStorage.getItem(ACCESS_TOKEN) ?? '';
const fileList = value ?? [];
const emitChange = useCallback(
(nextList: UploadFileInfo[]) => {
onChange?.(limitMcpAskUploadFileList(nextList, multiple));
},
[onChange, multiple],
);
const handleChange: UploadProps['onChange'] = (info) => {
emitChange(handleUploadFileList(info.fileList ?? []));
};
const handleDel = (uid: string) => {
emitChange(fileList.filter((item) => item.uid !== uid));
};
return (
<div className={cx(styles['mcp-ask-file-upload'])}>
{fileList.length > 0 ? (
<ChatUploadFile files={fileList} onDel={handleDel} />
) : null}
<Upload
action={UPLOAD_FILE_ACTION}
headers={{ Authorization: token ? `Bearer ${token}` : '' }}
data={{ type: 'tmp' }}
disabled={disabled}
multiple={multiple}
maxCount={multiple ? undefined : 1}
accept={accept}
fileList={fileList}
onChange={handleChange}
showUploadList={false}
>
<Button
type="default"
disabled={disabled}
icon={<PaperClipOutlined />}
className={cx(styles['upload-trigger'])}
>
{t('PC.Components.McpAskQuestionCard.uploadDragText') ||
'点击上传文件'}
</Button>
</Upload>
</div>
);
};
export default McpAskFileUpload;

View File

@@ -0,0 +1,227 @@
@import '@/styles/token.less';
@import '@/styles/color.less';
.option-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
min-width: 0;
max-width: 100%;
:global {
.ant-radio-wrapper,
.ant-checkbox-wrapper {
align-items: center;
max-width: 100%;
margin: 0;
padding: 5px 10px;
background: @colorBgContainer;
color: @colorTextBase;
font-size: 12px;
line-height: 1.4;
white-space: normal;
transition: background 0.15s ease, color 0.15s ease;
}
.ant-radio-wrapper span:last-child,
.ant-checkbox-wrapper span:last-child {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.ant-radio-wrapper:hover,
.ant-checkbox-wrapper:hover {
background: @colorBgContainer !important;
color: @colorTextBase !important;
}
.ant-radio-wrapper-checked,
.ant-checkbox-wrapper-checked {
background: @colorBgContainer !important;
color: @colorTextBase !important;
}
.ant-radio-wrapper::after,
.ant-checkbox-wrapper::after {
display: none;
}
.ant-radio-checked .ant-radio-inner {
border-color: @colorBgSolid !important;
background-color: @colorBgContainer !important;
}
.ant-radio-checked .ant-radio-inner::after {
background-color: @colorBgSolid !important;
}
.ant-radio-wrapper:hover .ant-radio-inner,
.ant-radio:hover .ant-radio-inner,
.ant-radio-input:focus + .ant-radio-inner {
border-color: @colorBgSolid !important;
}
.ant-checkbox-checked .ant-checkbox-inner {
border-color: @colorBgSolid !important;
background-color: @colorBgSolid !important;
}
.ant-checkbox-wrapper:hover .ant-checkbox-inner,
.ant-checkbox:hover .ant-checkbox-inner,
.ant-checkbox-input:focus + .ant-checkbox-inner {
border-color: @colorBgSolid !important;
}
}
}
.text-control {
width: 100%;
min-width: 0;
max-width: 100%;
border-radius: 6px !important;
font-size: 12px !important;
&:hover {
border-color: @colorBgSolid !important;
}
&:focus,
&:focus-within {
border-color: @colorBgSolid !important;
box-shadow: 0 0 0 2px @colorFillSecondary !important;
}
// 2. Global Ant Design wrapper hover & focus states (where .text-control is on the wrapper)
&:global(.ant-input-affix-wrapper):hover,
&:global(.ant-input-number):hover,
&:global(.ant-select):not(:global(.ant-select-disabled)):hover
:global(.ant-select-selector) {
border-color: @colorBgSolid !important;
}
&:global(.ant-input-affix-wrapper-focused),
&:global(.ant-input-number-focused),
&:global(.ant-select-focused) :global(.ant-select-selector),
&:global(.ant-select-open) :global(.ant-select-selector) {
border-color: @colorBgSolid !important;
box-shadow: 0 0 0 2px @colorFillSecondary !important;
}
// 3. Child elements selectors (just in case)
:global {
.ant-input,
.ant-input-number-input,
.ant-select-selector {
border-radius: 6px !important;
font-size: 12px !important;
&:hover {
border-color: @colorBgSolid !important;
}
&:focus {
border-color: @colorBgSolid !important;
box-shadow: 0 0 0 2px @colorFillSecondary !important;
}
}
.ant-input-textarea textarea {
font-size: 12px !important;
}
}
}
.mcp-ask-file-upload {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
max-width: 100%;
}
.upload-trigger {
align-self: flex-start;
font-size: 12px !important;
}
.upload-control {
width: 100%;
min-width: 0;
max-width: 100%;
:global {
.ant-upload,
.ant-upload-list,
.ant-upload-list-item,
.ant-upload-drag {
max-width: 100%;
}
.ant-upload-text {
overflow-wrap: anywhere;
word-break: break-word;
font-size: @fontSizeSm !important;
color: @colorTextSecondary !important;
}
.ant-upload-list-item-name {
overflow-wrap: anywhere;
word-break: break-word;
}
// Hover border color
.ant-upload-drag:not(.ant-upload-disabled):hover {
border-color: @colorBgSolid !important;
}
// Icon color
.ant-upload-drag-icon {
.anticon {
color: @colorBgSolid !important;
}
}
// Active/focus border color
.ant-upload-drag-hover {
border-color: @colorBgSolid !important;
}
// Upload list active/progress color
.ant-upload-list-item-done {
.anticon {
color: @colorBgSolid !important;
}
}
}
}
.list-radio {
display: flex !important;
align-items: center;
width: 100%;
min-height: 32px;
:global {
.ant-radio {
display: flex;
align-items: center;
}
}
}
.select-dropdown {
:global {
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
background-color: @colorFillTertiary !important;
color: @colorTextBase !important;
font-weight: 500;
}
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
background-color: @colorFillSecondary !important;
}
}
}

View File

@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Form } from 'antd';
import { describe, expect, it, vi } from 'vitest';
import type { ParsedMcpAskField } from '../utils/parseMcpAskSchema';
import McpAskFormField from './McpAskFormField';
vi.mock('@/services/i18nRuntime', () => ({
t: (key: string) => key,
dict: (key: string) => key,
}));
vi.mock('@/constants/common.constants', () => ({
UPLOAD_FILE_ACTION: '/upload',
}));
vi.mock('@/constants/home.constants', () => ({
ACCESS_TOKEN: 'access-token',
}));
vi.mock('@/utils/upload', () => ({
handleUploadFileList: (list: unknown) => list,
}));
vi.mock('./McpAskFormField.less', () => ({
default: new Proxy({}, { get: (_, key) => String(key) }),
}));
function renderNumberField(overrides: Partial<ParsedMcpAskField> = {}) {
const field: ParsedMcpAskField = {
name: 'count',
property: {
type: 'integer',
title: '并发数',
minimum: 1,
maximum: 10,
},
widget: 'number',
required: true,
options: {},
enumValues: [],
enumLabels: [],
...overrides,
};
render(
<Form>
<McpAskFormField field={field} />
</Form>,
);
}
describe('McpAskFormField number widget', () => {
it('renders InputNumber for number widget', () => {
renderNumberField();
expect(screen.getByRole('spinbutton')).toBeInTheDocument();
expect(screen.getByText('并发数')).toBeInTheDocument();
});
it('accepts numeric input', async () => {
const user = userEvent.setup();
renderNumberField();
const input = screen.getByRole('spinbutton');
await user.clear(input);
await user.type(input, '5');
expect(input).toHaveValue('5');
});
it('uses integer precision for integer schema type', () => {
renderNumberField({
property: { type: ['integer', 'null'], title: '数量', minimum: 0 },
});
const input = screen.getByRole('spinbutton');
expect(input).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,249 @@
import { t } from '@/services/i18nRuntime';
import { Checkbox, Form, Input, InputNumber, Radio, Select } from 'antd';
import classNames from 'classnames';
import React from 'react';
import { validateMcpAskRequiredFileField } from '../utils/normalizeMcpAskFormData';
import type { ParsedMcpAskField } from '../utils/parseMcpAskSchema';
import { getJsonSchemaPrimaryType } from '../utils/parseMcpAskSchema';
import McpAskFileUpload from './McpAskFileUpload';
import styles from './McpAskFormField.less';
const cx = classNames.bind(styles);
interface McpAskFormFieldProps {
field: ParsedMcpAskField;
disabled?: boolean;
}
const CUSTOM_OPTION_VALUE = '__custom__';
const McpAskFormField: React.FC<McpAskFormFieldProps> = ({
field,
disabled,
}) => {
const { name, property, widget, required, options, enumValues, enumLabels } =
field;
const label = property.title || name;
const rules = required
? [
{
required: true,
message: t('PC.Components.McpAskQuestionCard.fieldRequired'),
},
]
: [];
if (widget === 'checkboxes' && enumValues.length) {
return (
<Form.Item
name={name}
label={label}
rules={[
...rules,
{
type: 'array',
min: property.minItems ?? (required ? 1 : 0),
message: t('PC.Components.McpAskQuestionCard.multiSelectMin'),
},
]}
>
<Checkbox.Group
disabled={disabled}
className={cx(styles['option-group'])}
>
{enumValues.map((value, index) => (
<Checkbox key={value} value={value}>
{enumLabels[index] ?? value}
</Checkbox>
))}
</Checkbox.Group>
</Form.Item>
);
}
if (widget === 'radio-with-custom' && enumValues.length) {
const otherValue = options.otherValue ?? CUSTOM_OPTION_VALUE;
const otherField = options.otherField ?? `${name}Custom`;
return (
<>
<Form.Item name={name} label={label} rules={rules}>
<Radio.Group
disabled={disabled}
className={cx(styles['option-group'])}
>
{enumValues.map((value, index) => (
<Radio key={value} value={value}>
{enumLabels[index] ?? value}
</Radio>
))}
{options.allowCustom !== false && (
<Radio value={otherValue}>
{t('PC.Components.McpAskQuestionCard.customOption')}
</Radio>
)}
</Radio.Group>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev[name] !== cur[name]}
>
{({ getFieldValue }) =>
getFieldValue(name) === otherValue ? (
<Form.Item
name={otherField}
label={t('PC.Components.McpAskQuestionCard.customInputLabel')}
rules={[
{
required: true,
message: t(
'PC.Components.McpAskQuestionCard.fieldRequired',
),
},
]}
>
<Input
disabled={disabled}
className={cx(styles['text-control'])}
placeholder={
options.placeholder ||
t('PC.Components.McpAskQuestionCard.customInputPlaceholder')
}
/>
</Form.Item>
) : null
}
</Form.Item>
</>
);
}
if ((widget === 'radio' || widget === 'select') && enumValues.length) {
if (widget === 'select') {
return (
<Form.Item name={name} label={label} rules={rules}>
<Select
disabled={disabled}
className={cx(styles['text-control'])}
classNames={{
popup: {
root: cx(styles['select-dropdown']),
},
}}
placeholder={options.placeholder || label}
options={enumValues.map((value, index) => ({
value,
label: enumLabels[index] ?? value,
}))}
/>
</Form.Item>
);
}
return (
<Form.Item name={name} label={label} rules={rules}>
<Radio.Group disabled={disabled} className={cx(styles['option-group'])}>
{enumValues.map((value, index) => (
<Radio key={value} value={value}>
{enumLabels[index] ?? value}
</Radio>
))}
</Radio.Group>
</Form.Item>
);
}
if (widget === 'number') {
const isInteger = getJsonSchemaPrimaryType(property) === 'integer';
return (
<Form.Item name={name} label={label} rules={rules}>
<InputNumber
disabled={disabled}
className={cx(styles['text-control'])}
style={{ width: '100%' }}
placeholder={options.placeholder || label}
min={property.minimum}
max={property.maximum}
step={property.multipleOf ?? (isInteger ? 1 : undefined)}
precision={isInteger ? 0 : undefined}
/>
</Form.Item>
);
}
if (widget === 'textarea') {
return (
<Form.Item name={name} label={label} rules={rules}>
<Input.TextArea
disabled={disabled}
className={cx(styles['text-control'])}
rows={3}
placeholder={options.placeholder || label}
maxLength={property.maxLength}
showCount={!!property.maxLength}
/>
</Form.Item>
);
}
if (widget === 'list' && enumValues.length) {
return (
<Form.Item name={name} label={label} rules={rules}>
<Radio.Group disabled={disabled} className={cx(styles['option-group'])}>
{enumValues.map((value, index) => (
<Radio
key={value}
value={value}
className={cx(styles['list-radio'])}
>
{enumLabels[index] ?? value}
</Radio>
))}
</Radio.Group>
</Form.Item>
);
}
if (widget === 'file') {
const accept = (options as any)?.accept;
const multiple = (options as any)?.multiple;
const fileRules = required
? [
{
validator: async (_: unknown, value: unknown) => {
try {
validateMcpAskRequiredFileField(value);
} catch {
throw new Error(
t('PC.Components.McpAskQuestionCard.fieldRequired'),
);
}
},
},
]
: [];
return (
<Form.Item name={name} label={label} rules={fileRules}>
<McpAskFileUpload
disabled={disabled}
multiple={multiple}
accept={accept}
/>
</Form.Item>
);
}
return (
<Form.Item name={name} label={label} rules={rules}>
<Input
disabled={disabled}
className={cx(styles['text-control'])}
placeholder={options.placeholder || label}
maxLength={property.maxLength}
/>
</Form.Item>
);
};
export default McpAskFormField;

View File

@@ -0,0 +1,339 @@
@import '@/styles/token.less';
/* ─── Shell ─────────────────────────────────────────── */
.shell {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
max-width: 100%;
height: auto;
max-height: min(440px, 48vh);
overflow: hidden;
border: 1px solid @colorBorderSecondary;
border-radius: @borderRadiusLg;
background: @colorBgContainer;
/* ─── Header ─────────────────────────────────────────── */
.header {
flex-shrink: 0;
display: flex;
align-items: flex-start;
min-width: 0;
gap: @marginXs;
padding: @paddingSm @paddingSm @paddingXs;
background: @colorBgContainer;
border-bottom: 1px solid @colorBorderSecondary;
border-radius: @borderRadiusLg @borderRadiusLg 0 0;
.iconWrap {
display: none;
}
.headerMain {
flex: 1;
min-width: 0;
.eyebrow {
display: block;
margin-bottom: @marginXxs;
color: @colorTextTertiary;
font-size: @fontSizeSm;
font-weight: 500;
line-height: 1.3;
}
.title {
display: block;
min-width: 0;
max-width: 100%;
color: @colorTextBase;
font-size: @fontSize;
font-weight: 600;
line-height: @lineHeight;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.desc {
display: block;
min-width: 0;
max-width: 100%;
margin-top: @marginXxs;
font-size: @fontSizeSm;
color: @colorTextSecondary;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.statusTag {
flex-shrink: 0;
align-self: flex-start;
margin: 2px 0 0;
}
}
/* ─── Wizard ─────────────────────────────────────────── */
.wizardBlock {
flex-shrink: 0;
padding: @paddingXs @paddingSm 0;
border-bottom: 1px solid @colorBorderSecondary;
.steps {
margin-bottom: @marginXs;
:global {
.ant-steps-item-container {
display: flex !important;
align-items: center !important;
}
.ant-steps-item-icon {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.ant-steps-item-content {
display: inline-flex !important;
align-items: center !important;
}
.ant-steps-item-title {
font-size: @fontSizeSm !important;
line-height: 1 !important;
display: inline-flex !important;
align-items: center !important;
height: 24px !important;
}
.ant-steps-item-description {
max-width: 120px;
font-size: 11px !important;
}
.ant-steps-item-process .ant-steps-item-icon {
background: @colorBgSolid;
border-color: @colorBgSolid;
}
}
}
.stepCallout {
margin-bottom: @marginXs;
padding: @paddingXxs @paddingXs;
border-radius: @borderRadiusSm;
background: @colorFillQuaternary;
.stepCalloutText {
font-size: @fontSizeSm;
color: @colorTextSecondary;
line-height: 1.4;
}
}
}
/* ─── Form Surface (scrollable) ─────────────────────── */
.formSurface {
flex: 1;
min-height: 0;
padding: @paddingXs @paddingSm;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 10%) transparent;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 10%);
border-radius: 4px;
}
.form {
margin: 0;
min-width: 0;
:global {
.ant-form-item,
.ant-form-item-control,
.ant-form-item-control-input,
.ant-form-item-control-input-content {
min-width: 0;
max-width: 100%;
}
.ant-form-item {
margin-bottom: @marginXs;
}
.ant-form-item:last-child {
margin-bottom: 0;
}
.ant-form-item-label > label {
color: @colorTextSecondary;
font-size: @fontSizeSm;
font-weight: 500;
padding-bottom: 3px;
}
.ant-form-item-explain-error {
font-size: 11px;
margin-top: 2px;
}
}
}
}
/* ─── Footer ─────────────────────────────────────────── */
.footer {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: @marginXs;
padding: @paddingXs @paddingSm;
border-top: 1px solid @colorBorderSecondary;
background: @colorBgContainer;
border-radius: 0 0 @borderRadiusLg @borderRadiusLg;
.stepMeta {
font-size: @fontSizeSm;
color: @colorTextSecondary;
}
.footerActions {
margin-left: auto;
max-width: 100%;
display: flex;
align-items: center;
gap: @marginXs;
justify-content: flex-end;
.submit-btn {
background: @colorBgSolid !important;
border-color: @colorBgSolid !important;
color: @colorTextLightSolid !important;
border-radius: @borderRadiusLg !important;
font-weight: 500;
&:hover {
background: @colorBgSolidHover !important;
border-color: @colorBgSolidHover !important;
}
&:disabled {
opacity: 0.5;
}
.shortcut {
border: 1px solid rgba(255, 255, 255, 25%) !important;
background: rgba(255, 255, 255, 15%) !important;
color: rgba(255, 255, 255, 75%) !important;
}
}
.cancel-btn {
border: none !important;
background: transparent !important;
box-shadow: none !important;
color: @colorTextSecondary !important;
padding-left: @paddingSm !important; // 增加内边距
padding-right: @paddingSm !important; // 增加内边距
margin-right: @marginXs; // 增加右侧外边距以和确定按钮拉开距离
border-radius: @borderRadiusLg !important;
&:hover {
color: @colorText !important;
background: @colorFillTertiary !important;
}
.shortcut {
border: 1px solid @colorBorderSecondary !important;
background: @colorFillSecondary !important;
color: @colorTextSecondary !important;
}
}
}
}
/* ─── Error ─────────────────────────────────────────── */
.error {
display: block;
padding: 0 @paddingSm @paddingXs;
font-size: @fontSizeSm;
}
}
/* ─── 通用按钮快捷键角标 ─── */
.buttonLabel {
display: inline-flex;
align-items: center;
min-width: 0;
gap: @marginXxs;
}
.shortcut {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 @paddingXxs;
border: 1px solid rgba(255, 255, 255, 25%);
border-radius: @borderRadiusSm;
background: rgba(255, 255, 255, 15%);
color: rgba(255, 255, 255, 75%);
font-size: 11px;
font-family: inherit;
font-weight: 600;
line-height: 1;
pointer-events: none;
}
/* ─── 媒体查询响应式 ─── */
@media screen and (max-width: 520px) {
.shell {
max-height: min(480px, 58vh);
.header {
align-items: flex-start;
gap: @marginXs;
padding: @paddingXs @paddingXs @paddingXxs;
}
.wizardBlock,
.formSurface {
padding-left: @paddingXs;
padding-right: @paddingXs;
}
.footer {
justify-content: flex-end;
padding: @paddingXs @paddingXs;
.stepMeta {
width: 100%;
}
.footerActions {
margin-left: 0;
}
}
}
}

View File

@@ -0,0 +1,122 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { McpAskInteraction } from '../types/mcpAskIntervention';
import McpAskQuestionCard from './index';
vi.mock('@/services/i18nRuntime', () => ({
t: (key: string, ...args: string[]) => {
const dict: Record<string, string> = {
'PC.Common.Global.confirm': '确认',
'PC.Common.Global.cancel': '取消',
'PC.Components.McpAskQuestionCard.cancelShortcutHint': '取消Esc',
'PC.Components.McpAskQuestionCard.eyebrow': '补充回复',
'PC.Components.McpAskQuestionCard.fieldRequired': '请填写此项',
'PC.Components.McpAskQuestionCard.multiSelectMin': '请至少选择一项',
'PC.Components.McpAskQuestionCard.skip': '跳过',
};
const template = dict[key] ?? key;
return args.reduce(
(text, item, index) => text.replace(`{${index}}`, item),
template,
);
},
}));
vi.mock('./index.less', () => ({
default: new Proxy({}, { get: (_, key) => String(key) }),
}));
vi.mock('./McpAskFormField.less', () => ({
default: new Proxy({}, { get: (_, key) => String(key) }),
}));
const interaction: McpAskInteraction = {
toolCallId: 'tc-ask-1',
responseStatus: 'pending',
input: {
toolName: 'nuwax_ask_question',
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'ask-1',
revision: 1,
sessionId: 'session-1',
title: '请选择继续方式',
description: 'Agent 需要你确认下一步。',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'inline',
title: '请选择继续方式',
fields: [
{
name: 'choice',
title: '选项',
widget: 'radio',
required: true,
options: [
{ value: 'deploy', label: '直接部署' },
{ value: 'test', label: '先跑测试' },
{ value: 'cancel', label: '取消任务' },
],
},
{ name: 'notes', title: '补充说明', widget: 'textarea' },
{
name: 'checks',
title: '检查项',
widget: 'checkboxes',
type: 'array',
options: [
{ value: 'lint', label: '代码检查' },
{ value: 'unit', label: '单元测试' },
],
},
],
submitLabel: '提交',
cancelLabel: '取消',
},
},
};
describe('McpAskQuestionCard', () => {
it('renders MCP Ask fields and submits form data as a normal response payload', async () => {
const onRespond = vi.fn();
render(
<McpAskQuestionCard
interaction={interaction}
keyboardShortcutsEnabled={false}
onRespond={onRespond}
/>,
);
expect(screen.getByRole('region', { name: '请选择继续方式' })).toBeTruthy();
expect(screen.getByText('Agent 需要你确认下一步。')).toBeTruthy();
expect(screen.getByText('选项')).toBeTruthy();
expect(screen.getByText('先跑测试')).toBeTruthy();
expect(screen.getByText('补充说明')).toBeTruthy();
expect(screen.getByText('检查项')).toBeTruthy();
expect(screen.getByText('代码检查')).toBeTruthy();
fireEvent.click(screen.getByText('先跑测试'));
fireEvent.change(screen.getByPlaceholderText('补充说明'), {
target: { value: '先跑关键链路' },
});
fireEvent.click(screen.getByText('代码检查'));
fireEvent.click(screen.getByRole('button', { name: '提 交' }));
await waitFor(() => expect(onRespond).toHaveBeenCalledTimes(1));
expect(onRespond).toHaveBeenCalledWith(
expect.objectContaining({
interventionId: 'ask-1',
toolCallId: 'tc-ask-1',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: {
choice: 'test',
notes: '先跑关键链路',
checks: ['lint'],
},
answeredBy: { kind: 'web' },
}),
);
});
});

View File

@@ -0,0 +1,376 @@
import { t } from '@/services/i18nRuntime';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { Button, Form, Steps, Tag, Typography } from 'antd';
import classNames from 'classnames';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useInterventionEscapeKey } from '../hooks/useInterventionEscapeKey';
import type {
McpAskInteraction,
McpAskRespondPayload,
} from '../types/mcpAskIntervention';
import {
extractMcpAskFormAttachments,
hydrateMcpAskFormValues,
normalizeMcpAskFormData,
} from '../utils/normalizeMcpAskFormData';
import {
getInteractionSteps,
getSkipLabel,
isSkipAllowed,
isWizardPresentation,
parseInteractionFields,
} from '../utils/parseMcpAskSchema';
import styles from './index.less';
import McpAskFormField from './McpAskFormField';
const { Text } = Typography;
interface McpAskQuestionCardProps {
interaction: McpAskInteraction;
dockShellClassName?: string;
keyboardShortcutsEnabled?: boolean;
onRespond?: (payload: McpAskRespondPayload) => void;
}
const McpAskQuestionCard: React.FC<McpAskQuestionCardProps> = ({
interaction,
dockShellClassName,
keyboardShortcutsEnabled = true,
onRespond,
}) => {
const [form] = Form.useForm<Record<string, unknown>>();
const [currentStep, setCurrentStep] = useState(0);
const { input, toolCallId } = interaction;
const ui = input.ui;
const cardRef = useRef<HTMLDivElement>(null);
const isSubmitting = interaction.responseStatus === 'submitting';
const isSubmitted = interaction.responseStatus === 'submitted';
const isCancelled = interaction.responseStatus === 'cancelled';
const isSkipped = interaction.responseStatus === 'skipped';
const isFailed = interaction.responseStatus === 'failed';
const disabled =
isSubmitting || isSubmitted || isCancelled || isSkipped || !onRespond;
const steps = useMemo(() => getInteractionSteps(ui), [ui]);
const isWizard = isWizardPresentation(ui);
const allowSkip = isSkipAllowed(ui);
const skipLabel =
getSkipLabel(ui) || t('PC.Components.McpAskQuestionCard.skip');
const activeStep = steps[currentStep];
const visibleFields = useMemo(() => {
if (isWizard && steps.length > 1) {
return parseInteractionFields(ui, activeStep?.fields);
}
return parseInteractionFields(ui);
}, [ui, isWizard, steps.length, activeStep?.fields]);
const isLastStep = currentStep >= steps.length - 1;
const title = input.title || ui.title;
const description = input.description || ui.description;
useEffect(() => {
setCurrentStep(0);
}, [input.requestId]);
useEffect(() => {
const fieldInitials = (ui.fields ?? []).reduce<Record<string, unknown>>(
(acc, f) =>
f.initialValue !== undefined
? { ...acc, [f.name]: f.initialValue }
: acc,
{},
);
const initial =
Object.keys(fieldInitials).length > 0
? { ...fieldInitials, ...(interaction.formData ?? {}) }
: interaction.formData;
if (initial) {
form.setFieldsValue(hydrateMcpAskFormValues(initial, ui));
}
}, [form, ui.fields, interaction.formData, input.requestId]);
const buildPayload = (
action: McpAskRespondPayload['action'],
formData?: Record<string, unknown>,
files?: McpAskRespondPayload['files'],
): McpAskRespondPayload => ({
interventionId: input.requestId,
toolCallId,
revision: input.revision,
source: 'mcp_ask',
protocol: 'mcp',
action,
formData,
files,
answeredAt: Date.now(),
answeredBy: { kind: 'web' },
});
const validateStepFields = async (stepIndex: number) => {
const step = steps[stepIndex];
if (!step?.fields?.length) {
return;
}
await form.validateFields(step.fields);
};
const handleNext = async () => {
await validateStepFields(currentStep);
setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
};
const handlePrev = () => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
};
const handleSubmit = async () => {
if (isWizard) {
for (let i = 0; i < steps.length; i += 1) {
await validateStepFields(i);
}
} else {
await form.validateFields();
}
const rawValues = form.getFieldsValue(true);
const files = extractMcpAskFormAttachments(rawValues, ui);
const values = normalizeMcpAskFormData(rawValues, ui);
onRespond?.(buildPayload('submit', values, files));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
if (
e.target instanceof HTMLElement &&
(e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON')
) {
return;
}
e.preventDefault();
void handleSubmit();
}
};
// 卡片弹出后自动获取焦点
useEffect(() => {
if (!disabled) {
cardRef.current?.focus();
}
}, [disabled]);
// 保持焦点始终在卡片上,防止失焦导致快捷键失效
const handleBlur = (e: React.FocusEvent) => {
if (
!disabled &&
cardRef.current &&
!cardRef.current.contains(e.relatedTarget)
) {
e.preventDefault();
cardRef.current.focus();
}
};
const handleCancel = useCallback(() => {
onRespond?.({
interventionId: input.requestId,
toolCallId,
revision: input.revision,
source: 'mcp_ask',
protocol: 'mcp',
action: 'cancel',
answeredAt: Date.now(),
answeredBy: { kind: 'web' },
});
}, [onRespond, input.requestId, input.revision, toolCallId]);
useInterventionEscapeKey({
enabled: !disabled && keyboardShortcutsEnabled,
onEscape: handleCancel,
respectFormFieldFocus: false,
});
const handleSkip = () => {
onRespond?.(buildPayload('skip'));
};
const stepItems = steps.map((step) => ({
title: step.title,
}));
const renderStatusTag = () => {
if (isSubmitted) {
return (
<Tag color="success" className={styles.statusTag}>
{t('PC.Components.McpAskQuestionCard.submitted')}
</Tag>
);
}
if (isSkipped) {
return (
<Tag className={styles.statusTag}>
{t('PC.Components.McpAskQuestionCard.skipped')}
</Tag>
);
}
if (isCancelled) {
return (
<Tag className={styles.statusTag}>
{t('PC.Components.McpAskQuestionCard.cancelled')}
</Tag>
);
}
return null;
};
return (
<div
ref={cardRef}
className={classNames(styles.shell, dockShellClassName)}
role="region"
aria-label={title}
tabIndex={-1}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
>
<header className={styles.header}>
<div className={styles.iconWrap} aria-hidden>
<QuestionCircleOutlined />
</div>
<div className={styles.headerMain}>
<span className={styles.eyebrow}>
{t('PC.Components.McpAskQuestionCard.eyebrow')}
</span>
<Text strong className={styles.title}>
{title}
</Text>
{description ? (
<Text type="secondary" className={styles.desc}>
{description}
</Text>
) : null}
</div>
{renderStatusTag()}
</header>
{isWizard && steps.length > 1 ? (
<div className={styles.wizardBlock}>
<Steps
className={styles.steps}
size="small"
current={currentStep}
items={stepItems}
/>
{activeStep?.description ? (
<div className={styles.stepCallout}>
<Text type="secondary" className={styles.stepCalloutText}>
{activeStep.description}
</Text>
</div>
) : null}
</div>
) : null}
<div className={styles.formSurface}>
<Form
form={form}
layout="vertical"
className={styles.form}
disabled={disabled}
requiredMark="optional"
>
{visibleFields.map((field) => (
<McpAskFormField
key={field.name}
field={field}
disabled={disabled}
/>
))}
</Form>
</div>
{!isSubmitted && !isCancelled && !isSkipped ? (
<footer className={styles.footer}>
{isWizard && steps.length > 1 ? (
<Text type="secondary" className={styles.stepMeta}>
{t(
'PC.Components.McpAskQuestionCard.stepOf',
String(currentStep + 1),
String(steps.length),
)}
</Text>
) : (
<span />
)}
<div className={styles.footerActions}>
{isWizard && currentStep > 0 ? (
<Button
className={styles['cancel-btn']}
disabled={disabled}
onClick={handlePrev}
>
{t('PC.Components.McpAskQuestionCard.prevStep')}
</Button>
) : null}
<Button
className={styles['cancel-btn']}
disabled={disabled}
onClick={handleCancel}
title={t('PC.Components.McpAskQuestionCard.cancelShortcutHint')}
>
<span className={styles.buttonLabel}>
{ui.cancelLabel || t('PC.Common.Global.cancel')}
<kbd className={styles.shortcut}>Esc</kbd>
</span>
</Button>
{allowSkip ? (
<Button
className={styles['cancel-btn']}
disabled={disabled}
onClick={handleSkip}
>
{skipLabel}
</Button>
) : null}
{isWizard && !isLastStep ? (
<Button
className={styles['submit-btn']}
disabled={disabled}
onClick={() => void handleNext()}
>
{t('PC.Components.McpAskQuestionCard.nextStep')}
</Button>
) : (
<Button
className={styles['submit-btn']}
loading={isSubmitting}
disabled={disabled}
onClick={() => void handleSubmit()}
>
<span className={styles.buttonLabel}>
{ui.submitLabel || t('PC.Common.Global.confirm')}
<kbd className={styles.shortcut}></kbd>
</span>
</Button>
)}
</div>
</footer>
) : null}
{isFailed && interaction.errorMessage ? (
<Text type="danger" className={styles.error}>
{interaction.errorMessage}
</Text>
) : null}
</div>
);
};
export default McpAskQuestionCard;

View File

@@ -0,0 +1,563 @@
# AgentIntervention
AI Agent 运行时干预模块,支持两种暂停-恢复流程:**ACP 权限审批** 和 **MCP Ask 结构化提问**
## 示例页面
本地开发时可通过以下地址预览干预卡片 UIMock 数据,免登录,仅用于开发预览):
| 说明 | 路由 | 本地地址(默认端口 8000 |
| --- | --- | --- |
| 干预卡片 UI DemoACP + MCP Ask | `/examples/agent-intervention-demo` | http://localhost:8000/examples/agent-intervention-demo |
| 全部示例索引 | `/examples` | http://localhost:8000/examples |
示例源码:`src/examples/AgentInterventionDemo/``index.tsx` + `mockData.ts`。Mock 使用模块内 **camelCase** 字段;真实 SSE 入参多为 **snake_case**,由 `applyAcpPermissionSseEvent` / `parseMcpAskToolInput` 归一化。
## 模块结构
```
AgentIntervention/
index.ts
AgentInterventionChatLayer/ # 对外入口DockPanel.tsx 堆叠渲染卡片)
AcpPermissionCard/ # 业务卡片;示例页直连
McpAskQuestionCard/
hooks/
types/
utils/
```
## 对外导出(`index.ts`
| 导出 | 说明 |
| --- | --- |
| `AgentInterventionChatLayer` | 聊天页干预层(队列 + 停靠卡片) |
| `useAgentInterventionLayer` | 页面接入(含 `agentMode`、Chat 层 props |
| `useAgentInterventionHandlers` | model 内 ACP/MCP 响应(写 `messageList` |
| `processInterventionSsePatch` | SSE 拦截,挂载 interaction 到当前消息 |
| `hydrateMcpAskInteractionsInMessageList` | 历史消息 MCP Ask 状态重建 |
| 类型 | `AcpPermissionInteraction``McpAskInteraction``AgentMode` 等 |
卡片组件(`AcpPermissionCard``McpAskQuestionCard`**未**从 barrel 导出;示例页或调试可 deep import。
## 已接入位置
| 文件 | 职责 |
| --- | --- |
| `src/models/conversationInfo.ts` | 主会话:`processInterventionSsePatch``hydrateMcpAskInteractionsInMessageList``useAgentInterventionHandlers` |
| `src/models/conversationAgent.ts` | **ConversationAgent 预览 Tab** 隔离会话:同上三项(与 `conversationInfo` 对齐) |
| `src/pages/Chat/index.tsx` | `useAgentInterventionLayer` + `AgentInterventionChatLayer` |
| `src/pages/EditAgent/PreviewAndDebug/index.tsx` | 同上(编排预览调试) |
| `src/pages/ConversationAgent/` | 左侧主聊天气泡区(`conversationInfo`+ 右侧「预览」Tab`conversationAgent` + `interventionHandlers` 注入) |
| `src/components/business-component/UnifiedChatSession` | 统一挂载 `AgentInterventionChatLayer`DockPanel |
| `src/services/agentConfig.ts` | `apiAgentInterventionRespond`ACP 回执 HTTP |
| `src/types/interfaces/conversationInfo.ts` | `MessageInfo.acpPermissionInteractions` / `mcpAskInteractions` |
## 快速接入
```tsx
import {
AgentInterventionChatLayer,
useAgentInterventionLayer,
} from '@/components/business-component/AgentIntervention';
const { agentMode, chatLayerProps, agentModeInputProps } = useAgentInterventionLayer({
conversationId: id,
messageList,
onSendMessage: handleMessageSend,
});
<AgentInterventionChatLayer {...chatLayerProps} />
<ChatInputHome {...agentModeInputProps} agentMode={agentMode} ... />
```
发送消息时在参数中带上 `agentMode: 'ask' | 'yolo'`(见 `AgentMode`)。
**model 侧必须同时完成:**
1. SSE 循环中优先调用 `processInterventionSsePatch`,命中则替换当前消息并 `return`
2. 拉取历史消息后调用 `hydrateMcpAskInteractionsInMessageList`
`conversationInfo``conversationAgent` 均已接入上述逻辑。
**ConversationAgent 预览 Tab`conversationAgent` model额外要求**
`UnifiedChatSession` 传入 `interventionHandlers`(由 `useConversationAgentChatSession` 组装),避免 Dock 回执误写入全局 `conversationInfo``messageList`
```tsx
<UnifiedChatSession
{...chatSessionProps}
interventionHandlers={interventionHandlers}
/>
```
`interventionHandlers` 类型:`AgentInterventionHandlersOverride``respondAcpPermission` / `respondMcpAsk`)。
## `MessageInfo` 挂载字段
归一化后挂在**当前流式消息**上(与 `processingList` 同级):
```typescript
interface MessageInfo {
acpPermissionInteractions?: AcpPermissionInteraction[];
mcpAskInteractions?: McpAskInteraction[];
}
```
### `AcpPermissionInteraction`内存模型camelCase
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `intervention` | `AcpPermissionInterventionRequest` | 审批元数据 + `acp.request` |
| `responseStatus` | `'pending' \| 'submitting' \| 'submitted' \| 'failed'` | 前端响应状态 |
| `selectedOptionId` | `string?` | 用户选中的 `optionId` |
| `errorMessage` | `string?` | 回执失败文案 |
| `triggeredAt` | `number?` | 入队时间戳(`createInterventionTriggeredAt` |
`intervention` 主要字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 干预 ID无后端 `_intervention` 时为 `itv_{sessionId}_{toolCallId}` |
| `revision` | 修订号;可从 `_meta.qimingclaw_revision` 补齐 |
| `kind` | 固定 `'approval'` |
| `status` | 固定 `'pending'` |
| `sessionId` | 会话 ID |
| `source` | 固定 `'acp_permission'` |
| `engine` | `'claude-code' \| 'nuwaxcode' \| 'codex'`SSE 若带 `codex-cli` 等别名会规范为 `codex` |
| `protocol` | 固定 `'acp'` |
| `callbackTarget` | `{ kind: 'electron' \| 'rcoder', targetId: string }` |
| `schemaRef` | 模式引用字符串 |
| `acp.method` | 固定 `'session/request_permission'` |
| `acp.request.sessionId` | 同 `sessionId` |
| `acp.request.toolCall` | `toolCallId`, `title`, `kind`, `rawInput`, `status` 等 |
| `acp.request.options[]` | `{ optionId, kind, name }``kind``AcpPermissionOptionKind` |
| `createdAt` | 时间戳PROCESSING 事件可取 `result.startTime` |
`AcpPermissionOptionKind``allow_once` | `allow_always` | `reject_once` | `reject_always`。卡片 UI **隐藏** `reject_always`
### `McpAskInteraction`内存模型camelCase
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `input` | `McpAskUserToolInput` | 工具 rawInput 归一化结果 |
| `toolCallId` | `string` | 与 SSE `tool_call_id` / `executeId` 对齐 |
| `responseStatus` | `'pending' \| 'submitting' \| 'submitted' \| 'cancelled' \| 'skipped' \| 'failed'` | |
| `formData` | `Record<string, unknown>?` | 用户提交值 |
| `errorMessage` | `string?` | |
| `triggeredAt` | `number?` | |
`McpAskUserToolInput` 必填(`parseMcpAskToolInput`
| 字段 | 约束 |
| --- | --- |
| `schemaVersion` | `qimingclaw.mcp_ask.v1` 或别名 `nuwax.mcp_ask.v1` |
| `toolName` | `nuwax_ask_question`(缺省同值;其它工具名拒绝) |
| `requestId` | 去重键;**同时作为** `McpAskRespondPayload.interventionId` |
| `revision` | 数字 |
| `sessionId` | 字符串 |
| `title` | 字符串 |
| `description` | 可选 |
| `ui.version` | `qimingclaw.interaction.v1``nuwax.interaction.v1` |
| `ui.presentation` | 类型含 `modal \| inline \| wizard \| table`**实际 UI** 仅区分 wizard / 非 wizard |
| `ui.schema` | JSON Schema 对象 |
| `ui.uiSchema` | 可选,含 `ui:widget``ui:options` |
| `ui.steps` | wizard 步骤 |
| `timeoutMs` | 可选 |
## SSE 识别与归一化
`parseSseEventEnvelope` 兼容多层包裹:`res.data.{messageType,subType,data}` 或扁平字段(含 `message_type` / `sub_type`)。
### ACP何时命中 `applyAcpPermissionSseEvent`
满足其一即可:
- `res.eventType === 'ACP_REQUEST_PERMISSION'`
- `message_type` / `messageType === 'acpRequestPermission'``sub_type` / `subType``request_permission``AcpRequestPermission`
- `res.eventType === 'PROCESSING'` 且(`subEventType === 'REQUEST_PERMISSION'``result.input.request_permission_request` 存在)
`eventData` 读取snake_case 优先):
- `request_permission_request`(或 PROCESSING `result.input.request_permission_request`
- `tool_call_id``session_id`
- 可选 `_intervention` / `interventionRequest`(完整 camelCase 干预体)
- 可选 `_meta.qimingclaw_intervention_id``qimingclaw_revision`
**入参示例snake_case与单测一致**
```json
{
"message_type": "acpRequestPermission",
"sub_type": "request_permission",
"data": {
"request_permission_request": {
"session_id": "session-snake",
"tool_call": {
"tool_call_id": "tool-call-snake",
"title": "Write file",
"kind": "edit",
"status": "pending",
"raw_input": { "command": "touch approval-test.txt" }
},
"options": [
{
"option_id": "allow-once",
"kind": "allow_once",
"name": "Allow once"
},
{ "option_id": "reject-once", "kind": "reject_once", "name": "Reject" }
]
},
"tool_call_id": "tool-call-snake"
}
}
```
同一 `intervention.id` 不会重复挂载。
### MCP Ask何时命中 `applyMcpAskToolCallSseEvent`
- `message_type` / `messageType``tool_call``sub_type` / `subType``tool_call` / `tool_call_update`,且存在 `tool_call_id` / `toolCallId` / `raw_input` / `rawInput`(或 `ext` 内 rawInput
- `messageType === 'agentSessionUpdate'` + `tool_call` 子类型(同上)
- `res.eventType === 'PROCESSING'` 且存在 `executeId``result.executeId` / `result.input`
- 后端沙箱 **`Backend.Sandbox.Event.AskQuestion`**`subEventType === 'ASK_QUESTION'`,表单在 `data.result.input`(与 ToolCall 形态等价,由 `isProcessingToolCallEvent` 识别)
`raw_input` / `rawInput` / `result.input``parseMcpAskToolInput` 校验通过后写入 `mcpAskInteractions`。同一 `input.requestId` 不重复挂载。
**入参示例ASK_QUESTION Event与 demo2.json 对齐):**
```json
{
"eventType": "PROCESSING",
"data": {
"name": "Backend.Sandbox.Event.AskQuestion",
"type": "Event",
"status": "FINISHED",
"subEventType": "ASK_QUESTION",
"result": {
"executeId": "call_272edddbb5e140128d146826",
"input": {
"requestId": "demo_form_1",
"ui": {
"version": "nuwax.interaction.v2",
"presentation": "inline",
"fields": [{ "name": "choice", "title": "选项", "widget": "text" }]
}
}
}
}
}
```
**入参示例PROCESSING + executeId**
```json
{
"eventType": "PROCESSING",
"data": {
"executeId": "tool-call-1",
"result": {
"executeId": "tool-call-1",
"input": {
"toolName": "nuwax_ask_question",
"schemaVersion": "qimingclaw.mcp_ask.v1",
"requestId": "ask-1",
"revision": 1,
"sessionId": "session-1",
"title": "Need input",
"ui": {
"version": "qimingclaw.interaction.v1",
"presentation": "inline",
"title": "Need input",
"schema": { "type": "object", "properties": {} }
}
}
}
}
}
```
**入参示例agentSessionUpdate / snake_case**
```json
{
"messageType": "agentSessionUpdate",
"subType": "tool_call",
"data": {
"tool_call_id": "tool-call-3",
"raw_input": {
"schemaVersion": "qimingclaw.mcp_ask.v1",
"requestId": "ask-3",
"revision": 1,
"sessionId": "session-1",
"title": "请选择继续方式",
"ui": {
"version": "qimingclaw.interaction.v1",
"presentation": "inline",
"title": "请选择继续方式",
"schema": {
"type": "object",
"properties": {
"choice": {
"type": "string",
"enum": ["deploy", "test"],
"title": "选项"
}
},
"required": ["choice"]
},
"uiSchema": {
"choice": {
"ui:widget": "radio",
"ui:options": { "enumNames": ["部署", "测试"] }
}
}
}
}
}
}
```
## 用户操作与回执
### ACP 卡片 → `AcpRequestPermissionResponse` → HTTP
卡片 `onRespond` 传出camelCase
```typescript
// 选中某项
{ outcome: { outcome: 'selected', optionId: 'allow-once' } }
// 取消(卡片取消按钮,非 Esc 伪造 Cancelled
{ outcome: { outcome: 'cancelled' } }
```
`useAgentInterventionHandlers.respondAcpPermission` 组装 `AgentInterventionRespondRequest` 后调用 **`apiAgentInterventionRespond`**。
**实际请求(`src/services/agentConfig.ts`**
```
POST /api/agent/conversation/chat/permission-request/response
```
```json
{
"conversationId": 43,
"toolId": "tool-call-snake",
"option": {
"optionId": "allow-once",
"outcome": "selected"
}
}
```
取消时 `outcome``"cancelled"``optionId` 回退为 `"reject"`(见 service 内 fallback
类型层 `permission_resolve_request` 仍保留 QimingClaw 形状(`Selected.option_id` / `Cancelled`),由 service 映射为上述 body。
### MCP Ask → 普通聊天消息(无单独干预 API
`McpAskQuestionCard` 构建的 `McpAskRespondPayload`
| 字段 | 来源 |
| --- | --- |
| `interventionId` | **`input.requestId`**(非后端 intervention 表 ID |
| `toolCallId` | interaction.`toolCallId` |
| `revision` | `input.revision` |
| `source` | `'mcp_ask'` |
| `protocol` | `'mcp'` |
| `action` | `'submit' \| 'cancel' \| 'skip' \| 'timeout'` |
| `formData` | 提交时表单值;`file` 字段为统一上传服务返回的 `https://` URL`normalizeMcpAskFormData` |
| `answeredBy` | `{ kind: 'web' }` |
| `answeredAt` | `Date.now()` |
`respondMcpAsk` 更新 `mcpAskInteractions` 状态后,返回 `buildMcpAskResumeMessage` 字符串,由 `useAgentInterventionLayer` 调用 `onSendMessage` 发回会话。
**resume 文案规则(`mcpAskResumeMessage.ts`**
```text
我已填写「{title}」,表单内容如下:
{字段 title}{展示值}
```
- `cancel``我取消了「{title}」。`
- `skip``我跳过了「{title}」。`
- `timeout``「{title}」已超时,没有收到表单答案。`
- 枚举展示优先 `uiSchema[field].ui:options.enumNames`;布尔为 `是/否`;空为 `未填写`
### `uiSchema` 已落地 widget
| `ui:widget` | 渲染 |
| --- | --- |
| `radio` | Radio.Group |
| `checkboxes` | Checkbox.Group含 items.enum |
| `select` | Select |
| `text` | Input |
| `textarea` | Input.TextArea |
| `number` | InputNumber`minimum` / `maximum` / `multipleOf` |
| `radio-with-custom` | Radio + 自定义输入(`otherValue` / `otherField` |
| `list` | 纵向单选列表 |
| `file` | 上传(`accept``multiple`);走 `/api/file/upload`**提交值为远程 URL**(单文件 `string`,多文件 `string[]` |
未写 `ui:widget` 时按 schema 推断(如 `enum``radio``items.enum``checkboxes``number`/`integer``number`)。
### `presentation` 实际行为
| 条件 | UI |
| --- | --- |
| `presentation === 'wizard'``steps.length > 1` | 分步 Steps + 上一步/下一步 |
| 其他(含 `inline``modal``table` | 单页表单 |
根级 `uiSchema['ui:options'].allowSkip === true` 时显示跳过;文案来自 `ui.skipLabel` 或字段 `skipLabel`
## 核心数据结构
干预数据挂载在 `MessageInfo` 上:
```typescript
interface MessageInfo {
acpPermissionInteractions?: AcpPermissionInteraction[];
mcpAskInteractions?: McpAskInteraction[];
processingList?: ProcessingInfo[]; // 累积 PROCESSING 事件(含 Plan/ToolCall不含 ASK_QUESTION/REQUEST_PERMISSION
}
```
## 数据写入路径
### 实时流live SSE
```
SSE stream → processInterventionSsePatch(chunk, currentMessage)
├─ isAcpPermissionEvent? → applyAcpPermissionSseEvent → message.acpPermissionInteractions.push
├─ isMcpAskToolCallEvent? → applyMcpAskToolCallSseEvent → message.mcpAskInteractions.push
└─ else → processingList.push / text 累积 ...
```
ACP 和 MCP Ask 事件被优先拦截,**不会进入** `processingList`
### 消息恢复sub stream
刷新页面 / 重进会话且任务仍 EXECUTING 时,由 `useConversationStreamResume` 轮询 `taskStatus` 检测后调用 `resumeConversationStream`
```
resumeConversationStream(conversationId, currentList)
1. 可选 reloadHistoryAsync → 更新 messageList含 resume 消息)
2. 追加占位 assistant messageid=currentMessageId, status=Loading
3. GET /api/agent/conversation/chat/sub/:id
→ processInterventionSsePatch(chunk, currentMessage) ← 与 live 复用
```
sub 流重放历史 SSE 时,已有 resume 消息(步骤 1 加载),`hasMcpAskResumeMessage` 可过滤已回答的 MCP Ask避免重复弹出。
### 历史消息 hydrate
```
拉取历史列表后hydrateMcpAskInteractionsInMessageList(messageList)
→ 遍历所有 assistant 消息,提取 mcpAskInteractions
→ 在后续消息中搜索 resume 文本,有则置 responseStatus = 'submitted' / 'cancelled' 等
```
ACP 权限审批**不从历史 hydrate**,仅存在于实时流/sub 流消息中。
## DockPanel 显隐规则(`useActiveInterventionQueue` + `DockPanel`
`useActiveInterventionQueue`**整个 `messageList`** 收集 `responseStatus``pending` / `submitting` 的干预项,按 `triggeredAt` / `createdAt` **升序FIFO** 排序后送入 `DockPanel``DockPanel` 堆叠渲染,**仅 front最早一项可交互**,其余 `aria-hidden`;用户逐项审批并调用 `permission-request/response`(→ QimingClaw `notify-resolved`)。
### 队列收集(`useActiveInterventionQueue`
- **跨消息**:不限于最新一条消息;并行工具可在多条 assistant 消息上各挂审批,统一入队。
- **ACP**`pending` / `submitting` 进队;`submitted` / `failed` 不进队(`failed` 不阻塞后续审批)。
- **MCP Ask 抑制 ACP**双路pending ACP 的 `toolCallId``rawInput.requestId` 命中时,对应 MCP Ask 不进队。
- **MCP Ask**:另需 `!hasMcpAskResumeMessage`**不受**消息 `Complete` 态影响。
### sub 恢复 / 跨页签ACP 已审批判定(`reconcileAcpPermissionStatus`
ACP **不从历史 hydrate**sub 流重放会把 `request_permission` 再次写入为 `pending`。每次 `processInterventionSsePatch``handleChangeMessageList` 更新后调用 `reconcileAcpPermissionStatusesInMessageList`
| 条件 | 动作 |
| --- | --- |
| 同 `toolCallId` / `executeId``processingList` 中已为 `FINISHED` | `responseStatus``submitted` |
| 关联 MCP Ask 已有 resume 消息(`hasMcpAskResumeMessage` | `submitted` |
| 用户本端审批 API 成功 | `submitted``useAgentInterventionHandlers` |
| API 返回 permission not found / already resolved | 幂等视为 `submitted` |
### ACP 权限审批关闭时机
| 原因 | 机制 |
| ----------------- | ------------------------------------------------------- |
| 用户审批 | `responseStatus``submitted` |
| sub 重放 / 跨页签 | `reconcileAcpPermissionStatuses` 根据 processing 完成态 |
| API 幂等 | Host pending 已不存在 → `submitted` |
### MCP Ask 关闭时机
| 原因 | 机制 |
| ------------ | ------------------------------------------------------------ |
| 本端用户提交 | `dismissedMcpAskRequestIds``responseStatus` 更新 |
| 跨端感知 | `hasMcpAskResumeMessage` |
| Agent 推进 | 由 `responseStatus` 独立控制(非 ACP 的 executeId 过期逻辑) |
### 规则速查
```
队列顺序 = sortKey 升序FIFODockPanel front = items[0]
ACP 进队 = pending/submittingfailed 不进队)
MCP 进队 = pending/submitting
&& !hasMcpAskResumeMessage
&& toolCallId ∉ permissionPendingToolCallIds
&& requestId ∉ permissionPendingAskRequestIds
```
### Resume 消息签名(`hasMcpAskResumeMessage` 匹配依据)
`collectResumeMessageSignatures` 从 5 个语言包ZH / ZH-TW / ZH-HK / EN / JA提取模板格式化签名 + legacy 硬编码片段,用 `text.includes(signature)` 匹配:
| 操作 | 中文签名示例 |
| ------- | --------------------------------------- |
| submit | `我已填写「{title}」,表单内容如下:` |
| cancel | `我取消了「{title}」。` |
| skip | `我跳过了「{title}」。` |
| timeout | `「{title}」已超时,没有收到表单答案。` |
**变更风险**:修改 `buildMcpAskResumeMessage` 格式或 i18n key 时,须确保新格式前缀仍能被 `collectResumeMessageSignatures` 收集,否则历史 resume 消息无法识别,已回答的卡片会重新出现。
### 与 Mobile 的对应关系
| 机制 | PC此文件 | Mobile`mcpAskInterventionState.uts` |
| --- | --- | --- |
| 过期检查 | `isExpired(executeId)` vs `focusExecuteId` | `processingList.length > processingListLengthAtAdd` |
| 本端提交关闭 | `dismissedMcpAskRequestIds` SetAPI 失败可回滚 | `removeMcpAskInteractionFromMessageList` 直接移除 |
| 跨端感知关闭 | `hasMcpAskResumeMessage` | 同左 |
| 历史状态恢复 | `hydrateMcpAskInteractionsInMessageList` | `applyMcpAskResumeStatusesInMessageList` |
| ACP 抑制 MCP Ask | `permissionPendingToolCallIds` + `permissionPendingAskRequestIds` | 同左 |
| 卡片渲染 | `DockPanel` FIFO 堆叠,最早在前 | ACP + MCP Ask 分列 `v-for`(待 mobile 同步) |
| MCP Ask `file` 提交 | `normalizeMcpAskFormData` 转为 URL 后写入 `formData` | Mobile 侧 `mcpAskInterventionState.uts` 需同样在上传完成后提交 URL |
| ACP sub 恢复 | `reconcileAcpPermissionStatuses` | 待 mobile 同步 |
## 数据流
```
SSE (ConversationChatResponse)
→ processInterventionSsePatch (+ reconcileAcpPermissionStatuses)
→ applyAcpPermissionSseEvent → message.acpPermissionInteractions[]
→ applyMcpAskToolCallSseEvent → message.mcpAskInteractions[]
→ useActiveInterventionQueueFIFOpending/submitting 进队)
→ AgentInterventionChatLayer → DockPanelfront 可点,逐个 notify-resolved
→ AcpPermissionCard / McpAskQuestionCard
→ respondAcpPermission → POST .../permission-request/response
→ respondMcpAsk → buildMcpAskResumeMessage → onSendMessage
```
历史加载:`hydrateMcpAskInteractionsInMessageList` 根据已有用户消息推断 MCP 是否已回复。
## 测试
关键单测与上述字段对齐:
- `utils/applyAcpPermissionSseEvent.test.ts` — ACP snake_case / PROCESSING / `_meta`
- `utils/applyMcpAskToolCallSseEvent.test.ts` — MCP tool_call / PROCESSING
- `utils/parseMcpAskSchema.test.ts` — widget 解析
- `utils/reconcileAcpPermissionStatus.test.ts` — ACP sub 恢复 reconcile / API 幂等
- `hooks/useAgentInterventionHandlers.test.ts` — ACP 回执与 MCP resume

View File

@@ -0,0 +1,541 @@
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { AcpPermissionInteraction } from '../types/acpIntervention';
import type { McpAskInteraction } from '../types/mcpAskIntervention';
import { buildMcpAskRequestIdMarker } from '../utils/mcpAskResumeMessage';
import { useActiveInterventionQueue } from './useActiveInterventionQueue';
function createAskInteraction(
overrides: Partial<McpAskInteraction> = {},
): McpAskInteraction {
return {
input: {
toolName: 'nuwax_ask_question',
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'ask-1',
revision: 1,
sessionId: 'session-1',
title: '毕业论文 PPT 前置信息收集',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'wizard',
title: '毕业论文 PPT 前置信息收集',
fields: [],
},
},
toolCallId: 'call-ask-1',
responseStatus: 'pending',
...overrides,
};
}
function createAcpPermissionInteraction(
status: AcpPermissionInteraction['responseStatus'] = 'pending',
rawInput: Record<string, unknown> = {
schemaVersion: 'nuwax.mcp_ask.v1',
requestId: 'ask-1',
},
): AcpPermissionInteraction {
return {
intervention: {
id: 'itv-ask-permission',
revision: 1,
kind: 'approval',
status: 'pending',
sessionId: 'session-1',
source: 'acp_permission',
engine: 'nuwaxcode',
protocol: 'acp',
callbackTarget: { kind: 'electron', targetId: '' },
schemaRef: '',
acp: {
method: 'session/request_permission',
request: {
sessionId: 'session-1',
toolCall: {
toolCallId: 'call-ask-1',
title: 'mcp__ask-question__nuwax_ask_question',
kind: 'other',
status: 'pending',
rawInput,
},
options: [
{
optionId: 'allow',
kind: 'allow_once',
name: 'Allow',
},
],
},
},
createdAt: 100,
},
responseStatus: status,
};
}
describe('useActiveInterventionQueue', () => {
it('does not show an ask/question interaction after its resume message exists', () => {
const askInteraction = createAskInteraction();
const messageList = [
{
id: 'assistant-ask',
index: 1,
mcpAskInteractions: [askInteraction],
},
{
id: 'user-resume',
index: 2,
text: '我已填写「毕业论文 PPT 前置信息收集」,表单内容如下:\n\n```json\n{}\n```',
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(0);
});
it('filters resolved ask/question interactions even when backend indexes are out of order', () => {
const askInteraction = createAskInteraction();
const messageList = [
{
id: 'user-resume',
index: 1,
text: '我已填写「毕业论文 PPT 前置信息收集」,表单内容如下:\n\n```json\n{}\n```',
},
{
id: 'assistant-ask',
index: 2,
mcpAskInteractions: [askInteraction],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(0);
});
it('keeps an unanswered ask/question interaction active', () => {
const messageList = [
{
id: 'assistant-ask',
index: 1,
mcpAskInteractions: [createAskInteraction()],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
expect(result.current[0].kind).toBe('mcp_ask');
});
it('prefers the latest message that still has an active intervention', () => {
const permission = createAcpPermissionInteraction('pending', {
filePath: '/tmp/a.txt',
});
permission.executeId = 'exec-write-1';
const messageList = [
{
id: 'assistant-with-approval',
index: 1,
status: 'loading',
acpPermissionInteractions: [permission],
},
{
id: 'later-message-without-approval',
index: 2,
status: 'complete',
text: 'later message',
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
expect(result.current[0].kind).toBe('acp_permission');
});
it('hides ask/question while matching ACP permission is pending', () => {
const messageList = [
{
id: 'assistant-ask',
index: 1,
mcpAskInteractions: [createAskInteraction()],
acpPermissionInteractions: [createAcpPermissionInteraction('pending')],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
expect(result.current[0].kind).toBe('acp_permission');
});
it('shows ask/question after matching ACP permission is submitted', () => {
const messageList = [
{
id: 'assistant-ask',
index: 1,
mcpAskInteractions: [createAskInteraction()],
acpPermissionInteractions: [
createAcpPermissionInteraction('submitted'),
],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
expect(result.current[0].kind).toBe('mcp_ask');
});
it('drops a failed ACP permission so later approvals can proceed', () => {
const failed = createAcpPermissionInteraction('failed');
failed.intervention.id = 'itv-failed';
failed.intervention.acp.request.toolCall.toolCallId = 'call-failed';
const pending = createAcpPermissionInteraction('pending');
pending.intervention.id = 'itv-pending';
pending.intervention.createdAt = 200;
pending.intervention.acp.request.toolCall.toolCallId = 'call-pending';
const messageList = [
{
id: 'assistant',
index: 1,
acpPermissionInteractions: [failed, pending],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
// The failed approval must leave the queue; only the later pending one stays.
expect(result.current).toHaveLength(1);
expect((result.current[0] as any).interaction.intervention.id).toBe(
'itv-pending',
);
});
it('keeps ask/question visible when pending ACP permission is unrelated', () => {
const unrelatedPermission = createAcpPermissionInteraction('pending', {
command: 'ls',
});
unrelatedPermission.intervention.acp.request.toolCall.toolCallId =
'call-bash-1';
const messageList = [
{
id: 'assistant-ask',
index: 1,
mcpAskInteractions: [createAskInteraction()],
acpPermissionInteractions: [unrelatedPermission],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(2);
expect(result.current.map((item) => item.kind)).toEqual([
'acp_permission',
'mcp_ask',
]);
});
it('keeps the approval whose executeId matches the latest processing focus', () => {
// 当前焦点 executeId = 最新 processingList 末尾的 executeId审批不再受此影响始终渲染
const interaction = createAcpPermissionInteraction('pending');
interaction.executeId = 'exec-1';
const messageList = [
{
id: 'assistant',
index: 1,
acpPermissionInteractions: [interaction],
processingList: [{ executeId: 'exec-1' }] as any,
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
});
it('keeps the approval card even when its executeId is no longer the focus', () => {
// 最新 processing 已推进到另一个 executeId → 仍保留弹窗队列,不再按 executeId 精确关闭
const interaction = createAcpPermissionInteraction('pending');
interaction.executeId = 'exec-old';
const messageList = [
{
id: 'assistant',
index: 1,
acpPermissionInteractions: [interaction],
processingList: [
{ executeId: 'exec-old' },
{ executeId: 'exec-new' },
] as any,
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
});
it('keeps a fresh approval before its tool processing block arrives', () => {
const interaction = createAcpPermissionInteraction('pending');
interaction.executeId = 'exec-edit';
const messageList = [
{
id: 'assistant',
index: 1,
acpPermissionInteractions: [interaction],
processingList: [{ executeId: 'exec-previous' }] as any,
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
expect(result.current[0].kind).toBe('acp_permission');
if (result.current[0].kind === 'acp_permission') {
expect(result.current[0].interaction.executeId).toBe('exec-edit');
}
});
it('keeps an approval with unknown executeId (fallback, no false close)', () => {
// 无 processingList / 审批无 executeId 时无法判定过期 → 保守显示,避免误关
const messageList = [
{
id: 'assistant',
index: 1,
acpPermissionInteractions: [createAcpPermissionInteraction('pending')],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
});
it('keeps a pending approval visible even if the host message becomes complete', () => {
const interaction = createAcpPermissionInteraction('pending');
interaction.executeId = 'exec-approval';
const messageList = [
{
id: 'assistant',
index: 1,
status: 'complete',
acpPermissionInteractions: [interaction],
processingList: [{ executeId: 'exec-approval' }] as any,
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
expect(result.current[0].kind).toBe('acp_permission');
});
it('keeps multiple pending approvals instead of hiding earlier ones', () => {
const first = createAcpPermissionInteraction('pending', {
filePath: '/tmp/a.txt',
});
first.intervention.id = 'itv-first';
first.intervention.createdAt = 100;
first.intervention.acp.request.toolCall.toolCallId = 'call-first';
first.executeId = 'exec-first';
const second = createAcpPermissionInteraction('pending', {
filePath: '/tmp/b.txt',
});
second.intervention.id = 'itv-second';
second.intervention.createdAt = 200;
second.intervention.acp.request.toolCall.toolCallId = 'call-second';
second.executeId = 'exec-second';
const messageList = [
{
id: 'assistant-1',
index: 1,
status: 'complete',
acpPermissionInteractions: [first],
processingList: [{ executeId: 'exec-first' }] as any,
},
{
id: 'assistant-2',
index: 2,
status: 'loading',
acpPermissionInteractions: [second],
processingList: [{ executeId: 'exec-second' }] as any,
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(2);
expect(
result.current.map((item) =>
item.kind === 'acp_permission'
? item.interaction.intervention.id
: item.interaction.input.requestId,
),
).toEqual(['itv-first', 'itv-second']);
});
it('closes the dock after the approval is answered', () => {
// 审批已应答(submitted),不再 pending → DockPanel 关闭
const messageList = [
{
id: 'assistant',
index: 1,
acpPermissionInteractions: [
createAcpPermissionInteraction('submitted'),
],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(0);
});
it('keeps a second ask active after the first same-title ask was answered', () => {
const sharedTitle = '补充回复';
const firstAsk = createAskInteraction({
input: {
...createAskInteraction().input,
requestId: 'ask-first',
title: sharedTitle,
ui: {
...createAskInteraction().input.ui,
title: sharedTitle,
},
},
responseStatus: 'submitted',
});
const secondAsk = createAskInteraction({
input: {
...createAskInteraction().input,
requestId: 'ask-second',
title: sharedTitle,
ui: {
...createAskInteraction().input.ui,
title: sharedTitle,
},
},
toolCallId: 'call-ask-2',
responseStatus: 'pending',
});
const resumeText = `我已填写「${sharedTitle}」,表单内容如下:\n选项test${buildMcpAskRequestIdMarker(
'ask-first',
)}`;
const messageList = [
{
id: 'assistant-ask',
index: 1,
mcpAskInteractions: [firstAsk, secondAsk],
},
{
id: 'user-resume',
index: 2,
text: resumeText,
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
expect(result.current[0].kind).toBe('mcp_ask');
if (result.current[0].kind === 'mcp_ask') {
expect(result.current[0].interaction.input.requestId).toBe('ask-second');
}
});
it('keeps a later ask active when an earlier assistant message had legacy same-title resume', () => {
const sharedTitle = '补充回复';
const firstAsk = createAskInteraction({
input: {
...createAskInteraction().input,
requestId: 'ask-first',
title: sharedTitle,
ui: {
...createAskInteraction().input.ui,
title: sharedTitle,
},
},
responseStatus: 'submitted',
});
const secondAsk = createAskInteraction({
input: {
...createAskInteraction().input,
requestId: 'ask-second',
title: sharedTitle,
ui: {
...createAskInteraction().input.ui,
title: sharedTitle,
},
},
toolCallId: 'call-ask-2',
responseStatus: 'pending',
});
const messageList = [
{
id: 'assistant-ask-1',
index: 2,
mcpAskInteractions: [firstAsk],
},
{
id: 'user-resume',
index: 3,
text: `我已填写「${sharedTitle}」,表单内容如下:\n选项test`,
},
{
id: 'assistant-ask-2',
index: 4,
mcpAskInteractions: [secondAsk],
},
] as unknown as MessageInfo[];
const { result } = renderHook(() =>
useActiveInterventionQueue(messageList),
);
expect(result.current).toHaveLength(1);
if (result.current[0].kind === 'mcp_ask') {
expect(result.current[0].interaction.input.requestId).toBe('ask-second');
}
});
});

View File

@@ -0,0 +1,139 @@
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import { useMemo } from 'react';
import type { AcpPermissionInteraction } from '../types/acpIntervention';
import type { McpAskInteraction } from '../types/mcpAskIntervention';
import {
hasMcpAskResumeMessage,
sortMessagesByConversationIndex,
} from '../utils/mcpAskResumeMessage';
export type InterventionQueueKind = 'acp_permission' | 'mcp_ask';
export interface AcpInterventionQueueItem {
kind: 'acp_permission';
messageId: string;
messageIndex: number;
interaction: AcpPermissionInteraction;
sortKey: number;
}
export interface McpAskInterventionQueueItem {
kind: 'mcp_ask';
messageId: string;
messageIndex: number;
interaction: McpAskInteraction;
sortKey: number;
}
export type InterventionQueueItem =
| AcpInterventionQueueItem
| McpAskInterventionQueueItem;
function isActiveResponseStatus(status: string | undefined): boolean {
const value = status ?? 'pending';
// `failed` is terminal: once the user has responded, the card closes
// regardless of whether the API call succeeded, so a failed approval no
// longer pins the front of the dock and blocks later approvals. The error
// itself is still surfaced via toast in respondAcpPermission.
return value === 'pending' || value === 'submitting';
}
function readMcpAskRequestId(rawInput: unknown): string | undefined {
if (!rawInput || typeof rawInput !== 'object') {
return undefined;
}
const input = rawInput as Record<string, unknown>;
return typeof input.requestId === 'string' && input.requestId
? input.requestId
: undefined;
}
function getAcpPermissionInteractions(
message: MessageInfo,
): AcpPermissionInteraction[] {
return (message.acpPermissionInteractions ??
[]) as unknown as AcpPermissionInteraction[];
}
export function useActiveInterventionQueue(
messageList: MessageInfo[],
): InterventionQueueItem[] {
return useMemo(() => {
const pendingMap = new Map<string, InterventionQueueItem>();
const permissionPendingToolCallIds = new Set<string>();
const permissionPendingAskRequestIds = new Set<string>();
let fallbackSeq = 0;
const messages = sortMessagesByConversationIndex(messageList ?? []);
messages.forEach((message) => {
getAcpPermissionInteractions(message).forEach((interaction) => {
if (!isActiveResponseStatus(interaction.responseStatus)) {
return;
}
const toolCall = interaction.intervention.acp.request.toolCall;
if (toolCall.toolCallId) {
permissionPendingToolCallIds.add(toolCall.toolCallId);
}
const askRequestId = readMcpAskRequestId(toolCall.rawInput);
if (askRequestId) {
permissionPendingAskRequestIds.add(askRequestId);
}
});
});
messages.forEach((message, messageIndex) => {
const messageId = String(message.id ?? message.index);
const messageIndexValue = message.index ?? messageIndex;
getAcpPermissionInteractions(message).forEach((interaction) => {
if (!isActiveResponseStatus(interaction.responseStatus)) {
return;
}
const sortKey =
interaction.triggeredAt ??
interaction.intervention.createdAt ??
fallbackSeq++;
pendingMap.set(`acp-${interaction.intervention.id}`, {
kind: 'acp_permission',
messageId,
messageIndex: messageIndexValue,
interaction,
sortKey,
});
});
message.mcpAskInteractions?.forEach((interaction) => {
if (!isActiveResponseStatus(interaction.responseStatus)) {
return;
}
if (
hasMcpAskResumeMessage(messages, interaction, {
containingMessageIndex: messageIndex,
})
) {
return;
}
if (
permissionPendingToolCallIds.has(interaction.toolCallId) ||
permissionPendingAskRequestIds.has(interaction.input.requestId)
) {
return;
}
const sortKey = interaction.triggeredAt ?? fallbackSeq++;
pendingMap.set(`ask-${interaction.input.requestId}`, {
kind: 'mcp_ask',
messageId,
messageIndex: messageIndexValue,
interaction,
sortKey,
});
});
});
return [...pendingMap.values()].sort((a, b) => a.sortKey - b.sortKey);
}, [messageList]);
}

View File

@@ -0,0 +1,473 @@
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import { renderHook } from '@testing-library/react';
import type {
AcpPermissionInteraction,
AcpRequestPermissionResponse,
} from '../types/acpIntervention';
import type {
McpAskInteraction,
McpAskRespondPayload,
} from '../types/mcpAskIntervention';
import { useAgentInterventionHandlers } from './useAgentInterventionHandlers';
const mockApiAgentInterventionRespond = vi.fn();
vi.mock('@/services/agentConfig', () => ({
apiAgentInterventionRespond: (...args: unknown[]) =>
mockApiAgentInterventionRespond(...args),
}));
vi.mock('@/services/i18nRuntime', () => ({
dict: (key: string) => key,
getCurrentLang: () => 'zh-CN',
}));
vi.mock('antd', () => ({
message: { error: vi.fn() },
}));
/* ------------------------------------------------------------------ */
/* helpers */
/* ------------------------------------------------------------------ */
function createAcpInteraction(
overrides: Partial<AcpPermissionInteraction> = {},
): AcpPermissionInteraction {
return {
intervention: {
id: 'itv-001',
sessionId: 'sess-001',
acp: {
method: 'session/request_permission',
request: {
sessionId: 'sess-001',
toolCall: { toolCallId: 'tc-001' },
options: [],
},
},
},
responseStatus: 'pending',
selectedOptionId: undefined,
errorMessage: undefined,
...overrides,
} as AcpPermissionInteraction;
}
function createMcpAskInteraction(
overrides: Partial<McpAskInteraction> = {},
): McpAskInteraction {
return {
input: {
toolName: 'nuwax_ask_question',
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'req-001',
revision: 1,
sessionId: 'sess-001',
title: '测试问题',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'inline',
title: 'Q',
fields: [],
},
},
toolCallId: 'tc-001',
responseStatus: 'pending',
...overrides,
} as McpAskInteraction;
}
/** Simulate the setMessageList functional updater pattern. */
function createMockSetMessageList(existing: MessageInfo[] = []) {
return vi.fn((fn: (list: MessageInfo[]) => MessageInfo[]) => fn(existing));
}
/* ------------------------------------------------------------------ */
/* ACP respondAcpPermission tests */
/* ------------------------------------------------------------------ */
describe('useAgentInterventionHandlers', () => {
describe('respondAcpPermission', () => {
it('sets responseStatus to submitted on SUCCESS_CODE', async () => {
mockApiAgentInterventionRespond.mockResolvedValueOnce({
code: '0000',
});
const existing = [
{
id: 'msg-1',
acpPermissionInteractions: [createAcpInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createAcpInteraction();
const acpResponse: AcpRequestPermissionResponse = {
outcome: { outcome: 'selected', optionId: 'opt-1' },
};
await result.current.respondAcpPermission(interaction, acpResponse);
// First call: submitting status
expect(setMessageList).toHaveBeenCalledTimes(2);
const firstUpdate = setMessageList.mock.calls[0][0] as (
list: MessageInfo[],
) => MessageInfo[];
const result1 = firstUpdate(existing);
expect(result1[0].acpPermissionInteractions![0].responseStatus).toBe(
'submitting',
);
// Second call: submitted status
const secondUpdate = setMessageList.mock.calls[1][0] as (
list: MessageInfo[],
) => MessageInfo[];
const result2 = secondUpdate(existing);
expect(result2[0].acpPermissionInteractions![0].responseStatus).toBe(
'submitted',
);
});
it('sets responseStatus to failed when API returns non-SUCCESS code', async () => {
mockApiAgentInterventionRespond.mockResolvedValueOnce({
code: '9999',
message: '失败',
});
const existing = [
{
id: 'msg-1',
acpPermissionInteractions: [createAcpInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createAcpInteraction();
const acpResponse: AcpRequestPermissionResponse = {
outcome: { outcome: 'selected', optionId: 'opt-1' },
};
await result.current.respondAcpPermission(interaction, acpResponse);
expect(setMessageList).toHaveBeenCalledTimes(2);
const failUpdate = setMessageList.mock.calls[1][0] as (
list: MessageInfo[],
) => MessageInfo[];
const updated = failUpdate(existing);
const updatedInteraction = updated[0].acpPermissionInteractions![0];
expect(updatedInteraction.responseStatus).toBe('failed');
expect(updatedInteraction.errorMessage).toBe('失败');
});
it('sets responseStatus to submitted when API reports permission already resolved', async () => {
mockApiAgentInterventionRespond.mockResolvedValueOnce({
code: '9999',
message: 'pending permission not found',
});
const existing = [
{
id: 'msg-1',
acpPermissionInteractions: [createAcpInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
await result.current.respondAcpPermission(createAcpInteraction(), {
outcome: { outcome: 'selected', optionId: 'opt-1' },
});
expect(setMessageList).toHaveBeenCalledTimes(2);
const submittedUpdate = setMessageList.mock.calls[1][0] as (
list: MessageInfo[],
) => MessageInfo[];
expect(
submittedUpdate(existing)[0].acpPermissionInteractions![0]
.responseStatus,
).toBe('submitted');
});
it('sets responseStatus to failed on network error and calls message.error', async () => {
const { message } = await import('antd');
mockApiAgentInterventionRespond.mockRejectedValueOnce(
new Error('network timeout'),
);
const existing = [
{
id: 'msg-1',
acpPermissionInteractions: [createAcpInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createAcpInteraction();
const acpResponse: AcpRequestPermissionResponse = {
outcome: { outcome: 'selected', optionId: 'opt-1' },
};
await result.current.respondAcpPermission(interaction, acpResponse);
expect(setMessageList).toHaveBeenCalledTimes(2);
const failUpdate = setMessageList.mock.calls[1][0] as (
list: MessageInfo[],
) => MessageInfo[];
const updated = failUpdate(existing);
expect(updated[0].acpPermissionInteractions![0].responseStatus).toBe(
'failed',
);
expect(updated[0].acpPermissionInteractions![0].errorMessage).toBe(
'network timeout',
);
// toast 始终是统一友好文案,不再透传原始 message
expect(message.error).toHaveBeenCalledWith(
'PC.Models.ConversationInfo.permissionResponseFailed',
);
});
it('sets selectedOptionId when outcome is selected', async () => {
mockApiAgentInterventionRespond.mockResolvedValueOnce({
code: '0000',
});
const existing = [
{
id: 'msg-1',
acpPermissionInteractions: [createAcpInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createAcpInteraction();
const acpResponse: AcpRequestPermissionResponse = {
outcome: { outcome: 'selected', optionId: 'opt-1' },
};
await result.current.respondAcpPermission(interaction, acpResponse);
const firstUpdate = setMessageList.mock.calls[0][0] as (
list: MessageInfo[],
) => MessageInfo[];
const updated = firstUpdate(existing);
expect(updated[0].acpPermissionInteractions![0].selectedOptionId).toBe(
'opt-1',
);
});
it('leaves selectedOptionId undefined when outcome is cancelled', async () => {
mockApiAgentInterventionRespond.mockResolvedValueOnce({
code: '0000',
});
const existing = [
{
id: 'msg-1',
acpPermissionInteractions: [createAcpInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createAcpInteraction();
const acpResponse: AcpRequestPermissionResponse = {
outcome: { outcome: 'cancelled' },
};
await result.current.respondAcpPermission(interaction, acpResponse);
const firstUpdate = setMessageList.mock.calls[0][0] as (
list: MessageInfo[],
) => MessageInfo[];
const updated = firstUpdate(existing);
expect(
updated[0].acpPermissionInteractions![0].selectedOptionId,
).toBeUndefined();
});
});
/* ---------------------------------------------------------------- */
/* MCP respondMcpAsk tests */
/* ---------------------------------------------------------------- */
describe('respondMcpAsk', () => {
it('sets responseStatus to submitted for submit action', () => {
const existing = [
{
id: 'msg-1',
mcpAskInteractions: [createMcpAskInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createMcpAskInteraction();
const payload: McpAskRespondPayload = {
interventionId: 'itv-001',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: { name: 'test' },
};
result.current.respondMcpAsk(interaction, payload);
const update = setMessageList.mock.calls[0][0] as (
list: MessageInfo[],
) => MessageInfo[];
const updated = update(existing);
expect(updated[0].mcpAskInteractions![0].responseStatus).toBe(
'submitted',
);
expect(updated[0].mcpAskInteractions![0].formData).toEqual({
name: 'test',
});
});
it('sets responseStatus to cancelled for cancel action', () => {
const existing = [
{
id: 'msg-1',
mcpAskInteractions: [createMcpAskInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createMcpAskInteraction();
const payload: McpAskRespondPayload = {
interventionId: 'itv-001',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'cancel',
};
result.current.respondMcpAsk(interaction, payload);
const update = setMessageList.mock.calls[0][0] as (
list: MessageInfo[],
) => MessageInfo[];
const updated = update(existing);
expect(updated[0].mcpAskInteractions![0].responseStatus).toBe(
'cancelled',
);
});
it('sets responseStatus to skipped for skip action', () => {
const existing = [
{
id: 'msg-1',
mcpAskInteractions: [createMcpAskInteraction()],
},
] as MessageInfo[];
const setMessageList = createMockSetMessageList(existing);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createMcpAskInteraction();
const payload: McpAskRespondPayload = {
interventionId: 'itv-001',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'skip',
};
result.current.respondMcpAsk(interaction, payload);
const update = setMessageList.mock.calls[0][0] as (
list: MessageInfo[],
) => MessageInfo[];
const updated = update(existing);
expect(updated[0].mcpAskInteractions![0].responseStatus).toBe('skipped');
});
it('returns the result of buildMcpAskResumeMessage', async () => {
const setMessageList = createMockSetMessageList([]);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createMcpAskInteraction();
const payload: McpAskRespondPayload = {
interventionId: 'itv-001',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: { name: 'test' },
};
const resume = await result.current.respondMcpAsk(interaction, payload);
expect(resume?.text).toContain(
'PC.Components.McpAskQuestionCard.resumeSubmitted',
);
expect(resume?.files).toBeUndefined();
});
it('returns files from payload on submit', async () => {
const setMessageList = createMockSetMessageList([]);
const { result } = renderHook(() =>
useAgentInterventionHandlers({ setMessageList, conversationId: 123 }),
);
const interaction = createMcpAskInteraction();
const payload: McpAskRespondPayload = {
interventionId: 'itv-001',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: { screenshot: 'https://cdn.example.com/shot.png' },
files: [
{
uid: 'f1',
name: 'shot.png',
url: 'https://cdn.example.com/shot.png',
type: 'image/png',
size: 0,
},
],
};
const resume = await result.current.respondMcpAsk(interaction, payload);
expect(resume?.files).toHaveLength(1);
expect(resume?.files?.[0].name).toBe('shot.png');
});
});
});

View File

@@ -0,0 +1,191 @@
import { SUCCESS_CODE } from '@/constants/codes.constants';
import { apiAgentInterventionRespond } from '@/services/agentConfig';
import { dict } from '@/services/i18nRuntime';
import type { UploadFileInfo } from '@/types/interfaces/common';
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import { message } from 'antd';
import { useCallback, type Dispatch, type SetStateAction } from 'react';
import type {
AcpPermissionInteraction,
AcpRequestPermissionResponse,
} from '../types/acpIntervention';
import type {
McpAskInteraction,
McpAskRespondPayload,
} from '../types/mcpAskIntervention';
import { buildMcpAskResumeMessage } from '../utils/mcpAskResumeMessage';
import { isIdempotentAcpPermissionResolveError } from '../utils/reconcileAcpPermissionStatus';
/** MCP Ask resume 发送结果:正文 + 可选附件(走 chat attachments */
export interface McpAskResumeSendResult {
text: string;
files?: UploadFileInfo[];
}
export interface UseAgentInterventionHandlersOptions {
setMessageList: Dispatch<SetStateAction<MessageInfo[]>>;
conversationId?: number | null;
}
export function useAgentInterventionHandlers({
setMessageList,
conversationId,
}: UseAgentInterventionHandlersOptions) {
const updateAcpPermissionInteraction = useCallback(
(interventionId: string, updates: Partial<AcpPermissionInteraction>) => {
setMessageList((list) =>
list.map((item) => {
const interactions = item.acpPermissionInteractions;
if (
!interactions?.some(
(interaction) => interaction.intervention.id === interventionId,
)
) {
return item;
}
return {
...item,
acpPermissionInteractions: interactions.map((interaction) =>
interaction.intervention.id === interventionId
? { ...interaction, ...updates }
: interaction,
),
};
}),
);
},
[setMessageList],
);
const updateMcpAskInteraction = useCallback(
(requestId: string, updates: Partial<McpAskInteraction>) => {
setMessageList((list) =>
list.map((item) => {
const interactions = item.mcpAskInteractions;
if (!interactions?.some((x) => x.input.requestId === requestId)) {
return item;
}
return {
...item,
mcpAskInteractions: interactions.map((interaction) =>
interaction.input.requestId === requestId
? { ...interaction, ...updates }
: interaction,
),
};
}),
);
},
[setMessageList],
);
const respondAcpPermission = useCallback(
async (
interaction: AcpPermissionInteraction,
acpResponse: AcpRequestPermissionResponse,
) => {
const { intervention } = interaction;
updateAcpPermissionInteraction(intervention.id, {
responseStatus: 'submitting',
selectedOptionId:
acpResponse.outcome.outcome === 'selected'
? acpResponse.outcome.optionId
: undefined,
errorMessage: undefined,
});
try {
const isSelected = acpResponse.outcome.outcome === 'selected';
const sessionId =
intervention.acp?.request?.sessionId || intervention.sessionId;
const toolCallId = intervention.acp?.request?.toolCall?.toolCallId;
const response = await apiAgentInterventionRespond({
interventionId: intervention.id,
permission_resolve_request: {
request_permission_response: {
outcome: isSelected
? { Selected: { option_id: acpResponse.outcome.optionId } }
: { Cancelled: null },
},
session_id: sessionId,
tool_call_id: toolCallId,
save_rule: false,
},
user_id: String(conversationId),
conversation_id: conversationId || undefined,
});
if (response?.code && response.code !== SUCCESS_CODE) {
const apiMessage = response.message || '';
if (isIdempotentAcpPermissionResolveError(apiMessage)) {
updateAcpPermissionInteraction(intervention.id, {
responseStatus: 'submitted',
});
return;
}
throw new Error(
apiMessage ||
dict('PC.Models.ConversationInfo.permissionResponseFailed'),
);
}
updateAcpPermissionInteraction(intervention.id, {
responseStatus: 'submitted',
});
} catch (error) {
if (isIdempotentAcpPermissionResolveError(error)) {
updateAcpPermissionInteraction(intervention.id, {
responseStatus: 'submitted',
errorMessage: undefined,
});
return;
}
const errorMessage =
error instanceof Error
? error.message
: dict('PC.Models.ConversationInfo.permissionResponseFailed');
updateAcpPermissionInteraction(intervention.id, {
responseStatus: 'failed',
errorMessage,
});
// 始终展示统一的友好提示,避免透传后端原始 message已通过
// skipErrorHandler 关闭全局 toast确保只弹一次
message.error(
dict('PC.Models.ConversationInfo.permissionResponseFailed'),
);
}
},
[updateAcpPermissionInteraction, conversationId],
);
const respondMcpAsk = useCallback(
async (interaction: McpAskInteraction, payload: McpAskRespondPayload) => {
const requestId = interaction.input.requestId;
const { action } = payload;
const resolveStatus = (): McpAskInteraction['responseStatus'] => {
if (action === 'cancel') return 'cancelled';
if (action === 'skip') return 'skipped';
return 'submitted';
};
updateMcpAskInteraction(requestId, {
responseStatus: resolveStatus(),
formData: payload.formData,
});
const text = buildMcpAskResumeMessage(interaction, payload);
const files =
action === 'submit' && payload.files?.length
? payload.files
: undefined;
return { text, files };
},
[updateMcpAskInteraction],
);
return {
respondAcpPermission,
respondMcpAsk,
};
}

View File

@@ -0,0 +1,160 @@
import { DefaultSelectedEnum } from '@/types/enums/agent';
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAgentInterventionLayer } from './useAgentInterventionLayer';
const conversationInfoHandlers = {
respondAcpPermission: vi.fn(),
respondMcpAsk: vi
.fn()
.mockResolvedValue({ text: 'resume-from-conversation-info' }),
};
vi.mock('umi', () => ({
useModel: () => conversationInfoHandlers,
}));
describe('useAgentInterventionLayer', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
conversationInfoHandlers.respondMcpAsk.mockResolvedValue({
text: 'resume-from-conversation-info',
});
});
it('uses conversationInfo handlers by default', async () => {
const onSendMessage = vi.fn();
const { result } = renderHook(() =>
useAgentInterventionLayer({
conversationId: 1,
messageList: [],
onSendMessage,
}),
);
await act(async () => {
await result.current.chatLayerProps.onRespondMcpAsk?.(
{
input: { requestId: 'ask-1', toolName: 'nuwax_ask_question' },
toolCallId: 'tc-1',
responseStatus: 'pending',
} as any,
{ action: 'submit', formData: {} },
);
});
expect(conversationInfoHandlers.respondMcpAsk).toHaveBeenCalled();
expect(onSendMessage).toHaveBeenCalledWith(
'resume-from-conversation-info',
undefined,
);
});
it('uses injected interventionHandlers for isolated session sources', async () => {
const onSendMessage = vi.fn();
const respondMcpAsk = vi
.fn()
.mockResolvedValue({ text: 'resume-from-preview' });
const { result } = renderHook(() =>
useAgentInterventionLayer({
conversationId: 99,
messageList: [],
onSendMessage,
interventionHandlers: {
respondAcpPermission: vi.fn(),
respondMcpAsk,
},
}),
);
await act(async () => {
await result.current.chatLayerProps.onRespondMcpAsk?.(
{
input: { requestId: 'ask-2', toolName: 'nuwax_ask_question' },
toolCallId: 'tc-2',
responseStatus: 'pending',
} as any,
{ action: 'submit', formData: {} },
);
});
expect(respondMcpAsk).toHaveBeenCalled();
expect(conversationInfoHandlers.respondMcpAsk).not.toHaveBeenCalled();
expect(onSendMessage).toHaveBeenCalledWith(
'resume-from-preview',
undefined,
);
});
it('syncs agentMode cache between sessions of the same agent', () => {
const createHook = () =>
renderHook(() =>
useAgentInterventionLayer({
conversationId: 1,
agentId: 1001,
messageList: [],
allowChooseMode: DefaultSelectedEnum.Yes,
onSendMessage: vi.fn(),
}),
);
const first = createHook();
const second = createHook();
expect(first.result.current.agentMode).toBe('yolo');
expect(second.result.current.agentMode).toBe('yolo');
act(() => {
first.result.current.agentModeInputProps.onAgentModeChange('ask');
window.dispatchEvent(
new StorageEvent('storage', {
key: 'nuwax_agent_mode_cache',
}),
);
});
expect(first.result.current.agentMode).toBe('ask');
expect(second.result.current.agentMode).toBe('ask');
expect(
JSON.parse(localStorage.getItem('nuwax_agent_mode_cache') || '{}'),
).toMatchObject({
agents: {
'1001': 'ask',
},
});
});
it('keeps agentMode cache isolated between different agents', () => {
const first = renderHook(() =>
useAgentInterventionLayer({
conversationId: 1,
agentId: 1001,
messageList: [],
allowChooseMode: DefaultSelectedEnum.Yes,
onSendMessage: vi.fn(),
}),
);
const second = renderHook(() =>
useAgentInterventionLayer({
conversationId: 2,
agentId: 1002,
messageList: [],
allowChooseMode: DefaultSelectedEnum.Yes,
onSendMessage: vi.fn(),
}),
);
act(() => {
first.result.current.agentModeInputProps.onAgentModeChange('ask');
window.dispatchEvent(
new StorageEvent('storage', {
key: 'nuwax_agent_mode_cache',
}),
);
});
expect(first.result.current.agentMode).toBe('ask');
expect(second.result.current.agentMode).toBe('yolo');
});
});

View File

@@ -0,0 +1,305 @@
import { GLOBAL_POLLING_INTERVAL } from '@/constants/home.constants';
import { DefaultSelectedEnum } from '@/types/enums/agent';
import type { UploadFileInfo } from '@/types/interfaces/common';
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useModel } from 'umi';
import type { AgentInterventionChatLayerProps } from '../AgentInterventionChatLayer';
import type {
AcpPermissionInteraction,
AcpRequestPermissionResponse,
AgentMode,
} from '../types/acpIntervention';
import type {
McpAskInteraction,
McpAskRespondPayload,
} from '../types/mcpAskIntervention';
import type { McpAskResumeSendResult } from './useAgentInterventionHandlers';
/**
* 隔离会话源(如 ConversationAgent 预览 Tab / conversationAgent model时注入
* 避免干预回执误写入全局 conversationInfo 的 messageList。
*/
export interface AgentInterventionHandlersOverride {
respondAcpPermission: (
interaction: AcpPermissionInteraction,
response: AcpRequestPermissionResponse,
) => void | Promise<void>;
respondMcpAsk: (
interaction: McpAskInteraction,
payload: McpAskRespondPayload,
) => Promise<McpAskResumeSendResult | null | undefined>;
}
export interface UseAgentInterventionLayerOptions {
conversationId?: number | string | null;
/** 智能体 ID开启模式切换时按智能体维度读写 Agent 模式缓存 */
agentId?: number | string | null;
messageList: MessageInfo[];
/** 由上层(如新建项目页)透传的初始 Agent 模式;无智能体缓存时用于初始化 */
initialAgentMode?: AgentMode;
/**
* 是否允许选择 Agent 模式(智能体 allowChooseMode 配置)。仅 === Yes 时启用模式切换:
* 读写 localStorage 缓存 + storage 同步;否则 agentMode 固定 yolo不污染/读取全局缓存,
* 避免没展示模式切换的会话框误用别的会话切换留下的 ask。
*/
allowChooseMode?: DefaultSelectedEnum | number;
onSendMessage: (message: string, files?: UploadFileInfo[]) => void;
/**
* 非 conversationInfo 会话源时传入(如 ConversationAgent 预览 Tab
* 使 Dock 回执与停止会话作用于正确的 model。
*/
interventionHandlers?: AgentInterventionHandlersOverride;
}
export interface AgentModeInputProps {
agentMode: AgentMode;
onAgentModeChange: (mode: AgentMode) => void;
}
export interface UseAgentInterventionLayerResult {
agentMode: AgentMode;
chatLayerProps: Pick<
AgentInterventionChatLayerProps,
'messageList' | 'onRespondAcpPermission' | 'onRespondMcpAsk'
>;
agentModeInputProps: AgentModeInputProps;
}
const AGENT_MODE_STORAGE_KEY = 'nuwax_agent_mode_cache';
type AgentModeCacheObject = {
version?: number;
defaultMode?: AgentMode;
agents?: Record<string, AgentMode>;
};
const isAgentMode = (mode: unknown): mode is AgentMode =>
mode === 'yolo' || mode === 'ask';
const normalizeAgentModeCacheAgentId = (
agentId?: number | string | null,
): string | undefined => {
if (agentId === undefined || agentId === null) {
return undefined;
}
const normalized = String(agentId).trim();
return normalized ? normalized : undefined;
};
const parseAgentModeCache = (
raw: string | null,
): AgentModeCacheObject | AgentMode | null => {
if (!raw) {
return null;
}
if (isAgentMode(raw)) {
return raw;
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed as AgentModeCacheObject;
}
} catch (e) {
// ignore invalid cache
}
return null;
};
export const readAgentModeCache = (
agentId?: number | string | null,
): AgentMode | null => {
const agentKey = normalizeAgentModeCacheAgentId(agentId);
const parsed = parseAgentModeCache(
localStorage.getItem(AGENT_MODE_STORAGE_KEY),
);
if (isAgentMode(parsed)) {
return parsed;
}
if (!parsed) {
return null;
}
const agentMode = agentKey ? parsed.agents?.[agentKey] : undefined;
if (isAgentMode(agentMode)) {
return agentMode;
}
if (!agentKey && isAgentMode(parsed.defaultMode)) {
return parsed.defaultMode;
}
return null;
};
export const writeAgentModeCache = (
mode: AgentMode,
agentId?: number | string | null,
) => {
const agentKey = normalizeAgentModeCacheAgentId(agentId);
const parsed = parseAgentModeCache(
localStorage.getItem(AGENT_MODE_STORAGE_KEY),
);
const nextCache: AgentModeCacheObject =
parsed && !isAgentMode(parsed)
? {
...parsed,
agents: { ...(parsed.agents || {}) },
}
: {
version: 1,
defaultMode: isAgentMode(parsed) ? parsed : undefined,
agents: {},
};
if (agentKey) {
nextCache.agents = {
...(nextCache.agents || {}),
[agentKey]: mode,
};
} else {
nextCache.defaultMode = mode;
}
localStorage.setItem(AGENT_MODE_STORAGE_KEY, JSON.stringify(nextCache));
};
export function useAgentInterventionLayer(
options: UseAgentInterventionLayerOptions,
): UseAgentInterventionLayerResult {
const {
agentId,
messageList,
initialAgentMode,
allowChooseMode,
onSendMessage,
interventionHandlers,
} = options;
// 仅当智能体开启了模式切换(allowChooseMode===Yes)且非预览模式时,才读写/同步 localStorage 缓存;
// 否则 agentMode 固定 yolo避免没展示模式切换的会话框误用别的会话切换留下的 ask。
const agentModeEnabled = allowChooseMode === DefaultSelectedEnum.Yes;
const skipStorage = !!interventionHandlers || !agentModeEnabled;
const [agentMode, setAgentModeState] = useState<AgentMode>(() => {
// 未开启模式切换 / 预览模式:仅内存态,固定 yolo不读写 localStorage
if (skipStorage) {
return 'yolo';
}
try {
const cached = readAgentModeCache(agentId);
if (cached) {
return cached;
}
} catch (e) {
// ignore localStorage errors
}
if (initialAgentMode === 'yolo' || initialAgentMode === 'ask') {
return initialAgentMode;
}
return 'yolo';
});
const setAgentMode = useCallback(
(mode: AgentMode) => {
setAgentModeState(mode);
if (skipStorage) {
return;
}
try {
writeAgentModeCache(mode, agentId);
} catch (e) {
// ignore localStorage errors
}
},
[agentId, skipStorage],
);
// 用 ref 跟踪最新 agentMode避免同步 effect 依赖它而频繁重建定时器
const agentModeRef = useRef(agentMode);
agentModeRef.current = agentMode;
// 当智能体切换或其模式选择权限发生变化时,重置/更新 agentMode 状态,
// 特别是在未开启模式切换时强制回归 'yolo',避免上一个智能体遗留下来的 'ask' 被错误使用。
useEffect(() => {
if (!agentModeEnabled) {
setAgentModeState('yolo');
} else {
try {
const cached = readAgentModeCache(agentId);
if (cached === 'yolo' || cached === 'ask') {
setAgentModeState(cached);
} else if (initialAgentMode === 'yolo' || initialAgentMode === 'ask') {
setAgentModeState(initialAgentMode);
} else {
setAgentModeState('yolo');
}
} catch {
setAgentModeState('yolo');
}
}
}, [agentId, agentModeEnabled, initialAgentMode]);
// 模式切换已开启:轮询 + storage 事件把 localStorage 的 agentMode 同步到 state
// 使恢复中的会话使用最新模式(多标签下 A 切换后 B 能及时同步)。
// 未开启模式切换 / 预览模式:仅内存态、不读写 localStorage跳过。
useEffect(() => {
if (skipStorage) {
return;
}
const syncFromCache = () => {
try {
const cached = readAgentModeCache(agentId);
if (
(cached === 'yolo' || cached === 'ask') &&
cached !== agentModeRef.current
) {
// 仅更新 state不回写缓存避免与写入方形成循环
setAgentModeState(cached);
}
} catch (e) {
// ignore localStorage errors
}
};
// 跨标签storage 事件即时同步
window.addEventListener('storage', syncFromCache);
// 同标签兜底:与全局轮询同频定期读
const timer = window.setInterval(syncFromCache, GLOBAL_POLLING_INTERVAL);
return () => {
window.removeEventListener('storage', syncFromCache);
window.clearInterval(timer);
};
}, [agentId, skipStorage]);
const conversationInfoModel = useModel('conversationInfo');
const respondAcpPermission =
interventionHandlers?.respondAcpPermission ??
conversationInfoModel.respondAcpPermission;
const respondMcpAsk =
interventionHandlers?.respondMcpAsk ?? conversationInfoModel.respondMcpAsk;
const handleRespondMcpAsk = useCallback(
async (interaction: McpAskInteraction, payload: McpAskRespondPayload) => {
const resume = await respondMcpAsk(interaction, payload);
if (resume?.text) {
onSendMessage(resume.text, resume.files);
}
},
[respondMcpAsk, onSendMessage],
);
return {
agentMode,
chatLayerProps: {
messageList,
onRespondAcpPermission: respondAcpPermission,
onRespondMcpAsk: handleRespondMcpAsk,
},
agentModeInputProps: {
agentMode,
onAgentModeChange: setAgentMode,
},
};
}

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react';
export interface UseInterventionEscapeKeyOptions {
enabled: boolean;
onEscape: () => void;
respectFormFieldFocus?: boolean;
}
/** 判断键盘事件是否发生在表单控件内(供快捷键 hook 复用) */
export function isFormFieldTarget(target: EventTarget | null): boolean {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
return (
target.isContentEditable ||
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT'
);
}
export function useInterventionEscapeKey({
enabled,
onEscape,
respectFormFieldFocus = true,
}: UseInterventionEscapeKeyOptions): void {
useEffect(() => {
if (!enabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
if (respectFormFieldFocus && isFormFieldTarget(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
onEscape();
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [enabled, onEscape, respectFormFieldFocus]);
}

View File

@@ -0,0 +1,28 @@
// Components
export { default as AgentInterventionChatLayer } from './AgentInterventionChatLayer';
// Hooks
export { useAgentInterventionHandlers } from './hooks/useAgentInterventionHandlers';
export {
useAgentInterventionLayer,
type AgentInterventionHandlersOverride,
} from './hooks/useAgentInterventionLayer';
// Utils
export {
hydrateMcpAskInteractionsInMessageList,
prependAndHydrateMcpAskMessageList,
} from './utils/mcpAskHydrateMessage';
export { processInterventionSsePatch } from './utils/processInterventionSsePatch';
// Types
export type {
AcpPermissionInteraction,
AcpRequestPermissionResponse,
AgentInterventionRespondRequest,
AgentMode,
} from './types/acpIntervention';
export type {
McpAskInteraction,
McpAskRespondPayload,
} from './types/mcpAskIntervention';

View File

@@ -0,0 +1,108 @@
import type { McpAskRespondPayload } from './mcpAskIntervention';
export type AgentMode = 'ask' | 'yolo';
export type AcpPermissionOptionKind =
| 'allow_once'
| 'allow_always'
| 'reject_once'
| 'reject_always';
export type AcpToolKind =
| 'read'
| 'edit'
| 'delete'
| 'move'
| 'search'
| 'execute'
| 'think'
| 'fetch'
| 'switch_mode'
| 'other';
export interface AcpPermissionOption {
optionId: string;
kind: AcpPermissionOptionKind;
name: string;
_meta?: Record<string, unknown> | null;
}
export interface AcpToolCallUpdate {
toolCallId: string;
title?: string | null;
kind?: AcpToolKind | string | null;
rawInput?: unknown;
rawOutput?: unknown;
locations?: Array<{ path: string; line?: number | null }> | null;
content?: unknown[] | null;
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | string | null;
}
export interface AcpRequestPermissionRequest {
sessionId: string;
toolCall: AcpToolCallUpdate;
options: AcpPermissionOption[];
_meta?: Record<string, unknown> | null;
}
export interface AcpRequestPermissionResponse {
outcome:
| {
outcome: 'selected';
optionId: string;
_meta?: Record<string, unknown> | null;
}
| { outcome: 'cancelled' };
_meta?: Record<string, unknown> | null;
}
export interface AcpPermissionInterventionRequest {
id: string;
revision: number;
kind: 'approval';
status: 'pending';
sessionId: string;
source: 'acp_permission';
engine: 'claude-code' | 'nuwaxcode' | 'codex';
protocol: 'acp';
callbackTarget: {
kind: 'electron' | 'rcoder';
targetId: string;
};
schemaRef: string;
acp: {
method: 'session/request_permission';
request: AcpRequestPermissionRequest;
};
timeoutMs?: number;
createdAt: number;
}
export interface AcpPermissionInteraction {
intervention: AcpPermissionInterventionRequest;
/** 关联的 processing executeId与 processingList 同源),用于按 executeId 判断审批是否已过期 */
executeId?: string;
responseStatus?: 'pending' | 'submitting' | 'submitted' | 'failed';
selectedOptionId?: string;
errorMessage?: string;
triggeredAt?: number;
}
export type AgentInterventionAction = 'submit' | 'cancel' | 'skip' | 'timeout';
export interface AgentInterventionRespondRequest {
interventionId: string;
permission_resolve_request?: {
request_permission_response: {
outcome:
| { Selected: { option_id: string } }
| { Cancelled: Record<string, never> | null };
};
session_id: string;
tool_call_id: string;
save_rule?: boolean;
};
mcp_ask_resolve?: McpAskRespondPayload;
user_id?: string;
conversation_id?: number;
}

View File

@@ -0,0 +1,164 @@
/**
* MCP Ask v2 契约类型(nuwax.interaction.v2 / nuwax.mcp_ask.v2)。
* 表单为有序自描述数组 ui.fields[];enum/enumNames 合并为 options,控件/约束/required/initialValue 下沉到字段。
* 仅支持最新 v2,不兼容旧版本(历史 v1 rawInput 不再渲染)。
*/
export const MCP_ASK_SCHEMA_VERSION = 'nuwax.mcp_ask.v2';
export const INTERACTION_UI_SCHEMA_VERSION = 'nuwax.interaction.v2';
export type McpAskToolName = 'nuwax_ask_question';
export interface InteractionUiStep {
id: string;
title: string;
description?: string;
/** 本步展示的字段 name 数组,引用 ui.fields 中字段的 name */
fields: string[];
}
/** v2 字段选项(合并旧 enum + enumNames) */
export interface FieldOption {
value: string;
label: string;
}
/** 渲染层(ParsedMcpAskField.options)读取的 UI 配置 */
export interface InteractionUiOptions {
placeholder?: string;
allowCustom?: boolean;
otherValue?: string;
otherField?: string;
accept?: string;
multiple?: boolean;
}
/** v2 单个表单字段(自描述:控件/选项/约束合并进同一对象) */
export interface FormField {
name: string;
title: string;
widget: McpAskFieldWidget;
description?: string;
required?: boolean;
placeholder?: string;
initialValue?: unknown;
type?: 'string' | 'integer' | 'number' | 'array';
minimum?: number;
maximum?: number;
exclusiveMinimum?: number;
exclusiveMaximum?: number;
multipleOf?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
options?: FieldOption[];
accept?: string;
multiple?: boolean;
maxFileSize?: number;
allowCustom?: boolean;
otherValue?: string;
otherField?: string;
}
/** v2 交互 UI 定义;数组顺序即渲染顺序,不再有 schema/uiSchema/ui:order */
export interface InteractionUiSchema {
version: typeof INTERACTION_UI_SCHEMA_VERSION;
presentation: 'modal' | 'inline' | 'wizard';
title: string;
description?: string;
fields?: FormField[];
steps?: InteractionUiStep[];
submitLabel?: string;
cancelLabel?: string;
allowSkip?: boolean;
skipLabel?: string;
fallback?: {
text: string;
webUrl?: string;
mobileUrl?: string;
};
}
export interface McpAskUserToolInput {
toolName: McpAskToolName;
schemaVersion: typeof MCP_ASK_SCHEMA_VERSION;
requestId: string;
revision: number;
sessionId: string;
title: string;
description?: string;
ui: InteractionUiSchema;
business?: Record<string, unknown>;
timeoutMs?: number;
priority?: 'normal' | 'high';
}
import type { UploadFileInfo } from '@/types/interfaces/common';
export type McpAskRespondAction = 'submit' | 'cancel' | 'skip' | 'timeout';
export interface McpAskRespondPayload {
interventionId: string;
toolCallId?: string;
revision: number;
source: 'mcp_ask';
protocol: 'mcp';
action: McpAskRespondAction;
formData?: Record<string, unknown>;
/** submit 时从 file 字段提取的附件,走 chat attachments 发送 */
files?: UploadFileInfo[];
answeredBy?: {
kind: 'web' | 'mobile';
userId?: string;
clientId?: string;
};
answeredAt?: number;
}
export type McpAskResponseStatus =
| 'pending'
| 'submitting'
| 'submitted'
| 'cancelled'
| 'skipped'
| 'failed';
export interface McpAskInteraction {
input: McpAskUserToolInput;
toolCallId: string;
/** 关联的 processing executeId与 processingList 同源),用于按 executeId 判断审批是否已过期 */
executeId?: string;
responseStatus?: McpAskResponseStatus;
formData?: Record<string, unknown>;
errorMessage?: string;
triggeredAt?: number;
}
/**
* JsonSchema 约束视图:parser 从 v2 FormField 重建,
* 供 McpAskFormField 读取(property.title/minimum/maximum/multipleOf/maxLength/minItems/type)。
*/
export interface JsonSchemaProperty {
type?: string | string[];
title?: string;
description?: string;
minLength?: number;
maxLength?: number;
minItems?: number;
minimum?: number;
maximum?: number;
multipleOf?: number;
}
export const MCP_ASK_WIDGET_TYPES = [
'text',
'textarea',
'number',
'radio',
'checkboxes',
'select',
'list',
'file',
'radio-with-custom',
] as const;
export type McpAskFieldWidget = (typeof MCP_ASK_WIDGET_TYPES)[number];

View File

@@ -0,0 +1,427 @@
import { ConversationEventTypeEnum } from '@/types/enums/agent';
import { MessageStatusEnum } from '@/types/enums/common';
import { describe, expect, it } from 'vitest';
import { applyAcpPermissionSseEvent } from './applyAcpPermissionSseEvent';
describe('applyAcpPermissionSseEvent', () => {
it('accepts documented snake_case ACP request_permission events', () => {
const patched = applyAcpPermissionSseEvent(
{
session_id: 'session-snake',
message_type: 'acpRequestPermission',
sub_type: 'request_permission',
data: {
request_permission_request: {
session_id: 'session-snake',
tool_call: {
tool_call_id: 'tool-call-snake',
title: 'Write file',
kind: 'edit',
status: 'pending',
raw_input: {
command: 'touch approval-test.txt',
},
},
options: [
{
option_id: 'allow-once',
kind: 'allow_once',
name: 'Allow once',
},
{
option_id: 'reject-once',
kind: 'reject_once',
name: 'Reject',
},
],
},
tool_call_id: 'tool-call-snake',
},
} as any,
{ id: 'msg-1' } as any,
);
const request =
patched?.acpPermissionInteractions?.[0]?.intervention.acp.request;
expect(request?.sessionId).toBe('session-snake');
expect(request?.toolCall.toolCallId).toBe('tool-call-snake');
expect(request?.toolCall.rawInput).toEqual({
command: 'touch approval-test.txt',
});
expect(request?.options.map((option) => option.optionId)).toEqual([
'allow-once',
'reject-once',
]);
});
it('accepts raw ACP request_permission progress events', () => {
const patched = applyAcpPermissionSseEvent(
{
messageType: 'acpRequestPermission',
subType: 'request_permission',
data: {
request_permission_request: {
session_id: 'session-1',
tool_call: {
tool_call_id: 'tool-call-1',
title: 'Write file',
kind: 'edit',
status: 'pending',
},
options: [
{
option_id: 'allow_once',
kind: 'allow_once',
name: 'Allow once',
},
],
},
tool_call_id: 'tool-call-1',
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.acpPermissionInteractions?.[0]?.intervention.id).toBe(
'itv_session-1_tool-call-1',
);
expect(
patched?.acpPermissionInteractions?.[0]?.intervention.acp.request.options
.length,
).toBe(1);
});
it('accepts backend-wrapped ACP request_permission progress events', () => {
const patched = applyAcpPermissionSseEvent(
{
eventType: ConversationEventTypeEnum.MESSAGE,
data: {
messageType: 'acpRequestPermission',
subType: 'request_permission',
data: {
request_permission_request: {
session_id: 'session-2',
tool_call: {
tool_call_id: 'tool-call-2',
title: 'Run command',
kind: 'execute',
status: 'pending',
},
options: [
{
option_id: 'approved',
kind: 'allow_once',
name: 'Yes, proceed',
},
],
},
tool_call_id: 'tool-call-2',
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.acpPermissionInteractions?.[0]?.intervention.id).toBe(
'itv_session-2_tool-call-2',
);
expect(
patched?.acpPermissionInteractions?.[0]?.intervention.acp.request.toolCall
.toolCallId,
).toBe('tool-call-2');
});
it('accepts PROCESSING request permission component events', () => {
const patched = applyAcpPermissionSseEvent(
{
requestId: 'request-1',
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
targetId: -1,
name: 'Backend.Sandbox.Event.RequestPermission',
type: 'Event',
status: 'FINISHED',
result: {
id: -1,
name: 'Backend.Sandbox.Event.RequestPermission',
type: 'Event',
startTime: 1780479903686,
endTime: 1780479903686,
input: {
request_permission_request: {
session_id: 'session-processing',
tool_call: {
tool_call_id: 'tool-call-processing',
kind: 'other',
status: 'pending',
title: 'external_directory',
raw_input: {
filepath: '/Users/apple/Desktop/acp-verify-test.txt',
parentDir: '/Users/apple/Desktop',
},
},
options: [
{
option_id: 'once',
name: 'Allow once',
kind: 'allow_once',
},
{
option_id: 'reject',
name: 'Reject',
kind: 'reject_once',
},
],
},
tool_call_id: 'tool-call-processing',
_meta: {
nuwaclaw_intervention_id: 'itv-processing',
nuwaclaw_revision: 2,
},
},
executeId: 'tool-call-processing',
},
subEventType: 'REQUEST_PERMISSION',
},
completed: false,
} as any,
{ id: 'msg-1' } as any,
);
const interaction = patched?.acpPermissionInteractions?.[0];
expect(interaction?.intervention.id).toBe('itv-processing');
expect(interaction?.intervention.revision).toBe(2);
expect(interaction?.intervention.createdAt).toBe(1780479903686);
expect(interaction?.intervention.acp.request.sessionId).toBe(
'session-processing',
);
expect(interaction?.intervention.acp.request.toolCall.toolCallId).toBe(
'tool-call-processing',
);
expect(interaction?.intervention.acp.request.toolCall.rawInput).toEqual({
filepath: '/Users/apple/Desktop/acp-verify-test.txt',
parentDir: '/Users/apple/Desktop',
});
expect(
interaction?.intervention.acp.request.options.map(
(option) => option.optionId,
),
).toEqual(['once', 'reject']);
});
it('forces the message back to loading so the approval card can render', () => {
const patched = applyAcpPermissionSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
result: {
input: {
request_permission_request: {
session_id: 'session-loading',
tool_call: {
tool_call_id: 'tool-call-loading',
kind: 'edit',
status: 'pending',
title: '编辑文件',
},
options: [
{
option_id: 'once',
name: 'Allow once',
kind: 'allow_once',
},
],
},
tool_call_id: 'tool-call-loading',
},
executeId: 'tool-call-loading',
},
subEventType: 'REQUEST_PERMISSION',
},
} as any,
{ id: 'msg-loading', status: MessageStatusEnum.Complete } as any,
);
expect(patched?.status).toBe(MessageStatusEnum.Loading);
});
it('accepts camelCase ACP request_permission events (ACP standard)', () => {
const patched = applyAcpPermissionSseEvent(
{
requestId: 'req-camel',
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
targetId: -1,
name: 'Backend.Sandbox.Event.RequestPermission',
type: 'Event',
status: 'FINISHED',
result: {
id: -1,
name: 'Backend.Sandbox.Event.RequestPermission',
type: 'Event',
startTime: 1780574185241,
endTime: 1780574185241,
input: {
request_permission_request: {
sessionId: 'session-camel',
toolCall: {
toolCallId: 'tool-call-camel',
title: 'Read /etc/hosts',
kind: 'read',
status: 'pending',
rawInput: { file_path: '/etc/hosts' },
locations: [{ path: '/etc/hosts', line: 1 }],
content: [],
},
options: [
{
optionId: 'allow_always',
kind: 'allow_always',
name: 'Always Allow Read(//etc/**)',
},
{
optionId: 'allow',
kind: 'allow_once',
name: 'Allow',
},
{
optionId: 'reject',
kind: 'reject_once',
name: 'Reject',
},
],
},
tool_call_id: 'tool-call-camel',
},
executeId: 'tool-call-camel',
},
subEventType: 'REQUEST_PERMISSION',
},
completed: false,
} as any,
{ id: 'msg-camel' } as any,
);
const request =
patched?.acpPermissionInteractions?.[0]?.intervention.acp.request;
expect(request?.sessionId).toBe('session-camel');
expect(request?.toolCall.toolCallId).toBe('tool-call-camel');
expect(request?.toolCall.rawInput).toEqual({ file_path: '/etc/hosts' });
expect(request?.toolCall.locations).toEqual([
{ path: '/etc/hosts', line: 1 },
]);
expect(request?.options.map((o) => o.optionId)).toEqual([
'allow_always',
'allow',
'reject',
]);
});
it('accepts camelCase requestPermissionRequest payloads', () => {
const patched = applyAcpPermissionSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
name: 'Backend.Sandbox.Event.RequestPermission',
result: {
name: 'Backend.Sandbox.Event.RequestPermission',
executeId: 'tool-call-camel-root',
input: {
requestPermissionRequest: {
sessionId: 'session-camel-root',
toolCall: {
toolCallId: 'tool-call-camel-root',
title: '写入文件',
kind: 'edit',
status: 'pending',
},
options: [
{
optionId: 'once',
kind: 'allow_once',
name: 'Allow once',
},
],
},
toolCallId: 'tool-call-camel-root',
},
},
},
} as any,
{ id: 'msg-camel-root' } as any,
);
const request =
patched?.acpPermissionInteractions?.[0]?.intervention.acp.request;
expect(request?.sessionId).toBe('session-camel-root');
expect(request?.toolCall.toolCallId).toBe('tool-call-camel-root');
expect(request?.options[0]?.optionId).toBe('once');
});
it('recognizes RequestPermission events by event name even without subEventType', () => {
const patched = applyAcpPermissionSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
name: 'Backend.Sandbox.Event.RequestPermission',
result: {
input: {
request_permission_request: {
session_id: 'session-by-name',
tool_call: {
tool_call_id: 'tool-call-by-name',
title: '写入文件',
kind: 'edit',
status: 'pending',
},
options: [
{
option_id: 'once',
kind: 'allow_once',
name: 'Allow once',
},
],
},
tool_call_id: 'tool-call-by-name',
},
executeId: 'tool-call-by-name',
},
},
} as any,
{ id: 'msg-by-name' } as any,
);
expect(
patched?.acpPermissionInteractions?.[0]?.intervention.acp.request.toolCall
.toolCallId,
).toBe('tool-call-by-name');
});
it('normalizes codex-cli engine aliases to codex', () => {
const patched = applyAcpPermissionSseEvent(
{
messageType: 'acpRequestPermission',
subType: 'request_permission',
data: {
request_permission_request: {
session_id: 'session-engine',
tool_call: {
tool_call_id: 'tool-call-engine',
title: 'Run',
kind: 'execute',
status: 'pending',
},
options: [],
},
tool_call_id: 'tool-call-engine',
_engine: 'codex-cli',
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.acpPermissionInteractions?.[0]?.intervention.engine).toBe(
'codex',
);
});
});

View File

@@ -0,0 +1,168 @@
import { ConversationEventTypeEnum } from '@/types/enums/agent';
import { MessageStatusEnum } from '@/types/enums/common';
import type {
ConversationChatResponse,
MessageInfo,
} from '@/types/interfaces/conversationInfo';
import type { AcpPermissionInterventionRequest } from '../types/acpIntervention';
import { createInterventionTriggeredAt } from './interventionTrigger';
import {
extractEventData,
parseSseEventEnvelope,
} from './parseSseEventEnvelope';
function normalizeAcpEngine(
raw: unknown,
): AcpPermissionInterventionRequest['engine'] {
const value = typeof raw === 'string' ? raw : '';
if (value === 'claude-code' || value === 'nuwaxcode' || value === 'codex') {
return value;
}
return 'codex';
}
export function applyAcpPermissionSseEvent(
res: ConversationChatResponse,
currentMessage: MessageInfo,
): MessageInfo | null {
const envelope = parseSseEventEnvelope(res);
const subType = envelope.subType ?? envelope.sub_type;
const eventData = extractEventData(envelope, res);
const processingResult = eventData.result as
| Record<string, unknown>
| undefined;
const processingInput = processingResult?.input as
| Record<string, unknown>
| undefined;
const eventName =
(eventData.name as string) || (processingResult?.name as string) || '';
const eventSubType =
(eventData.subEventType as string) ||
(envelope.subEventType as string) ||
'';
const requestPermissionRequest = (eventData.request_permission_request ??
eventData.requestPermissionRequest ??
processingInput?.request_permission_request ??
processingInput?.requestPermissionRequest) as
| Record<string, unknown>
| undefined;
const isAcpPermissionEvent =
res.eventType === ConversationEventTypeEnum.ACP_REQUEST_PERMISSION ||
((envelope.message_type === 'acpRequestPermission' ||
envelope.messageType === 'acpRequestPermission') &&
(subType === 'AcpRequestPermission' ||
subType === 'request_permission')) ||
(res.eventType === ConversationEventTypeEnum.PROCESSING &&
(eventSubType === 'REQUEST_PERMISSION' ||
eventName === 'Backend.Sandbox.Event.RequestPermission' ||
!!requestPermissionRequest));
if (!isAcpPermissionEvent) {
return null;
}
const reqPerm = requestPermissionRequest;
const requestMeta = (reqPerm?._meta ??
processingInput?._meta ??
eventData._meta) as Record<string, unknown> | undefined;
const intervention = (eventData._intervention ??
eventData.interventionRequest) as
| AcpPermissionInterventionRequest
| undefined;
// Support both camelCase (ACP standard) and snake_case (legacy) field names
const sessionId =
(reqPerm?.sessionId as string) ||
(reqPerm?.session_id as string) ||
intervention?.sessionId ||
(eventData.sessionId as string) ||
(eventData.session_id as string);
const toolCall = (reqPerm?.toolCall ??
reqPerm?.tool_call ??
processingInput?.toolCall ??
processingInput?.tool_call ??
intervention?.acp?.request?.toolCall) as
| Record<string, unknown>
| undefined;
const options = (reqPerm?.options ??
processingInput?.options ??
intervention?.acp?.request?.options) as
| Array<Record<string, unknown>>
| undefined;
const toolCallId =
(eventData.tool_call_id as string) ||
(eventData.toolCallId as string) ||
(processingInput?.tool_call_id as string) ||
(processingInput?.toolCallId as string) ||
(toolCall?.toolCallId as string) ||
(toolCall?.tool_call_id as string);
const executeId =
(eventData.executeId as string) ||
(processingResult?.executeId as string) ||
undefined;
if ((!intervention?.id && !sessionId) || !toolCall) {
return null;
}
const interventionId =
intervention?.id ||
(requestMeta?.nuwaclaw_intervention_id as string) ||
`itv_${sessionId}_${toolCallId}`;
const interactions = currentMessage.acpPermissionInteractions || [];
if (interactions.some((item) => item.intervention.id === interventionId)) {
return null;
}
const normalizedIntervention: AcpPermissionInterventionRequest =
intervention || {
id: interventionId,
revision: (requestMeta?.nuwaclaw_revision as number) || 1,
kind: 'approval',
status: 'pending',
sessionId: sessionId,
source: 'acp_permission',
engine: normalizeAcpEngine(eventData._engine),
protocol: 'acp',
callbackTarget: { kind: 'electron', targetId: '' },
schemaRef: '',
acp: {
method: 'session/request_permission',
request: {
sessionId: sessionId,
toolCall: {
toolCallId: toolCallId,
kind: toolCall?.kind as string,
title: toolCall?.title as string,
rawInput: (toolCall?.rawInput ?? toolCall?.raw_input) as unknown,
status: toolCall?.status as string,
locations: toolCall?.locations as Array<{
path: string;
line?: number | null;
}> | null,
},
options: (options || []).map((o) => ({
optionId: (o.optionId ?? o.option_id) as string,
kind: o.kind as string,
name: o.name as string,
})),
},
},
createdAt: (processingResult?.startTime as number) || Date.now(),
};
return {
...currentMessage,
acpPermissionInteractions: [
...interactions,
{
intervention: normalizedIntervention,
executeId,
responseStatus: 'pending',
triggeredAt: createInterventionTriggeredAt(),
},
],
status: MessageStatusEnum.Loading,
};
}

View File

@@ -0,0 +1,511 @@
import { ConversationEventTypeEnum } from '@/types/enums/agent';
import { MessageStatusEnum } from '@/types/enums/common';
import { describe, expect, it } from 'vitest';
import { applyMcpAskToolCallSseEvent } from './applyMcpAskToolCallSseEvent';
const baseAskInput = {
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'ask-1',
revision: 1,
sessionId: 'session-1',
title: 'Need input',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'inline',
title: 'Need input',
fields: [{ name: 'choice', title: '选项', widget: 'text' }],
},
};
describe('applyMcpAskToolCallSseEvent', () => {
it('accepts backend PROCESSING tool calls for nuwax_ask_question', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
executeId: 'tool-call-1',
result: {
executeId: 'tool-call-1',
input: {
...baseAskInput,
toolName: 'nuwax_ask_question',
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-1');
expect(patched?.mcpAskInteractions?.[0]?.input.toolName).toBe(
'nuwax_ask_question',
);
});
it('forces the message back to loading for pending ask interactions', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
executeId: 'ask-loading',
result: {
executeId: 'ask-loading',
input: {
...baseAskInput,
requestId: 'ask-loading',
toolName: 'nuwax_ask_question',
},
},
},
} as any,
{ id: 'msg-ask-loading', status: MessageStatusEnum.Complete } as any,
);
expect(patched?.status).toBe(MessageStatusEnum.Loading);
});
it('defaults missing toolName to nuwax_ask_question', () => {
const patched = applyMcpAskToolCallSseEvent(
{
messageType: 'agentSessionUpdate',
subType: 'tool_call',
data: {
toolCallId: 'tool-call-2',
rawInput: baseAskInput,
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.input.toolName).toBe(
'nuwax_ask_question',
);
});
it('accepts backend-wrapped tool_call progress events', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.MESSAGE,
data: {
messageType: 'agentSessionUpdate',
subType: 'tool_call',
data: {
tool_call_id: 'tool-call-3',
raw_input: {
...baseAskInput,
requestId: 'ask-3',
toolName: 'nuwax_ask_question',
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-3');
expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-3');
});
it('accepts tool_call_update in-progress events with MCP Ask raw input', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.MESSAGE,
data: {
messageType: 'agentSessionUpdate',
subType: 'tool_call_update',
data: {
toolCallId: 'tool-call-4',
status: 'in_progress',
rawInput: {
...baseAskInput,
requestId: 'ask-4',
toolName: 'nuwax_ask_question',
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-4');
expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-4');
});
it('keeps an active MCP Ask interaction when the MCP call completes pending user input', () => {
const currentMessage = {
id: 'msg-1',
mcpAskInteractions: [
{
input: {
...baseAskInput,
requestId: 'ask-5',
toolName: 'nuwax_ask_question',
},
toolCallId: 'tool-call-5',
responseStatus: 'pending',
},
],
};
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.MESSAGE,
data: {
messageType: 'agentSessionUpdate',
subType: 'tool_call_update',
data: {
toolCallId: 'tool-call-5',
status: 'completed',
rawInput: {
...baseAskInput,
requestId: 'ask-5',
toolName: 'nuwax_ask_question',
},
},
},
} as any,
currentMessage as any,
);
expect(patched).toBeNull();
});
it('creates an MCP Ask card from a completed tool_call_update if that is the first event received', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.MESSAGE,
data: {
messageType: 'agentSessionUpdate',
subType: 'tool_call_update',
data: {
toolCallId: 'tool-call-6',
status: 'completed',
rawInput: {
...baseAskInput,
requestId: 'ask-6',
toolName: 'nuwax_ask_question',
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-6');
expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-6');
});
it('accepts rawInput nested under data.ext', () => {
const patched = applyMcpAskToolCallSseEvent(
{
messageType: 'agentSessionUpdate',
subType: 'tool_call',
data: {
toolCallId: 'tool-call-7',
ext: {
rawInput: {
...baseAskInput,
requestId: 'ask-7',
toolName: 'nuwax_ask_question',
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe('tool-call-7');
expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-7');
});
it('accepts agent input missing schemaVersion when ui.version is present', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
name: 'ask-question__nuwax_ask_question',
type: 'ToolCall',
status: 'EXECUTING',
result: {
executeId: 'call-775',
input: {
requestId: 'weather-plan-confirm-5',
revision: 1,
sessionId: 'weather-dev',
title: '确认方案',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'modal',
title: '确认方案',
fields: [{ name: 'confirm', title: '确认', widget: 'text' }],
},
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.input.schemaVersion).toBe(
'nuwax.mcp_ask.v2',
);
expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe(
'weather-plan-confirm-5',
);
});
it('prefers canonical input from result.data structuredContent over agent result.input', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
name: 'ask-question__nuwax_ask_question',
type: 'ToolCall',
status: 'FINISHED',
result: {
executeId: 'call-775',
input: {
requestId: 'weather-plan-confirm-5',
revision: 1,
sessionId: 'weather-dev',
title: 'agent title',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'modal',
title: 'agent title',
fields: [{ name: 'confirm', title: '确认', widget: 'text' }],
},
},
data: [
{
type: 'content',
content: {
type: 'text',
text: JSON.stringify({
status: 'pending',
requestId: 'weather-plan-confirm-5',
revision: 1,
message: 'presented',
input: {
toolName: 'nuwax_ask_question',
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'weather-plan-confirm-5',
revision: 1,
sessionId: 'weather-dev',
title: 'canonical title',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'modal',
title: 'canonical title',
fields: [
{ name: 'confirm', title: '确认', widget: 'text' },
],
},
},
}),
},
},
],
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.input.title).toBe(
'canonical title',
);
expect(patched?.mcpAskInteractions?.[0]?.input.toolName).toBe(
'nuwax_ask_question',
);
});
it('accepts Backend.Sandbox.Event.AskQuestion ASK_QUESTION PROCESSING event', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
targetId: -1,
name: 'Backend.Sandbox.Event.AskQuestion',
type: 'Event',
status: 'FINISHED',
subEventType: 'ASK_QUESTION',
result: {
executeId: 'call_272edddbb5e140128d146826',
input: {
requestId: 'demo_form_1',
sessionId: 'demo_session_1',
title: 'nuwax_ask_question 演示',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'inline',
title: '演示表单',
fields: [
{
name: 'favorite_color',
title: '最喜欢的颜色',
widget: 'radio',
options: [{ value: 'red', label: '红色' }],
},
],
},
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe(
'call_272edddbb5e140128d146826',
);
expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe(
'demo_form_1',
);
});
it('accepts bare v2 input with no schemaVersion/ui.version (agent omits stamp fields)', () => {
const patched = applyMcpAskToolCallSseEvent(
{
messageType: 'agentSessionUpdate',
subType: 'tool_call',
data: {
toolCallId: 'tool-call-bare-v2',
rawInput: {
requestId: 'ask-bare',
revision: 1,
sessionId: 'sess-bare',
title: 'Bare v2',
// 无 schemaVersion、无 ui.version(agent 按"服务端盖戳"约定省略)
ui: {
presentation: 'inline',
title: 'Bare v2',
fields: [
{
name: 'choice',
title: '选项',
widget: 'radio',
options: [
{ value: 'a', label: '甲' },
{ value: 'b', label: '乙' },
],
},
],
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe(
'tool-call-bare-v2',
);
expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe('ask-bare');
expect(patched?.mcpAskInteractions?.[0]?.input.schemaVersion).toBe(
'nuwax.mcp_ask.v2',
);
});
it('accepts AgentFlow ASK_QUESTION payload with result.data and nuwax.mcp_ask.v1', () => {
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
name: 'AskQuestion',
type: 'Event',
status: 'EXECUTING',
subEventType: 'ASK_QUESTION',
result: {
executeId: '733e52ee48a8406b8f148de386092f47',
data: {
schemaVersion: 'nuwax.mcp_ask.v1',
requestId: '13f030d0c07547fe83fd6d43b624f0e0',
title: '补充回复',
revision: 1,
ui: {
presentation: 'inline',
title: '请选择\n\n',
fields: [
{ widget: 'text', name: 'name', title: '你的名字' },
{
widget: 'radio',
name: 'ddw',
title: '单选',
options: [
{ label: '1', value: '1' },
{ label: '2', value: '2' },
],
},
],
},
},
},
},
} as any,
{ id: 'msg-1' } as any,
);
expect(patched?.mcpAskInteractions?.[0]?.toolCallId).toBe(
'13f030d0c07547fe83fd6d43b624f0e0',
);
expect(patched?.mcpAskInteractions?.[0]?.executeId).toBe(
'733e52ee48a8406b8f148de386092f47',
);
expect(patched?.mcpAskInteractions?.[0]?.input.requestId).toBe(
'13f030d0c07547fe83fd6d43b624f0e0',
);
expect(patched?.mcpAskInteractions?.[0]?.input.schemaVersion).toBe(
'nuwax.mcp_ask.v2',
);
});
it('creates a second ask interaction when requestId differs', () => {
const currentMessage = {
id: 'msg-1',
mcpAskInteractions: [
{
input: {
...baseAskInput,
requestId: '13f030d0c07547fe83fd6d43b624f0e0',
title: '补充回复',
toolName: 'nuwax_ask_question',
},
toolCallId: '13f030d0c07547fe83fd6d43b624f0e0',
executeId: '733e52ee48a8406b8f148de386092f47',
responseStatus: 'submitted',
},
],
};
const patched = applyMcpAskToolCallSseEvent(
{
eventType: ConversationEventTypeEnum.PROCESSING,
data: {
subEventType: 'ASK_QUESTION',
result: {
executeId: '449db35f985747bf8cd1645627bc2d8c',
data: {
schemaVersion: 'nuwax.mcp_ask.v1',
requestId: 'ed325c9eec724bce95ca6a05974b42d6',
title: '补充回复',
revision: 1,
ui: {
presentation: 'inline',
title: '请选择\n\n',
fields: [{ widget: 'text', name: 'name', title: '你的名字' }],
},
},
},
},
} as any,
currentMessage as any,
);
expect(patched?.mcpAskInteractions).toHaveLength(2);
expect(patched?.mcpAskInteractions?.[1]?.input.requestId).toBe(
'ed325c9eec724bce95ca6a05974b42d6',
);
expect(patched?.mcpAskInteractions?.[1]?.responseStatus).toBe('pending');
});
});

View File

@@ -0,0 +1,144 @@
import { ConversationEventTypeEnum } from '@/types/enums/agent';
import { MessageStatusEnum } from '@/types/enums/common';
import type {
ConversationChatResponse,
MessageInfo,
} from '@/types/interfaces/conversationInfo';
import { extractMcpAskStructuredInputFromResult } from './extractMcpAskStructuredInput';
import { createInterventionTriggeredAt } from './interventionTrigger';
import { parseMcpAskToolInput } from './parseMcpAskToolInput';
import {
extractEventData,
parseSseEventEnvelope,
} from './parseSseEventEnvelope';
function readRawInput(
eventData: Record<string, unknown>,
result?: Record<string, unknown>,
) {
const ext =
eventData.ext && typeof eventData.ext === 'object'
? (eventData.ext as Record<string, unknown>)
: {};
// 优先选择非空对象,避免后端把 `result.input` 设为 `{}` 时误匹配空对象,
// 导致 `parseMcpAskToolInput` 解析失败。
const nonEmpty = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' && Object.keys(value).length > 0
? (value as Record<string, unknown>)
: undefined;
return (
nonEmpty(extractMcpAskStructuredInputFromResult(result)) ??
nonEmpty(eventData.raw_input) ??
nonEmpty(eventData.rawInput) ??
nonEmpty(ext.raw_input) ??
nonEmpty(ext.rawInput) ??
nonEmpty(result?.input) ??
nonEmpty((result?.ext as Record<string, unknown> | undefined)?.raw_input) ??
nonEmpty((result?.ext as Record<string, unknown> | undefined)?.rawInput) ??
(result?.input as Record<string, unknown> | undefined)
);
}
export function applyMcpAskToolCallSseEvent(
res: ConversationChatResponse,
currentMessage: MessageInfo,
): MessageInfo | null {
const envelope = parseSseEventEnvelope(res);
const eventData = extractEventData(envelope, res);
const subType = envelope.subType ?? envelope.sub_type;
const isToolCallLikeSubType =
subType === 'tool_call' || subType === 'tool_call_update';
const isToolCallEvent =
(envelope.message_type === 'tool_call' ||
envelope.messageType === 'tool_call' ||
isToolCallLikeSubType) &&
!!(
eventData.tool_call_id ||
eventData.toolCallId ||
eventData.raw_input ||
eventData.rawInput ||
((eventData.ext as Record<string, unknown> | undefined)?.raw_input ??
(eventData.ext as Record<string, unknown> | undefined)?.rawInput)
);
const isProcessingToolCallEvent =
res.eventType === ConversationEventTypeEnum.PROCESSING &&
!!(
eventData.executeId ||
(eventData.result as Record<string, unknown> | undefined)?.executeId ||
(eventData.result as Record<string, unknown> | undefined)?.input
);
// 识别 subEventType=ASK_QUESTION 的 PROCESSING 事件。
// 此类事件的 result.executeId 和 result.input 均为 null
// MCP Ask 数据在 result.data 中(含 schemaVersion、ui、requestId
const isAskQuestionEvent =
res.eventType === ConversationEventTypeEnum.PROCESSING &&
envelope.subEventType === 'ASK_QUESTION';
if (!isToolCallEvent && !isProcessingToolCallEvent && !isAskQuestionEvent) {
return null;
}
const result = eventData.result as Record<string, unknown> | undefined;
// ASK_QUESTION 事件MCP Ask 数据直接在 result.data 中,
// 不遵循 ToolCall 的 result.input 结构。
let rawInput: Record<string, unknown> | undefined;
let toolCallId: string | undefined;
if (isAskQuestionEvent && result) {
const resultData = result.data as Record<string, unknown> | undefined;
if (resultData && typeof resultData === 'object') {
rawInput = resultData;
toolCallId =
(resultData.requestId as string) ||
(eventData.executeId as string) ||
(result.executeId as string);
}
}
if (!rawInput) {
rawInput = readRawInput(eventData, result);
}
if (!toolCallId) {
toolCallId =
(eventData.tool_call_id as string) ||
(eventData.toolCallId as string) ||
(eventData.executeId as string) ||
(result?.executeId as string);
}
const executeId =
(eventData.executeId as string) ||
(result?.executeId as string) ||
undefined;
const mcpAskInput = parseMcpAskToolInput(rawInput);
if (!mcpAskInput || !toolCallId) {
return null;
}
const interactions = currentMessage.mcpAskInteractions || [];
if (
interactions.some((item) => item.input.requestId === mcpAskInput.requestId)
) {
return null;
}
return {
...currentMessage,
mcpAskInteractions: [
...interactions,
{
input: mcpAskInput,
toolCallId,
executeId,
responseStatus: 'pending',
triggeredAt: createInterventionTriggeredAt(),
},
],
status: MessageStatusEnum.Loading,
};
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { extractMcpAskStructuredInputFromResult } from './extractMcpAskStructuredInput';
describe('extractMcpAskStructuredInputFromResult', () => {
it('extracts canonical input from MCP structuredContent text block', () => {
const input = extractMcpAskStructuredInputFromResult({
data: [
{
type: 'content',
content: {
type: 'text',
text: JSON.stringify({
status: 'pending',
requestId: 'weather-plan-confirm-5',
revision: 1,
message: 'presented',
input: {
toolName: 'nuwax_ask_question',
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'weather-plan-confirm-5',
revision: 1,
sessionId: 'weather-dev',
title: '确认方案',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'modal',
title: '确认方案',
fields: [],
},
},
}),
},
},
],
});
expect(input?.schemaVersion).toBe('nuwax.mcp_ask.v2');
expect(input?.requestId).toBe('weather-plan-confirm-5');
});
});

View File

@@ -0,0 +1,35 @@
/**
* 从 PROCESSING ToolCall 的 result.data 文本块中提取 MCP structuredContent.input。
* MCP 3.3.2+ 在工具返回 JSON 中附带完整 rawInput平台 SSE 的 result.input 可能仍是 agent 原始参数。
*/
export function extractMcpAskStructuredInputFromResult(
result?: Record<string, unknown>,
): Record<string, unknown> | undefined {
const data = result?.data;
if (!Array.isArray(data)) {
return undefined;
}
for (const item of data) {
if (!item || typeof item !== 'object') {
continue;
}
const entry = item as Record<string, unknown>;
const content = entry.content as Record<string, unknown> | undefined;
if (content?.type !== 'text' || typeof content.text !== 'string') {
continue;
}
try {
const parsed = JSON.parse(content.text) as Record<string, unknown>;
const input = parsed.input;
if (input && typeof input === 'object' && Object.keys(input).length > 0) {
return input as Record<string, unknown>;
}
} catch {
// 非 JSON 文本,跳过
}
}
return undefined;
}

View File

@@ -0,0 +1,5 @@
let triggerSequence = 0;
export function createInterventionTriggeredAt(): number {
return Date.now() + (triggerSequence += 1);
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
isMcpAskFailedComponent,
resolveMcpAskHydratedResponseStatus,
} from './mcpAskExecutedComponent';
describe('mcpAskExecutedComponent', () => {
it('treats SUCCESS components as pending是否已回答由后续 resume 消息决定)', () => {
// component status(SUCCESS/FINISHED)只代表「问」完成,不代表用户已回答;
// hydrate 默认 pending(交给 reconcile 按 resume 消息判 submitted),让历史最后一条 ASK_QUESTION 能恢复渲染
expect(
resolveMcpAskHydratedResponseStatus({
status: 'SUCCESS',
result: { success: true },
}),
).toBe('pending');
});
it('treats EXECUTING components as pending', () => {
expect(
resolveMcpAskHydratedResponseStatus({
status: 'EXECUTING',
}),
).toBe('pending');
});
it('treats FAILED components as failed', () => {
expect(
isMcpAskFailedComponent({
status: 'FAILED',
result: { success: false },
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,64 @@
import type { McpAskInteraction } from '../types/mcpAskIntervention';
/** 后端持久化的 componentExecutedList 条目(结构因版本略有差异) */
export type McpAskExecutedComponent = {
status?: string;
success?: boolean;
executeId?: string;
toolCallId?: string;
input?: unknown;
result?: {
status?: string;
success?: boolean;
executeId?: string;
toolCallId?: string;
input?: unknown;
};
};
export function getMcpAskComponentInput(
component: McpAskExecutedComponent,
): unknown {
return component.input ?? component.result?.input;
}
export function getMcpAskComponentToolCallId(
component: McpAskExecutedComponent,
): string | undefined {
const id =
component.executeId ??
component.result?.executeId ??
component.toolCallId ??
component.result?.toolCallId;
return typeof id === 'string' && id ? id : undefined;
}
function readComponentStatus(component: McpAskExecutedComponent): string {
return String(component.status ?? component.result?.status ?? '')
.trim()
.toLowerCase();
}
export function isMcpAskFailedComponent(
component: McpAskExecutedComponent,
): boolean {
const status = readComponentStatus(component);
return (
status === 'failed' ||
status === 'error' ||
component.success === false ||
component.result?.success === false
);
}
export function resolveMcpAskHydratedResponseStatus(
component: McpAskExecutedComponent,
): McpAskInteraction['responseStatus'] {
if (isMcpAskFailedComponent(component)) {
return 'failed';
}
// ASK_QUESTION 的 component status(FINISHED/EXECUTING/SUCCESS)只代表「问」这一步完成,
// 不代表用户已回答——回答由后续 resume 用户消息标志(reconcileMcpAskHydratedStatus 据此判 submitted)。
// 默认 pending,让历史最后一条 ASK_QUESTION 能恢复渲染 dockpanel;reconcile 检测到 resume 才置 submitted(关闭)。
return 'pending';
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import {
hydrateMcpAskInteractionsFromExecutedComponents,
hydrateMcpAskInteractionsInMessageList,
prependAndHydrateMcpAskMessageList,
} from './mcpAskHydrateMessage';
const askInput = {
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'ask-history-1',
revision: 1,
sessionId: 'session-1',
title: '历史表单',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'inline',
title: '历史表单',
fields: [{ name: 'topic', title: '主题', widget: 'text', required: true }],
},
};
describe('hydrateMcpAskInteractionsFromExecutedComponents', () => {
it('hydrates successful persisted tool calls as submitted', () => {
const message = hydrateMcpAskInteractionsFromExecutedComponents({
id: 'msg-1',
componentExecutedList: [
{
status: 'SUCCESS',
result: {
success: true,
executeId: 'call-1',
},
input: {
...askInput,
toolName: 'nuwax_ask_question',
},
},
],
} as any);
expect(message.mcpAskInteractions).toHaveLength(1);
expect(message.mcpAskInteractions?.[0].responseStatus).toBe('submitted');
});
it('does not hydrate failed timed-out ask/question calls', () => {
const message = hydrateMcpAskInteractionsFromExecutedComponents({
id: 'msg-1',
componentExecutedList: [
{
status: 'FAILED',
result: {
success: false,
executeId: 'call-1',
},
input: {
...askInput,
toolName: 'nuwax_ask_question',
},
},
],
} as any);
expect(message.mcpAskInteractions).toBeUndefined();
});
});
describe('hydrateMcpAskInteractionsInMessageList', () => {
it('resolves pending asks using resume messages from already loaded batches', () => {
const olderAskMessage = {
id: 'assistant-ask',
index: 1,
componentExecutedList: [
{
status: 'EXECUTING',
result: { executeId: 'call-1' },
input: { ...askInput, toolName: 'nuwax_ask_question' },
},
],
};
const newerResumeMessage = {
id: 'user-resume',
index: 2,
text: '我已填写「历史表单」,表单内容如下:\n\n主题AI',
};
const mergedContext = [olderAskMessage, newerResumeMessage] as any[];
const hydratedOlderBatch = hydrateMcpAskInteractionsInMessageList(
[olderAskMessage as any],
mergedContext,
);
expect(hydratedOlderBatch[0].mcpAskInteractions?.[0].responseStatus).toBe(
'submitted',
);
});
});
describe('prependAndHydrateMcpAskMessageList', () => {
it('hydrates prepended older messages against the merged context', () => {
const olderAskMessage = {
id: 'assistant-ask',
index: 1,
componentExecutedList: [
{
status: 'EXECUTING',
result: { executeId: 'call-1' },
input: { ...askInput, toolName: 'nuwax_ask_question' },
},
],
};
const currentMessageList = [
{
id: 'user-resume',
index: 2,
text: '我已填写「历史表单」,表单内容如下:\n\n主题AI',
},
] as any[];
const result = prependAndHydrateMcpAskMessageList(
[olderAskMessage as any],
currentMessageList,
);
expect(result).toHaveLength(2);
expect(result[0].mcpAskInteractions?.[0].responseStatus).toBe('submitted');
});
});

View File

@@ -0,0 +1,82 @@
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import type { McpAskInteraction } from '../types/mcpAskIntervention';
import { createInterventionTriggeredAt } from './interventionTrigger';
import {
getMcpAskComponentInput,
getMcpAskComponentToolCallId,
isMcpAskFailedComponent,
resolveMcpAskHydratedResponseStatus,
type McpAskExecutedComponent,
} from './mcpAskExecutedComponent';
import { parseMcpAskToolInput } from './parseMcpAskToolInput';
import { reconcileMcpAskHydratedMessageList } from './reconcileMcpAskHydratedStatus';
export function hydrateMcpAskInteractionsFromExecutedComponents(
message: MessageInfo,
): MessageInfo {
const componentExecutedList = (message.componentExecutedList ||
[]) as McpAskExecutedComponent[];
if (!componentExecutedList.length) {
return message;
}
const existing = message.mcpAskInteractions || [];
const existingRequestIds = new Set(
existing.map((interaction) => interaction.input.requestId),
);
const hydrated: McpAskInteraction[] = [];
componentExecutedList.forEach((component) => {
if (isMcpAskFailedComponent(component)) {
return;
}
const input = parseMcpAskToolInput(getMcpAskComponentInput(component));
const toolCallId = getMcpAskComponentToolCallId(component);
if (!input || !toolCallId || existingRequestIds.has(input.requestId)) {
return;
}
existingRequestIds.add(input.requestId);
hydrated.push({
input,
toolCallId,
responseStatus: resolveMcpAskHydratedResponseStatus(component),
triggeredAt: createInterventionTriggeredAt(),
});
});
if (!hydrated.length) {
return message;
}
return {
...message,
mcpAskInteractions: [...existing, ...hydrated],
};
}
/**
* 从历史 componentExecutedList 重建 MCP Ask 交互,并结合完整会话上下文推断是否已回复。
*/
export function hydrateMcpAskInteractionsInMessageList(
messageList: MessageInfo[] = [],
contextMessageList: MessageInfo[] = messageList,
): MessageInfo[] {
const hydratedList = messageList.map((message) =>
hydrateMcpAskInteractionsFromExecutedComponents(message),
);
return reconcileMcpAskHydratedMessageList(hydratedList, contextMessageList);
}
/**
* 加载更多历史消息:前置合并后在完整上下文中 hydrate。
*/
export function prependAndHydrateMcpAskMessageList(
olderMessages: MessageInfo[],
currentMessageList: MessageInfo[],
): MessageInfo[] {
const merged = [...olderMessages, ...currentMessageList];
return hydrateMcpAskInteractionsInMessageList(merged, merged);
}

View File

@@ -0,0 +1,678 @@
import { AssistantRoleEnum } from '@/types/enums/agent';
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import {
buildMcpAskResumeMessage,
hasMcpAskResumeMessage,
isMcpAskResumeMessageForInteraction,
stripMcpAskResumeDisplayArtifacts,
} from './mcpAskResumeMessage';
import { describe, expect, it, vi } from 'vitest';
import type { McpAskInteraction } from '../types/mcpAskIntervention';
/** 测试用中文 i18n 字典,与 zh-CN 本地包 resume 相关 key 对齐 */
const zhCnResumeDict: Record<string, string> = {
'PC.Common.Global.yes': '是',
'PC.Common.Global.no': '否',
'PC.Components.McpAskQuestionCard.defaultTitle': '这次提问',
'PC.Components.McpAskQuestionCard.notFilled': '未填写',
'PC.Components.McpAskQuestionCard.unknownFile': '未知文件',
'PC.Components.McpAskQuestionCard.file': '文件',
'PC.Components.McpAskQuestionCard.emptyFormContent': '(无表单内容)',
'PC.Components.McpAskQuestionCard.resumeCancelled': '我取消了「{0}」。',
'PC.Components.McpAskQuestionCard.resumeSkipped': '我跳过了「{0}」。',
'PC.Components.McpAskQuestionCard.resumeTimeout':
'「{0}」已超时,没有收到表单答案。',
'PC.Components.McpAskQuestionCard.resumeSubmitted':
'我已填写「{0}」,表单内容如下:',
};
/** 测试用英文 i18n 字典 */
const enUsResumeDict: Record<string, string> = {
'PC.Common.Global.yes': 'Yes',
'PC.Common.Global.no': 'No',
'PC.Components.McpAskQuestionCard.defaultTitle': 'This question',
'PC.Components.McpAskQuestionCard.notFilled': 'Not provided',
'PC.Components.McpAskQuestionCard.unknownFile': 'Unknown file',
'PC.Components.McpAskQuestionCard.file': 'File',
'PC.Components.McpAskQuestionCard.emptyFormContent': '(No form content)',
'PC.Components.McpAskQuestionCard.resumeCancelled': 'I cancelled "{0}".',
'PC.Components.McpAskQuestionCard.resumeSkipped': 'I skipped "{0}".',
'PC.Components.McpAskQuestionCard.resumeTimeout':
'"{0}" timed out. No form answer was received.',
'PC.Components.McpAskQuestionCard.resumeSubmitted':
'I answered "{0}". Form details:',
};
let activeDict = zhCnResumeDict;
let activeLang = 'zh-CN';
vi.mock('@/services/i18nRuntime', () => ({
dict: (key: string, ...args: (string | number)[]) => {
const template = activeDict[key] ?? key;
return args.reduce(
(text, item, index) =>
text.replace(new RegExp(`\\{${index}\\}`, 'g'), String(item)),
template,
);
},
getCurrentLang: () => activeLang,
}));
const baseInteraction: McpAskInteraction = {
toolCallId: 'tc-1',
input: {
toolName: 'nuwax_ask_question',
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'ask-1',
revision: 1,
sessionId: 'session-1',
title: '请选择继续方式',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'inline',
title: '请选择继续方式',
fields: [
{
name: 'choice',
title: '选项',
widget: 'radio',
required: true,
options: [
{ value: 'deploy', label: '直接部署' },
{ value: 'test', label: '先跑测试' },
{ value: 'cancel', label: '取消任务' },
],
},
{ name: 'notes', title: '补充说明', widget: 'textarea' },
{
name: 'checks',
title: '检查项',
widget: 'checkboxes',
type: 'array',
options: [
{ value: 'lint', label: '代码检查' },
{ value: 'unit', label: '单元测试' },
],
},
],
},
},
};
describe('buildMcpAskResumeMessage', () => {
it('formats submitted answers as user-friendly label value lines', () => {
activeDict = zhCnResumeDict;
activeLang = 'zh-CN';
const message = buildMcpAskResumeMessage(baseInteraction, {
interventionId: 'ask-1',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: {
choice: 'test',
notes: '先跑关键链路',
checks: ['lint', 'unit'],
},
});
expect(message).toBe(
[
'我已填写「请选择继续方式」,表单内容如下:',
'选项:先跑测试',
'补充说明:先跑关键链路',
'检查项:代码检查、单元测试',
].join('\n'),
);
expect(message).not.toContain('nuwax-mcp-ask-request-id');
expect(message).not.toContain('"choice"');
expect(message).not.toContain('```json');
});
it('keeps unknown fields readable without JSON blocks', () => {
activeDict = zhCnResumeDict;
activeLang = 'zh-CN';
const message = buildMcpAskResumeMessage(baseInteraction, {
interventionId: 'ask-1',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: {
confirmed: true,
extra: { owner: 'alice', retry: 2 },
},
});
expect(message).toContain('confirmed是');
expect(message).toContain('extraowneraliceretry2');
expect(message).not.toContain('nuwax-mcp-ask-request-id');
});
it('uses custom radio input as the field value', () => {
activeDict = zhCnResumeDict;
activeLang = 'zh-CN';
const message = buildMcpAskResumeMessage(
{
...baseInteraction,
input: {
...baseInteraction.input,
ui: {
...baseInteraction.input.ui,
fields: [
{
name: 'choice',
title: '选项',
widget: 'radio-with-custom',
options: [
{ value: 'deploy', label: '直接部署' },
{ value: 'test', label: '先跑测试' },
],
otherField: 'choiceOther',
},
],
},
},
},
{
interventionId: 'ask-1',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: {
choice: '__custom__',
choiceOther: '先发灰度环境',
},
},
);
expect(message).toContain('选项:先发灰度环境');
expect(message).not.toContain('__custom__');
expect(message).not.toContain('choiceOther');
});
it('formats cancel, skip, and timeout as normal chat messages', () => {
activeDict = zhCnResumeDict;
activeLang = 'zh-CN';
const commonPayload = {
interventionId: 'ask-1',
revision: 1,
source: 'mcp_ask' as const,
protocol: 'mcp' as const,
};
expect(
buildMcpAskResumeMessage(baseInteraction, {
...commonPayload,
action: 'cancel',
}),
).toContain('我取消了「请选择继续方式」。');
expect(
buildMcpAskResumeMessage(baseInteraction, {
...commonPayload,
action: 'skip',
}),
).toContain('我跳过了「请选择继续方式」。');
expect(
buildMcpAskResumeMessage(baseInteraction, {
...commonPayload,
action: 'timeout',
}),
).toContain('「请选择继续方式」已超时,没有收到表单答案。');
expect(
buildMcpAskResumeMessage(baseInteraction, {
...commonPayload,
action: 'cancel',
}),
).not.toContain('nuwax-mcp-ask-request-id');
});
it('uses English templates when locale dict is English', () => {
activeDict = enUsResumeDict;
activeLang = 'en-US';
const message = buildMcpAskResumeMessage(baseInteraction, {
interventionId: 'ask-1',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: {
confirmed: true,
},
});
expect(message).toContain('I answered "请选择继续方式". Form details:');
expect(message).toContain('confirmed: Yes');
});
it('formats normalized file field URLs in submitted resume message', () => {
activeDict = zhCnResumeDict;
activeLang = 'zh-CN';
const message = buildMcpAskResumeMessage(
{
...baseInteraction,
input: {
...baseInteraction.input,
title: '提交问题截图',
ui: {
...baseInteraction.input.ui,
title: '提交问题截图',
fields: [
{ name: 'screenshot', title: '截图', widget: 'file' },
{
name: 'attachments',
title: '相关附件',
widget: 'file',
multiple: true,
},
],
},
},
},
{
interventionId: 'ask-1',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: {
screenshot: 'https://cdn.example.com/shot.png',
attachments: [
'https://cdn.example.com/a.pdf',
'https://cdn.example.com/b.pdf',
],
},
},
);
expect(message).toContain('截图shot.png');
expect(message).toContain('相关附件a.pdf、b.pdf');
});
it('prefers file name over remote URL for UploadFileInfo objects', () => {
activeDict = zhCnResumeDict;
activeLang = 'zh-CN';
const message = buildMcpAskResumeMessage(
{
...baseInteraction,
input: {
...baseInteraction.input,
ui: {
...baseInteraction.input.ui,
fields: [{ name: 'screenshot', title: '截图', widget: 'file' }],
},
},
},
{
interventionId: 'ask-1',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: {
screenshot: [
{
name: 'shot.png',
url: 'https://cdn.example.com/shot.png',
},
],
},
},
);
expect(message).toContain('截图shot.png');
});
it('does not append internal requestId marker to chat payload', () => {
activeDict = zhCnResumeDict;
activeLang = 'zh-CN';
const sent = buildMcpAskResumeMessage(
{
...baseInteraction,
input: {
...baseInteraction.input,
ui: {
...baseInteraction.input.ui,
fields: [{ name: 'screenshot', title: '文件上传', widget: 'file' }],
},
},
},
{
interventionId: 'ask-1',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: {
screenshot: 'https://cdn.example.com/a.png',
},
},
);
expect(sent).toContain('文件上传a.png');
expect(sent).not.toContain('nuwax-mcp-ask-request-id');
});
});
describe('stripMcpAskResumeDisplayArtifacts', () => {
it('removes HTML requestId marker from display text', () => {
const raw =
'我已填写「补充回复」,表单内容如下:\n文件上传https://cdn.example.com/shot.png\n<!--nuwax-mcp-ask-request-id:ask-1-->';
expect(stripMcpAskResumeDisplayArtifacts(raw)).toBe(
'我已填写「补充回复」,表单内容如下:\n文件上传https://cdn.example.com/shot.png',
);
});
});
describe('isMcpAskResumeMessageForInteraction', () => {
const createSharedTitleInteraction = (
requestId: string,
responseStatus?: McpAskInteraction['responseStatus'],
): McpAskInteraction => ({
...baseInteraction,
toolCallId: `tc-${requestId}`,
responseStatus,
input: {
...baseInteraction.input,
requestId,
title: '补充回复',
ui: {
...baseInteraction.input.ui,
title: '补充回复',
},
},
});
it('matches resume messages across supported locales and legacy Chinese text', () => {
activeDict = enUsResumeDict;
activeLang = 'en-US';
const title = '请选择继续方式';
expect(
isMcpAskResumeMessageForInteraction(
'我已填写「请选择继续方式」,表单内容如下:\n选项先跑测试',
baseInteraction,
),
).toBe(true);
expect(
isMcpAskResumeMessageForInteraction(
`I answered "${title}". Form details:\nchoice: test`,
baseInteraction,
),
).toBe(true);
expect(
isMcpAskResumeMessageForInteraction(
'我取消了「请选择继续方式」。',
baseInteraction,
),
).toBe(true);
expect(
isMcpAskResumeMessageForInteraction(
'unrelated user message',
baseInteraction,
),
).toBe(false);
});
it('matches resume by ordinal pairing when multiple asks share the same title', () => {
const sharedTitle = '补充回复';
const firstInteraction: McpAskInteraction = {
...baseInteraction,
input: {
...baseInteraction.input,
requestId: 'ask-first',
title: sharedTitle,
ui: {
...baseInteraction.input.ui,
title: sharedTitle,
},
},
};
const secondInteraction: McpAskInteraction = {
...firstInteraction,
input: {
...firstInteraction.input,
requestId: 'ask-second',
},
toolCallId: 'tc-2',
};
const firstResume = buildMcpAskResumeMessage(firstInteraction, {
interventionId: 'ask-first',
revision: 1,
source: 'mcp_ask',
protocol: 'mcp',
action: 'submit',
formData: { choice: 'test' },
});
const messageList = [
{
id: 'assistant-ask-1',
index: 0,
mcpAskInteractions: [firstInteraction],
} as MessageInfo,
{
id: 'user-resume',
index: 1,
role: AssistantRoleEnum.USER,
text: firstResume,
} as MessageInfo,
{
id: 'assistant-ask-2',
index: 2,
mcpAskInteractions: [secondInteraction],
} as MessageInfo,
];
expect(
isMcpAskResumeMessageForInteraction(firstResume, firstInteraction),
).toBe(true);
expect(
hasMcpAskResumeMessage(messageList, secondInteraction, {
containingMessageIndex: 2,
}),
).toBe(false);
expect(
hasMcpAskResumeMessage(messageList, firstInteraction, {
containingMessageIndex: 0,
}),
).toBe(true);
});
it('matches legacy resume before ask when message indexes are out of storage order', () => {
const messageList = [
{
id: 'user-resume',
index: 1,
text: '我已填写「请选择继续方式」,表单内容如下:\n选项先跑测试',
},
{
id: 'assistant-ask',
index: 2,
mcpAskInteractions: [baseInteraction],
},
] as MessageInfo[];
expect(
hasMcpAskResumeMessage(messageList, baseInteraction, {
containingMessageIndex: 1,
}),
).toBe(true);
});
it('still recognizes legacy fenced JSON requestId markers', () => {
const legacyResume =
'我已填写「请选择继续方式」,表单内容如下:\n\n```json\n{"nuwaxMcpAskRequestId":"ask-1"}\n```';
expect(
isMcpAskResumeMessageForInteraction(legacyResume, baseInteraction),
).toBe(true);
});
it('does not legacy-match a later ask when an earlier assistant message had the same title', () => {
const sharedTitle = '补充回复';
const firstInteraction: McpAskInteraction = {
...baseInteraction,
input: {
...baseInteraction.input,
requestId: 'ask-first',
title: sharedTitle,
ui: { ...baseInteraction.input.ui, title: sharedTitle },
},
};
const secondInteraction: McpAskInteraction = {
...firstInteraction,
input: { ...firstInteraction.input, requestId: 'ask-second' },
toolCallId: 'tc-2',
};
const messageList = [
{
id: 'assistant-ask-1',
index: 2,
mcpAskInteractions: [
{ ...firstInteraction, responseStatus: 'submitted' as const },
],
},
{
id: 'user-resume',
index: 3,
text: `我已填写「${sharedTitle}」,表单内容如下:\n选项test`,
},
{
id: 'assistant-ask-2',
index: 4,
mcpAskInteractions: [
{ ...secondInteraction, responseStatus: 'pending' as const },
],
},
] as MessageInfo[];
expect(
hasMcpAskResumeMessage(messageList, secondInteraction, {
containingMessageIndex: 2,
}),
).toBe(false);
expect(
hasMcpAskResumeMessage(messageList, firstInteraction, {
containingMessageIndex: 0,
}),
).toBe(true);
});
it('pairs a lone resume with the nearest preceding unresolved same-title ask', () => {
const firstInteraction = createSharedTitleInteraction('ask-first');
const secondInteraction = createSharedTitleInteraction('ask-second');
const resumeText = '我已填写「补充回复」,表单内容如下:\n你的名字alice';
const messageList = [
{
id: 'assistant-ask-1',
index: 0,
mcpAskInteractions: [firstInteraction],
},
{
id: 'assistant-ask-2',
index: 2,
mcpAskInteractions: [secondInteraction],
},
{
id: 'user-resume-2',
index: 3,
role: AssistantRoleEnum.USER,
text: resumeText,
},
] as MessageInfo[];
expect(
hasMcpAskResumeMessage(messageList, firstInteraction, {
containingMessageIndex: 0,
}),
).toBe(false);
expect(
hasMcpAskResumeMessage(messageList, secondInteraction, {
containingMessageIndex: 1,
}),
).toBe(true);
});
it('does not pair a foreign marker-era resume with a new same-title ask', () => {
const interaction = createSharedTitleInteraction('ask-new');
const messageList = [
{
id: 'user-old-resume',
index: 1,
role: AssistantRoleEnum.USER,
text: [
'我已填写「补充回复」,表单内容如下:',
'你的名字alice',
'<!--nuwax-mcp-ask-request-id:ask-old-->',
].join('\n'),
},
{
id: 'assistant-new-ask',
index: 5,
mcpAskInteractions: [interaction],
},
] as MessageInfo[];
expect(
hasMcpAskResumeMessage(messageList, interaction, {
containingMessageIndex: 1,
}),
).toBe(false);
});
it('pairs an ask and resume that share the same conversation index', () => {
const interaction = createSharedTitleInteraction('ask-tie');
const messageList = [
{
id: 'assistant-ask',
index: 7,
mcpAskInteractions: [interaction],
},
{
id: 'user-resume',
index: 7,
role: AssistantRoleEnum.USER,
text: '我已填写「补充回复」,表单内容如下:\n你的名字alice',
},
] as MessageInfo[];
expect(
hasMcpAskResumeMessage(messageList, interaction, {
containingMessageIndex: 0,
}),
).toBe(true);
});
it('prefers a resolved ask when same-title asks share one message', () => {
const firstInteraction = createSharedTitleInteraction(
'ask-first',
'submitted',
);
const secondInteraction = createSharedTitleInteraction(
'ask-second',
'pending',
);
const messageList = [
{
id: 'assistant-asks',
index: 1,
mcpAskInteractions: [firstInteraction, secondInteraction],
},
{
id: 'user-resume',
index: 2,
role: AssistantRoleEnum.USER,
text: '我已填写「补充回复」,表单内容如下:\n你的名字alice',
},
] as MessageInfo[];
expect(hasMcpAskResumeMessage(messageList, firstInteraction)).toBe(true);
expect(hasMcpAskResumeMessage(messageList, secondInteraction)).toBe(false);
});
});

View File

@@ -0,0 +1,691 @@
import {
MIN_EN_I18N_MAP,
MIN_JA_I18N_MAP,
MIN_ZH_HK_I18N_MAP,
MIN_ZH_I18N_MAP,
MIN_ZH_TW_I18N_MAP,
} from '@/constants/i18n.constants';
import { dict, getCurrentLang } from '@/services/i18nRuntime';
import { AssistantRoleEnum } from '@/types/enums/agent';
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import type {
McpAskInteraction,
McpAskRespondPayload,
} from '../types/mcpAskIntervention';
import { parseInteractionFields } from './parseMcpAskSchema';
/** MCP Ask resume 消息相关的 i18n key 前缀 */
const I18N_PREFIX = 'PC.Components.McpAskQuestionCard';
type McpAskResumeAction = 'submit' | 'cancel' | 'skip' | 'timeout';
const RESUME_MESSAGE_KEY_BY_ACTION: Record<
Exclude<McpAskResumeAction, 'submit'>,
string
> = {
cancel: `${I18N_PREFIX}.resumeCancelled`,
skip: `${I18N_PREFIX}.resumeSkipped`,
timeout: `${I18N_PREFIX}.resumeTimeout`,
};
const SUBMITTED_HEADER_KEY = `${I18N_PREFIX}.resumeSubmitted`;
/** resume 消息 JSON 块中的 requestId 键(历史 fenced JSON 格式,仍识别) */
export const MCP_ASK_REQUEST_ID_MARKER_KEY = 'nuwaxMcpAskRequestId';
/** HTML 注释标记前缀,对用户不可见 */
const MCP_ASK_REQUEST_ID_HTML_PREFIX = 'nuwax-mcp-ask-request-id:';
/** 匹配 resume 消息末尾附带的 HTML 注释 requestId 标记 */
const MCP_ASK_REQUEST_ID_HTML_COMMENT_RE =
/\n?<!--nuwax-mcp-ask-request-id:[^>]+-->/g;
export interface McpAskResumeMatchOptions {
/** 承载该 interaction 的消息在已排序列表中的下标 */
containingMessageIndex?: number;
}
/**
* 按会话 message.index 升序排列,与 intervention 队列保持一致。
*/
export function sortMessagesByConversationIndex(
messages: MessageInfo[],
): MessageInfo[] {
return [...(messages ?? [])].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
}
function readMessageOrdinal(message: MessageInfo, fallback = 0): number {
return message.index ?? fallback;
}
/**
* 生成附在 resume 消息末尾的 HTML 注释 requestId 标记(聊天 UI 不展示)。
*/
export function buildMcpAskRequestIdMarker(requestId: string): string {
return `\n<!--${MCP_ASK_REQUEST_ID_HTML_PREFIX}${requestId}-->`;
}
/**
* 移除 resume 消息中仅供内部匹配的 requestId 标记,供聊天 UI 展示与复制使用。
* 原始 message.text 仍保留标记,以便 hasMcpAskResumeMessage 等逻辑识别。
*/
export function stripMcpAskResumeDisplayArtifacts(
text: string | undefined,
): string {
if (!text) {
return '';
}
return text
.replace(MCP_ASK_REQUEST_ID_HTML_COMMENT_RE, '')
.replace(/\s+$/, '');
}
/** MCP Ask resume 表单展示用标点:英文 locale 用西式标点,其余语系用 CJK 标点 */
function getMcpAskDisplaySeparators(lang = getCurrentLang()) {
const isEnglish = lang.toLowerCase().startsWith('en');
if (isEnglish) {
return {
listSeparator: ', ',
objectEntrySeparator: '; ',
labelSeparator: ': ',
};
}
return {
listSeparator: '、',
objectEntrySeparator: '',
labelSeparator: '',
};
}
function extractFileNameFromUrl(url: string, fallback: string): string {
try {
const pathname = new URL(url).pathname;
return pathname.split('/').filter(Boolean).pop() || fallback;
} catch {
return url.split('/').filter(Boolean).pop() || fallback;
}
}
function messageHasForeignRequestIdMarker(text: string): boolean {
return (
text.includes(MCP_ASK_REQUEST_ID_HTML_PREFIX) ||
text.includes(MCP_ASK_REQUEST_ID_MARKER_KEY)
);
}
/**
* 判断文本是否包含指定 requestId 的 resume 标记HTML 注释或历史 JSON 块)。
*/
export function textContainsMcpAskRequestIdMarker(
text: string | undefined,
requestId: string,
): boolean {
if (!text || !requestId) {
return false;
}
if (text.includes(`<!--${MCP_ASK_REQUEST_ID_HTML_PREFIX}${requestId}-->`)) {
return true;
}
const compact = `"${MCP_ASK_REQUEST_ID_MARKER_KEY}":"${requestId}"`;
const spaced = `"${MCP_ASK_REQUEST_ID_MARKER_KEY}": "${requestId}"`;
return text.includes(compact) || text.includes(spaced);
}
/** 各语言本地包,用于跨语言匹配历史 resume 消息 */
const LOCAL_RESUME_MESSAGE_MAPS = [
MIN_ZH_I18N_MAP,
MIN_ZH_TW_I18N_MAP,
MIN_ZH_HK_I18N_MAP,
MIN_EN_I18N_MAP,
MIN_JA_I18N_MAP,
] as const;
/**
* 迁移期兜底:旧版硬编码中文 resume 消息首行片段。
* 用于识别切换语言前已发送的历史消息。
*/
const LEGACY_RESUME_MESSAGE_SNIPPETS_BY_ACTION: Record<
McpAskResumeAction,
(title: string) => string
> = {
submit: (title) => `我已填写「${title}`,
cancel: (title) => `我取消了「${title}`,
skip: (title) => `我跳过了「${title}`,
timeout: (title) => `${title}」已超时`,
};
/**
* 按模板与占位符生成文案(不依赖当前运行时语言)。
*/
function formatTemplate(template: string, values: (string | number)[]): string {
let text = template;
values.forEach((value, index) => {
text = text.replace(new RegExp(`\\{${index}\\}`, 'g'), String(value ?? ''));
});
return text;
}
/**
* 从指定语言包读取 resume 消息模板并格式化。
*/
function formatResumeMessageFromMap(
map: Record<string, string>,
key: string,
title: string,
): string | undefined {
const template = map[key];
if (!template?.trim()) {
return undefined;
}
return formatTemplate(template, [title]);
}
/**
* 收集某标题下所有语言的 resume 消息签名,供历史消息识别使用。
*/
function collectResumeMessageSignatures(
title: string,
action: McpAskResumeAction,
): string[] {
const key =
action === 'submit'
? SUBMITTED_HEADER_KEY
: RESUME_MESSAGE_KEY_BY_ACTION[action];
const signatures = new Set<string>();
LOCAL_RESUME_MESSAGE_MAPS.forEach((map) => {
const formatted = formatResumeMessageFromMap(map, key, title);
if (formatted) {
signatures.add(formatted);
// submit 消息可能有多行,仅用首行做 includes 匹配
if (action === 'submit') {
signatures.add(formatted.split('\n')[0]);
}
}
});
signatures.add(LEGACY_RESUME_MESSAGE_SNIPPETS_BY_ACTION[action](title));
return [...signatures];
}
function tMcpAsk(key: string, ...values: (string | number)[]): string {
return dict(key, ...values);
}
function formatFileFieldDisplayValue(value: unknown): string {
if (value === undefined || value === null || value === '') {
return tMcpAsk(`${I18N_PREFIX}.notFilled`);
}
const formatSingle = (item: unknown): string => {
if (typeof item === 'string') {
return /^https?:\/\//i.test(item)
? extractFileNameFromUrl(item, tMcpAsk(`${I18N_PREFIX}.unknownFile`))
: item;
}
if (typeof item === 'object' && item !== null) {
const file = item as {
name?: string;
fileName?: string;
url?: string;
fileUrl?: string;
};
return (
file.name ||
file.fileName ||
(file.url || file.fileUrl
? extractFileNameFromUrl(
file.url || file.fileUrl || '',
tMcpAsk(`${I18N_PREFIX}.unknownFile`),
)
: tMcpAsk(`${I18N_PREFIX}.unknownFile`))
);
}
return String(item);
};
const { listSeparator } = getMcpAskDisplaySeparators();
if (Array.isArray(value)) {
if (!value.length) {
return tMcpAsk(`${I18N_PREFIX}.notFilled`);
}
return value.map(formatSingle).join(listSeparator);
}
return formatSingle(value);
}
function stringifyDisplayValue(value: unknown): string {
if (value === undefined || value === null || value === '') {
return tMcpAsk(`${I18N_PREFIX}.notFilled`);
}
if (typeof value === 'boolean') {
return value
? tMcpAsk('PC.Common.Global.yes')
: tMcpAsk('PC.Common.Global.no');
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
const { listSeparator, objectEntrySeparator, labelSeparator } =
getMcpAskDisplaySeparators();
if (Array.isArray(value)) {
if (!value.length) {
return tMcpAsk(`${I18N_PREFIX}.notFilled`);
}
// 检查是否为文件上传数组 (UploadFileInfo[])
if (value.length > 0 && typeof value[0] === 'object' && value[0] !== null) {
const first = value[0] as any;
if (
'name' in first ||
'url' in first ||
'fileName' in first ||
'fileUrl' in first
) {
return formatFileFieldDisplayValue(value);
}
}
return value.map(stringifyDisplayValue).join(listSeparator);
}
if (typeof value === 'object') {
// 检查是否为单个文件对象
const obj = value as any;
if (obj.name || obj.fileName || obj.url || obj.fileUrl) {
return formatFileFieldDisplayValue(value);
}
const entries = Object.entries(value as Record<string, unknown>);
if (!entries.length) {
return tMcpAsk(`${I18N_PREFIX}.notFilled`);
}
return entries
.map(
([key, item]) =>
`${key}${labelSeparator}${stringifyDisplayValue(item)}`,
)
.join(objectEntrySeparator);
}
return String(value);
}
function formatFieldValue(
value: unknown,
enumValues: string[],
enumLabels: string[],
): string {
const labelByValue = new Map(
enumValues.map((item, index) => [item, enumLabels[index] ?? item]),
);
if (Array.isArray(value)) {
return stringifyDisplayValue(
value.map((item) =>
typeof item === 'string' ? labelByValue.get(item) ?? item : item,
),
);
}
if (typeof value === 'string') {
return labelByValue.get(value) ?? value;
}
return stringifyDisplayValue(value);
}
function formatAskFormData(
interaction: McpAskInteraction,
formData?: Record<string, unknown>,
) {
const emptyFormContent = tMcpAsk(`${I18N_PREFIX}.emptyFormContent`);
if (!formData || Object.keys(formData).length === 0) {
return emptyFormContent;
}
const { labelSeparator } = getMcpAskDisplaySeparators();
const fields = parseInteractionFields(interaction.input.ui);
const consumedKeys = new Set<string>();
const lines = fields
.filter((field) =>
Object.prototype.hasOwnProperty.call(formData, field.name),
)
.map((field) => {
consumedKeys.add(field.name);
const label = field.property.title || field.name;
const otherValue = field.options.otherValue ?? '__custom__';
const otherField = field.options.otherField ?? `${field.name}Custom`;
if (
field.widget === 'radio-with-custom' &&
formData[field.name] === otherValue &&
Object.prototype.hasOwnProperty.call(formData, otherField)
) {
consumedKeys.add(otherField);
return `${label}${labelSeparator}${stringifyDisplayValue(
formData[otherField],
)}`;
}
const rawValue = formData[field.name];
const value =
field.widget === 'file'
? formatFileFieldDisplayValue(rawValue)
: formatFieldValue(rawValue, field.enumValues, field.enumLabels);
return `${label}${labelSeparator}${value}`;
});
Object.entries(formData).forEach(([key, value]) => {
if (!consumedKeys.has(key)) {
lines.push(`${key}${labelSeparator}${stringifyDisplayValue(value)}`);
}
});
return lines.length ? lines.join('\n') : emptyFormContent;
}
export function getMcpAskResumeTitle(interaction: McpAskInteraction): string {
return (
interaction.input.title ||
interaction.input.ui.title ||
tMcpAsk(`${I18N_PREFIX}.defaultTitle`)
);
}
/** 存在其他同 title ask 时,标题兜底无法可靠判断 resume 归属。 */
function shouldBlockLegacyTitleMatch(
sortedMessages: MessageInfo[],
interaction: McpAskInteraction,
): boolean {
const title = getMcpAskResumeTitle(interaction);
return sortedMessages.some((message) =>
(message.mcpAskInteractions ?? []).some(
(item) =>
item.input.requestId !== interaction.input.requestId &&
getMcpAskResumeTitle(item) === title,
),
);
}
function resolveContainingMessageIndex(
sortedMessages: MessageInfo[],
interaction: McpAskInteraction,
explicitIndex?: number,
): number | undefined {
if (explicitIndex !== undefined && explicitIndex >= 0) {
return explicitIndex;
}
const autoIndex = sortedMessages.findIndex((message) =>
message.mcpAskInteractions?.some(
(item) => item.input.requestId === interaction.input.requestId,
),
);
return autoIndex >= 0 ? autoIndex : undefined;
}
export function buildMcpAskResumeMessage(
interaction: McpAskInteraction,
payload: McpAskRespondPayload,
) {
const title = getMcpAskResumeTitle(interaction);
if (payload.action === 'cancel') {
return tMcpAsk(RESUME_MESSAGE_KEY_BY_ACTION.cancel, title);
}
if (payload.action === 'skip') {
return tMcpAsk(RESUME_MESSAGE_KEY_BY_ACTION.skip, title);
}
if (payload.action === 'timeout') {
return tMcpAsk(RESUME_MESSAGE_KEY_BY_ACTION.timeout, title);
}
return [
tMcpAsk(SUBMITTED_HEADER_KEY, title),
formatAskFormData(interaction, payload.formData),
].join('\n');
}
function messageMatchesAnyResumeSignature(
text: string | undefined,
title: string,
): boolean {
if (!text?.trim()) {
return false;
}
const actions: McpAskResumeAction[] = ['submit', 'cancel', 'skip', 'timeout'];
return actions.some((action) =>
collectResumeMessageSignatures(title, action).some((signature) =>
text.includes(signature),
),
);
}
function collectSameTitleAskOrdinals(
sortedMessages: MessageInfo[],
title: string,
): Array<{
ordinal: number;
sequence: number;
requestId: string;
resolved: boolean;
}> {
const entries: Array<{
ordinal: number;
sequence: number;
requestId: string;
resolved: boolean;
}> = [];
let sequence = 0;
sortedMessages.forEach((message, messageIndex) => {
const ordinal = readMessageOrdinal(message, messageIndex);
message.mcpAskInteractions?.forEach((item) => {
if (getMcpAskResumeTitle(item) !== title) {
return;
}
const responseStatus = item.responseStatus ?? 'pending';
entries.push({
ordinal,
sequence: sequence++,
requestId: item.input.requestId,
resolved:
responseStatus !== 'pending' && responseStatus !== 'submitting',
});
});
});
return entries.sort(
(left, right) =>
left.ordinal - right.ordinal || left.sequence - right.sequence,
);
}
function collectSameTitleResumeOrdinals(
sortedMessages: MessageInfo[],
title: string,
): Array<{ ordinal: number; sequence: number }> {
const entries: Array<{ ordinal: number; sequence: number }> = [];
sortedMessages.forEach((message, messageIndex) => {
if (message.role !== undefined && message.role !== AssistantRoleEnum.USER) {
return;
}
const text = message.text;
if (
!text ||
messageHasForeignRequestIdMarker(text) ||
!messageMatchesAnyResumeSignature(text, title)
) {
return;
}
entries.push({
ordinal: readMessageOrdinal(message, messageIndex),
sequence: messageIndex,
});
});
return entries.sort(
(left, right) =>
left.ordinal - right.ordinal || left.sequence - right.sequence,
);
}
/**
* 同 title 多次询问时,将无 marker 的 resume 配给它之前最可信的 ask
* 已有终态的 ask 优先,否则取位置最近的未配对 ask。
* 不依赖写入 chat 接口的 HTML 注释 marker。
*/
function hasOrdinalPairedResumeMessage(
sortedMessages: MessageInfo[],
interaction: McpAskInteraction,
): boolean {
const title = getMcpAskResumeTitle(interaction);
const asks = collectSameTitleAskOrdinals(sortedMessages, title);
const resumes = collectSameTitleResumeOrdinals(sortedMessages, title);
const targetAsk = asks.find(
(entry) => entry.requestId === interaction.input.requestId,
);
if (!targetAsk) {
return false;
}
const pairedRequestIds = new Set<string>();
resumes.forEach((resume) => {
const candidates = asks.filter(
(ask) =>
!pairedRequestIds.has(ask.requestId) &&
(ask.ordinal < resume.ordinal ||
(ask.ordinal === resume.ordinal && ask.sequence <= resume.sequence)),
);
if (!candidates.length) {
return;
}
const resolvedCandidate = candidates.find((ask) => ask.resolved);
const selectedAsk = resolvedCandidate ?? candidates[candidates.length - 1];
pairedRequestIds.add(selectedAsk.requestId);
});
if (pairedRequestIds.has(targetAsk.requestId)) {
return true;
}
// 历史 hydrate单条无 marker resume 的 index 可能小于 ask存储乱序
return (
asks.length === 1 &&
resumes.length === 1 &&
resumes[0].ordinal < targetAsk.ordinal
);
}
function matchesLegacyTitleResumeMessage(
text: string,
interaction: McpAskInteraction,
): boolean {
// 带其他 requestId 标记的 resume 不应被同 title 的后续 ask 误匹配
if (
messageHasForeignRequestIdMarker(text) &&
!textContainsMcpAskRequestIdMarker(text, interaction.input.requestId)
) {
return false;
}
const title = getMcpAskResumeTitle(interaction);
const actions: McpAskResumeAction[] = ['submit', 'cancel', 'skip', 'timeout'];
return actions.some((action) =>
collectResumeMessageSignatures(title, action).some((signature) =>
text.includes(signature),
),
);
}
export function isMcpAskResumeMessageForInteraction(
text: string | undefined,
interaction: McpAskInteraction,
options?: { legacyTitleMatch?: boolean },
): boolean {
if (!text) {
return false;
}
if (textContainsMcpAskRequestIdMarker(text, interaction.input.requestId)) {
return true;
}
if (options?.legacyTitleMatch === false) {
return false;
}
return matchesLegacyTitleResumeMessage(text, interaction);
}
export function hasMcpAskResumeMessage(
messages: MessageInfo[],
interaction: McpAskInteraction,
options?: McpAskResumeMatchOptions,
): boolean {
const sortedMessages = sortMessagesByConversationIndex(messages);
const containingMessageIndex = resolveContainingMessageIndex(
sortedMessages,
interaction,
options?.containingMessageIndex,
);
const containingMessage =
containingMessageIndex !== undefined
? sortedMessages[containingMessageIndex]
: undefined;
const containingOrdinal = containingMessage
? readMessageOrdinal(containingMessage, containingMessageIndex ?? 0)
: undefined;
if (
sortedMessages.some((message) =>
textContainsMcpAskRequestIdMarker(
message.text,
interaction.input.requestId,
),
)
) {
return true;
}
if (hasOrdinalPairedResumeMessage(sortedMessages, interaction)) {
return true;
}
if (shouldBlockLegacyTitleMatch(sortedMessages, interaction)) {
return false;
}
const matchesLegacy = (text: string | undefined) =>
text ? matchesLegacyTitleResumeMessage(text, interaction) : false;
if (containingOrdinal !== undefined) {
const afterMessages = sortedMessages.filter(
(message) => readMessageOrdinal(message, 0) > containingOrdinal,
);
if (afterMessages.some((message) => matchesLegacy(message.text))) {
return true;
}
// 历史 hydrateresume 的 index 可能小于 ask存储乱序仅匹配无 marker 的旧消息
const beforeMessages = sortedMessages.filter(
(message) =>
readMessageOrdinal(message, 0) < containingOrdinal &&
!messageHasForeignRequestIdMarker(message.text ?? ''),
);
return beforeMessages.some((message) => matchesLegacy(message.text));
}
return sortedMessages.some((message) => matchesLegacy(message.text));
}
export function hasLaterMcpAskResumeMessage(
messages: MessageInfo[],
messageIndex: number,
interaction: McpAskInteraction,
): boolean {
return hasMcpAskResumeMessage(messages, interaction, {
containingMessageIndex: messageIndex,
});
}

View File

@@ -0,0 +1,310 @@
import { UploadFileStatus } from '@/types/enums/common';
import type { UploadFileInfo } from '@/types/interfaces/common';
import { describe, expect, it } from 'vitest';
import type { InteractionUiSchema } from '../types/mcpAskIntervention';
import {
extractMcpAskFormAttachments,
extractUploadedFileUrls,
hydrateMcpAskFileFieldValue,
hydrateMcpAskFormValues,
limitMcpAskUploadFileList,
normalizeMcpAskFileFieldValue,
normalizeMcpAskFormData,
urlToUploadFileInfo,
validateMcpAskRequiredFileField,
} from './normalizeMcpAskFormData';
const fileUi = (
fields: InteractionUiSchema['fields'],
): InteractionUiSchema => ({
version: 'nuwax.interaction.v2',
presentation: 'inline',
title: '上传',
fields,
});
describe('normalizeMcpAskFormData', () => {
it('extracts remote URLs from UploadFileInfo list', () => {
const files: UploadFileInfo[] = [
{
uid: '1',
name: 'a.png',
type: 'image/png',
size: 100,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/a.png',
},
{
uid: '2',
name: 'b.png',
type: 'image/png',
size: 200,
status: UploadFileStatus.uploading,
url: '',
},
];
expect(extractUploadedFileUrls(files)).toEqual([
'https://cdn.example.com/a.png',
]);
});
it('keeps existing URL strings unchanged', () => {
expect(
extractUploadedFileUrls('https://cdn.example.com/existing.png'),
).toEqual(['https://cdn.example.com/existing.png']);
expect(
extractUploadedFileUrls([
'https://cdn.example.com/1.png',
'https://cdn.example.com/2.png',
]),
).toEqual([
'https://cdn.example.com/1.png',
'https://cdn.example.com/2.png',
]);
});
it('normalizes single file field to one URL string', () => {
expect(
normalizeMcpAskFileFieldValue(
[
{
uid: '1',
name: 'shot.png',
type: 'image/png',
size: 10,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/shot.png',
},
],
false,
),
).toBe('https://cdn.example.com/shot.png');
});
it('normalizes multiple file field to URL array', () => {
expect(
normalizeMcpAskFileFieldValue(
[
{
uid: '1',
name: 'a.png',
type: 'image/png',
size: 10,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/a.png',
},
{
uid: '2',
name: 'b.png',
type: 'image/png',
size: 20,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/b.png',
},
],
true,
),
).toEqual([
'https://cdn.example.com/a.png',
'https://cdn.example.com/b.png',
]);
});
it('rejects required file field without remote URL', () => {
expect(() =>
validateMcpAskRequiredFileField([
{
uid: '1',
name: 'shot.png',
type: 'image/png',
size: 10,
status: UploadFileStatus.uploading,
url: '',
},
]),
).toThrow('MCP_ASK_FILE_REQUIRED');
});
it('accepts required file field with remote URL', () => {
expect(() =>
validateMcpAskRequiredFileField([
{
uid: '1',
name: 'shot.png',
type: 'image/png',
size: 10,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/shot.png',
},
]),
).not.toThrow();
});
it('normalizes only file widgets in form data', () => {
const formData = normalizeMcpAskFormData(
{
choice: 'deploy',
screenshot: [
{
uid: '1',
name: 'shot.png',
type: 'image/png',
size: 10,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/shot.png',
},
],
attachments: [
{
uid: '2',
name: 'doc.pdf',
type: 'application/pdf',
size: 20,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/doc.pdf',
},
],
},
fileUi([
{ name: 'choice', title: '选项', widget: 'radio' },
{ name: 'screenshot', title: '截图', widget: 'file' },
{
name: 'attachments',
title: '附件',
widget: 'file',
multiple: true,
},
]),
);
expect(formData).toEqual({
choice: 'deploy',
screenshot: 'https://cdn.example.com/shot.png',
attachments: ['https://cdn.example.com/doc.pdf'],
});
});
it('hydrates submitted URL strings back to Upload fileList', () => {
const hydrated = hydrateMcpAskFormValues(
{
screenshot: 'https://cdn.example.com/shot.png',
attachments: ['https://cdn.example.com/a.pdf'],
},
fileUi([
{ name: 'screenshot', title: '截图', widget: 'file' },
{
name: 'attachments',
title: '附件',
widget: 'file',
multiple: true,
},
]),
);
expect(hydrated.screenshot).toEqual([
urlToUploadFileInfo('https://cdn.example.com/shot.png', 0),
]);
expect(hydrated.attachments).toEqual([
urlToUploadFileInfo('https://cdn.example.com/a.pdf', 0),
]);
});
it('keeps existing Upload fileList when hydrating in-progress values', () => {
const uploading: UploadFileInfo[] = [
{
uid: 'local-1',
name: 'local.png',
type: 'image/png',
size: 10,
status: UploadFileStatus.uploading,
url: '',
},
];
expect(hydrateMcpAskFileFieldValue(uploading, false)).toEqual(uploading);
});
it('limits single-file field hydration to one URL', () => {
expect(
hydrateMcpAskFileFieldValue(
['https://cdn.example.com/a.png', 'https://cdn.example.com/b.png'],
false,
),
).toEqual([urlToUploadFileInfo('https://cdn.example.com/a.png', 0)]);
});
it('limits single-file upload list to the latest file', () => {
const files: UploadFileInfo[] = [
{
uid: '1',
name: 'a.png',
type: 'image/png',
size: 10,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/a.png',
},
{
uid: '2',
name: 'b.png',
type: 'image/png',
size: 20,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/b.png',
},
];
expect(limitMcpAskUploadFileList(files, false)).toEqual([files[1]]);
expect(limitMcpAskUploadFileList(files, true)).toEqual(files);
});
it('extractMcpAskFormAttachments collects file widgets as UploadFileInfo', () => {
const ui = fileUi([
{ name: 'screenshot', title: '截图', widget: 'file' },
{
name: 'attachments',
title: '附件',
widget: 'file',
multiple: true,
},
]);
const files = extractMcpAskFormAttachments(
{
screenshot: [
{
uid: '1',
name: 'shot.png',
type: 'image/png',
size: 10,
status: UploadFileStatus.done,
url: 'https://cdn.example.com/shot.png',
key: 'k1',
},
],
attachments: [
{
fileKey: 'k2',
fileUrl: 'https://cdn.example.com/doc.pdf',
fileName: 'doc.pdf',
mimeType: 'application/pdf',
fileSize: 20,
},
],
},
ui,
);
expect(files).toHaveLength(2);
expect(files[0]).toMatchObject({
name: 'shot.png',
url: 'https://cdn.example.com/shot.png',
key: 'k1',
});
expect(files[1]).toMatchObject({
name: 'doc.pdf',
url: 'https://cdn.example.com/doc.pdf',
key: 'k2',
});
});
});

View File

@@ -0,0 +1,305 @@
import { UploadFileStatus } from '@/types/enums/common';
import type { UploadFileInfo } from '@/types/interfaces/common';
import type { InteractionUiSchema } from '../types/mcpAskIntervention';
import { parseInteractionFields } from './parseMcpAskSchema';
/** 判断是否为统一文件上传服务返回的可访问远程 URL */
export function isRemoteFileUrl(url: unknown): url is string {
return (
typeof url === 'string' &&
(url.startsWith('http://') || url.startsWith('https://'))
);
}
function isUploadFileInfoLike(item: unknown): item is UploadFileInfo {
return (
!!item &&
typeof item === 'object' &&
('url' in item || 'name' in item || 'uid' in item)
);
}
/** 判断是否为会话附件结构Mobile AttachmentFile */
function isAttachmentFileLike(item: unknown): item is {
fileUrl?: string;
fileKey?: string;
fileName?: string;
mimeType?: string;
fileSize?: number;
} {
return (
!!item &&
typeof item === 'object' &&
('fileUrl' in item || 'fileName' in item || 'fileKey' in item)
);
}
function inferFileNameFromUrl(url: string): string {
try {
const segment = new URL(url).pathname.split('/').filter(Boolean).pop();
return segment || 'file';
} catch {
return 'file';
}
}
/** 将单个远程 URL 转为 Upload 组件可识别的已完成文件项 */
export function urlToUploadFileInfo(url: string, index = 0): UploadFileInfo {
return {
uid: `mcp-ask-url-${index}-${url}`,
name: inferFileNameFromUrl(url),
type: '',
size: 0,
url,
status: UploadFileStatus.done,
};
}
/** 将 UploadFileInfo / AttachmentFile / URL 转为发送消息用的 UploadFileInfo */
function toUploadFileInfo(item: unknown, index: number): UploadFileInfo | null {
if (typeof item === 'string' && isRemoteFileUrl(item)) {
return urlToUploadFileInfo(item, index);
}
if (isUploadFileInfoLike(item)) {
if (item.status === UploadFileStatus.removed) {
return null;
}
const url = item.url;
if (!isRemoteFileUrl(url)) {
return null;
}
return {
uid: item.uid || `mcp-ask-file-${index}`,
name: item.name || inferFileNameFromUrl(url),
type: item.type || '',
size: item.size || 0,
url,
key: item.key,
status: item.status ?? UploadFileStatus.done,
};
}
if (isAttachmentFileLike(item)) {
const url = item.fileUrl;
if (!isRemoteFileUrl(url)) {
return null;
}
return {
uid: item.fileKey || `mcp-ask-attach-${index}`,
name: item.fileName || inferFileNameFromUrl(url),
type: item.mimeType || '',
size: item.fileSize || 0,
url,
key: item.fileKey,
status: UploadFileStatus.done,
};
}
return null;
}
/**
* 从 MCP Ask 表单 file 字段收集已上传附件,供 resume 消息走 chat attachments 发送。
* 兼容 UploadFileInfo[]、AttachmentFile[]、URL 字符串及其数组。
*/
export function extractMcpAskFormAttachments(
formData: Record<string, unknown>,
ui: InteractionUiSchema,
): UploadFileInfo[] {
const attachments: UploadFileInfo[] = [];
parseInteractionFields(ui).forEach((field) => {
if (field.widget !== 'file') {
return;
}
if (!Object.prototype.hasOwnProperty.call(formData, field.name)) {
return;
}
const rawValue = formData[field.name];
const items: unknown[] = [];
if (typeof rawValue === 'string') {
items.push(rawValue);
} else if (Array.isArray(rawValue)) {
items.push(...rawValue);
} else if (rawValue !== undefined && rawValue !== null && rawValue !== '') {
items.push(rawValue);
}
items.forEach((item) => {
const fileInfo = toUploadFileInfo(item, attachments.length);
if (fileInfo) {
attachments.push(fileInfo);
}
});
});
return attachments;
}
/**
* 从表单原始值中提取已上传文件的远程 URL 列表。
* 兼容:已是 URL 字符串、URL 数组、UploadFileInfo 对象或数组。
*/
export function extractUploadedFileUrls(value: unknown): string[] {
if (value === undefined || value === null || value === '') {
return [];
}
if (typeof value === 'string') {
return isRemoteFileUrl(value) ? [value] : [];
}
if (Array.isArray(value)) {
if (value.every((item) => typeof item === 'string')) {
return value.filter(isRemoteFileUrl);
}
if (value.length > 0 && isAttachmentFileLike(value[0])) {
return value
.filter(isAttachmentFileLike)
.map((item) => item.fileUrl)
.filter(isRemoteFileUrl);
}
return value
.filter(isUploadFileInfoLike)
.filter((item) => item.status !== UploadFileStatus.removed)
.map((item) => item.url)
.filter(isRemoteFileUrl);
}
if (isAttachmentFileLike(value)) {
return isRemoteFileUrl(value.fileUrl) ? [value.fileUrl] : [];
}
if (isUploadFileInfoLike(value)) {
return isRemoteFileUrl(value.url) ? [value.url] : [];
}
return [];
}
/**
* 校验必填 file 字段是否已有统一上传服务返回的远程 URL。
* 供 McpAskFormField 的 Form.Item validator 复用。
*/
export function validateMcpAskRequiredFileField(value: unknown): void {
if (!extractUploadedFileUrls(value).length) {
throw new Error('MCP_ASK_FILE_REQUIRED');
}
}
/**
* 将单个 file 字段值归一化为提交用 URL。
* - multiple: string[]
* - 单文件: string无有效 URL 时为空字符串)
*/
export function normalizeMcpAskFileFieldValue(
value: unknown,
multiple?: boolean,
): string | string[] {
const urls = extractUploadedFileUrls(value);
if (multiple) {
return urls;
}
return urls[0] ?? '';
}
/**
* 提交前将 MCP Ask 表单中的 file 字段转为统一文件上传服务返回的 URL。
*/
export function normalizeMcpAskFormData(
formData: Record<string, unknown>,
ui: InteractionUiSchema,
): Record<string, unknown> {
const normalized = { ...formData };
parseInteractionFields(ui).forEach((field) => {
if (field.widget !== 'file') {
return;
}
if (!Object.prototype.hasOwnProperty.call(normalized, field.name)) {
return;
}
normalized[field.name] = normalizeMcpAskFileFieldValue(
normalized[field.name],
field.options.multiple,
);
});
return normalized;
}
/**
* 将已提交的 URL 表单值还原为 Upload fileList避免回填时破坏 Upload 控件。
* 若值本身已是 UploadFileInfo[],则原样返回。
*/
export function hydrateMcpAskFileFieldValue(
value: unknown,
multiple?: boolean,
): unknown {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (
Array.isArray(value) &&
value.length > 0 &&
isUploadFileInfoLike(value[0])
) {
return value;
}
if (isUploadFileInfoLike(value)) {
return [value];
}
const urls = extractUploadedFileUrls(value);
if (!urls.length) {
return undefined;
}
const limitedUrls = multiple ? urls : urls.slice(0, 1);
return limitedUrls.map((url, index) => urlToUploadFileInfo(url, index));
}
/**
* 回填表单前,将 file 字段的 URL 值转为 Upload fileList。
*/
export function hydrateMcpAskFormValues(
formData: Record<string, unknown>,
ui: InteractionUiSchema,
): Record<string, unknown> {
const hydrated = { ...formData };
parseInteractionFields(ui).forEach((field) => {
if (field.widget !== 'file') {
return;
}
if (!Object.prototype.hasOwnProperty.call(hydrated, field.name)) {
return;
}
hydrated[field.name] = hydrateMcpAskFileFieldValue(
hydrated[field.name],
field.options.multiple,
);
});
return hydrated;
}
/**
* 单文件字段仅保留最后一个已选文件,防止多选后静默丢文件。
*/
export function limitMcpAskUploadFileList(
fileList: UploadFileInfo[],
multiple?: boolean,
): UploadFileInfo[] {
if (multiple || fileList.length <= 1) {
return fileList;
}
return fileList.slice(-1);
}

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import type { InteractionUiSchema } from '../types/mcpAskIntervention';
import {
getInteractionSteps,
isWizardPresentation,
parseInteractionFields,
} from './parseMcpAskSchema';
const v2 = (
overrides: Partial<InteractionUiSchema> = {},
): InteractionUiSchema =>
({
version: 'nuwax.interaction.v2',
presentation: 'inline',
title: 'Ask',
...overrides,
} as InteractionUiSchema);
describe('parseInteractionFields (v2)', () => {
it('maps fields[] in array order with merged options', () => {
const fields = parseInteractionFields(
v2({
fields: [
{ name: 'remark', title: '备注', widget: 'textarea' },
{
name: 'choice',
title: '继续方式',
widget: 'radio',
required: true,
options: [
{ value: 'a', label: '甲' },
{ value: 'b', label: '乙' },
],
},
],
}),
);
expect(fields).toMatchObject([
{ name: 'remark', widget: 'textarea', required: false },
{
name: 'choice',
widget: 'radio',
required: true,
enumValues: ['a', 'b'],
enumLabels: ['甲', '乙'],
},
]);
});
it('rebuilds JsonSchema constraint view from v2 field', () => {
const [field] = parseInteractionFields(
v2({
fields: [
{
name: 'count',
title: '并发数',
widget: 'number',
type: 'integer',
minimum: 1,
maximum: 10,
multipleOf: 2,
},
],
}),
);
expect(field.property).toMatchObject({
title: '并发数',
type: 'integer',
minimum: 1,
maximum: 10,
multipleOf: 2,
});
});
it('carries placeholder/accept/multiple into options', () => {
const [field] = parseInteractionFields(
v2({
fields: [
{
name: 'screenshot',
title: '截图',
widget: 'file',
accept: 'image/*',
multiple: true,
placeholder: '上传图片',
},
],
}),
);
expect(field.options).toMatchObject({
accept: 'image/*',
multiple: true,
placeholder: '上传图片',
});
});
it('falls back unknown widget to text', () => {
const [field] = parseInteractionFields(
v2({
fields: [{ name: 'x', title: 'X', widget: 'bogus' } as never],
}),
);
expect(field.widget).toBe('text');
});
it('filters by fieldNames (wizard step) in the given order', () => {
const fields = parseInteractionFields(
v2({
presentation: 'wizard',
fields: [
{ name: 'a', title: 'A', widget: 'text' },
{ name: 'b', title: 'B', widget: 'text' },
{ name: 'c', title: 'C', widget: 'text' },
],
}),
['c', 'a'],
);
expect(fields.map((f) => f.name)).toEqual(['c', 'a']);
});
it('returns [] when no fields', () => {
expect(parseInteractionFields(v2())).toEqual([]);
});
});
describe('getInteractionSteps / isWizardPresentation (v2)', () => {
it('returns ui.steps when provided', () => {
const ui = v2({
presentation: 'wizard',
fields: [{ name: 'a', title: 'A', widget: 'text' }],
steps: [{ id: 's1', title: '步骤一', fields: ['a'] }],
});
expect(getInteractionSteps(ui)).toMatchObject([
{ id: 's1', fields: ['a'] },
]);
expect(isWizardPresentation(ui)).toBe(true);
});
it('builds a default single step from fields[]', () => {
const ui = v2({
fields: [
{ name: 'a', title: 'A', widget: 'text' },
{ name: 'b', title: 'B', widget: 'text' },
],
});
expect(getInteractionSteps(ui)).toMatchObject([
{ id: 'default', fields: ['a', 'b'] },
]);
});
it('returns [] when no fields and no steps', () => {
expect(getInteractionSteps(v2())).toEqual([]);
});
});

View File

@@ -0,0 +1,145 @@
import type {
FieldOption,
FormField,
InteractionUiOptions,
InteractionUiSchema,
InteractionUiStep,
JsonSchemaProperty,
McpAskFieldWidget,
} from '../types/mcpAskIntervention';
import { MCP_ASK_WIDGET_TYPES } from '../types/mcpAskIntervention';
/**
* 解析后的字段:形状对齐渲染层(McpAskFormField)的读取路径,故渲染组件无需改动。
* `property` 为从 v2 FormField 重建的 JsonSchema 约束视图。
*/
export interface ParsedMcpAskField {
name: string;
property: JsonSchemaProperty;
widget: McpAskFieldWidget;
required: boolean;
options: InteractionUiOptions;
enumValues: string[];
enumLabels: string[];
}
function isKnownWidget(widget: unknown): widget is McpAskFieldWidget {
return (
typeof widget === 'string' &&
(MCP_ASK_WIDGET_TYPES as readonly string[]).includes(widget)
);
}
function isFieldOption(opt: unknown): opt is FieldOption {
return (
!!opt &&
typeof opt === 'object' &&
typeof (opt as FieldOption).value === 'string' &&
typeof (opt as FieldOption).label === 'string'
);
}
/** 把 v2 FormField 重建为 JsonSchema 约束视图(FormField 组件读取路径)。 */
function buildProperty(field: FormField): JsonSchemaProperty {
return {
type: field.type,
title: field.title,
description: field.description,
minimum: field.minimum,
maximum: field.maximum,
multipleOf: field.multipleOf,
minLength: field.minLength,
maxLength: field.maxLength,
};
}
function buildOptions(field: FormField): InteractionUiOptions {
return {
placeholder: field.placeholder,
allowCustom: field.allowCustom,
otherValue: field.otherValue,
otherField: field.otherField,
accept: field.accept,
multiple: field.multiple,
};
}
function parseField(field: FormField): ParsedMcpAskField {
const options = Array.isArray(field.options)
? field.options.filter(isFieldOption)
: [];
return {
name: field.name,
property: buildProperty(field),
widget: isKnownWidget(field.widget) ? field.widget : 'text',
required: field.required === true,
options: buildOptions(field),
enumValues: options.map((opt) => opt.value),
enumLabels: options.map((opt) => opt.label),
};
}
/**
* 解析 v2 表单字段:遍历 `ui.fields[]`,数组顺序即展示顺序。
* 传入 `fieldNames`(wizard 步骤)时,按其顺序与名称过滤对应字段。
*/
export function parseInteractionFields(
ui: InteractionUiSchema,
fieldNames?: string[],
): ParsedMcpAskField[] {
const fields = ui.fields ?? [];
if (!fieldNames?.length) {
return fields.map(parseField);
}
const byName = new Map(fields.map((f) => [f.name, f]));
return fieldNames
.map((name) => byName.get(name))
.filter((f): f is FormField => !!f)
.map(parseField);
}
export function isWizardPresentation(ui: InteractionUiSchema): boolean {
return (
ui.presentation === 'wizard' ||
(Array.isArray(ui.steps) && ui.steps.length > 1)
);
}
export function getInteractionSteps(
ui: InteractionUiSchema,
): InteractionUiStep[] {
if (ui.steps?.length) {
return ui.steps;
}
const fields = ui.fields ?? [];
if (!fields.length) {
return [];
}
return [
{
id: 'default',
title: ui.title,
description: ui.description,
fields: fields.map((f) => f.name),
},
];
}
/** 是否允许跳过:v2 由 ui.allowSkip 顶层字段控制(旧 v1 的 uiSchema.ui:options.allowSkip 已移除)。 */
export function isSkipAllowed(ui: InteractionUiSchema): boolean {
return ui.allowSkip === true;
}
export function getSkipLabel(ui: InteractionUiSchema): string | undefined {
return ui.skipLabel;
}
/** JSON Schema 主类型(忽略 null 联合成员)。McpAskFormField 用于判断 integer。 */
export function getJsonSchemaPrimaryType(
prop: Pick<JsonSchemaProperty, 'type'>,
): string {
if (Array.isArray(prop.type)) {
return prop.type.find((t) => t !== 'null') || 'string';
}
return prop.type || 'string';
}

View File

@@ -0,0 +1,64 @@
import type { McpAskUserToolInput } from '../types/mcpAskIntervention';
import {
INTERACTION_UI_SCHEMA_VERSION,
MCP_ASK_SCHEMA_VERSION,
} from '../types/mcpAskIntervention';
/**
* agent / 平台 SSE 常漏写 schemaVersion;若 ui.version 合法则推断 ask schema 版本。
* 仅支持最新 v2,不兼容旧版本(非 v2 输入返回 null,不渲染)。
*/
function resolveSchemaVersion(record: Record<string, unknown>): string | null {
if (
typeof record.schemaVersion === 'string' &&
record.schemaVersion === MCP_ASK_SCHEMA_VERSION
) {
return record.schemaVersion;
}
const ui = record.ui as Record<string, unknown> | undefined;
if (
typeof ui?.version === 'string' &&
ui.version === INTERACTION_UI_SCHEMA_VERSION
) {
return MCP_ASK_SCHEMA_VERSION;
}
// 兜底:agent 按"服务端盖戳"约定省略 schemaVersion/ui.version 时,识别裸 v2。
// v2 强信号 = 有 ui.fields 且无 v1 的 ui.schema(v1 用 schema,无 fields)。
if (ui && Array.isArray(ui.fields) && !ui.schema) {
return MCP_ASK_SCHEMA_VERSION;
}
return null;
}
export function parseMcpAskToolInput(raw: unknown): McpAskUserToolInput | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>;
const schemaVersion = resolveSchemaVersion(record);
if (!schemaVersion) {
return null;
}
if ((record.toolName ?? 'nuwax_ask_question') !== 'nuwax_ask_question') {
return null;
}
if (typeof record.requestId !== 'string' || !record.ui) {
return null;
}
const ui = record.ui as Record<string, unknown>;
// 版本识别已在 resolveSchemaVersion 完成(含裸 v2 推断);此处只校验 v2 形状。
// v2:表单定义为 fields[](inline/modal)或 steps[](wizard)
const hasFields = Array.isArray(ui.fields) && ui.fields.length > 0;
const hasSteps = Array.isArray(ui.steps) && ui.steps.length > 0;
if (!hasFields && !hasSteps) {
return null;
}
return {
...record,
schemaVersion,
toolName: 'nuwax_ask_question',
} as unknown as McpAskUserToolInput;
}

View File

@@ -0,0 +1,55 @@
import type { ConversationChatResponse } from '@/types/interfaces/conversationInfo';
interface SseEventEnvelope {
messageType?: string;
message_type?: string;
subType?: string;
sub_type?: string;
subEventType?: string;
data?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* Extracts the event envelope from an SSE response.
* The backend sends events in different shapes:
* - { eventType, data: { messageType, subType, data: {...} } }
* - { eventType, messageType, subType, data: {...} }
* - { eventType, message_type, sub_type, data: {...} }
* This normalizes access to a single envelope object.
*/
export function parseSseEventEnvelope(
res: ConversationChatResponse,
): SseEventEnvelope {
const nested = res.data as SseEventEnvelope | undefined;
if (
nested &&
typeof nested === 'object' &&
(nested.messageType ||
nested.message_type ||
nested.subType ||
nested.sub_type ||
nested.subEventType)
) {
return nested;
}
return res as unknown as SseEventEnvelope;
}
/**
* Extracts the inner event data from the envelope.
*/
export function extractEventData(
envelope: SseEventEnvelope,
fallback: ConversationChatResponse,
): Record<string, unknown> {
const inner = envelope.data;
if (inner && typeof inner === 'object') {
return inner as Record<string, unknown>;
}
const fallbackData = fallback.data;
if (fallbackData && typeof fallbackData === 'object') {
return fallbackData as Record<string, unknown>;
}
return fallback as unknown as Record<string, unknown>;
}

View File

@@ -0,0 +1,26 @@
import type {
ConversationChatResponse,
MessageInfo,
} from '@/types/interfaces/conversationInfo';
import { applyAcpPermissionSseEvent } from './applyAcpPermissionSseEvent';
import { applyMcpAskToolCallSseEvent } from './applyMcpAskToolCallSseEvent';
import { reconcileMessageAcpPermissionStatuses } from './reconcileAcpPermissionStatus';
export function processInterventionSsePatch(
res: ConversationChatResponse,
currentMessage: MessageInfo,
contextMessageList?: MessageInfo[],
): MessageInfo | null {
const patched =
applyAcpPermissionSseEvent(res, currentMessage) ??
applyMcpAskToolCallSseEvent(res, currentMessage);
if (!patched) {
return null;
}
return reconcileMessageAcpPermissionStatuses(
patched,
contextMessageList ?? [patched],
);
}

View File

@@ -0,0 +1,235 @@
import { AgentComponentTypeEnum } from '@/types/enums/agent';
import { ProcessingEnum } from '@/types/enums/common';
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import { describe, expect, it, vi } from 'vitest';
import type { AcpPermissionInteraction } from '../types/acpIntervention';
vi.mock('./mcpAskResumeMessage', () => ({
hasMcpAskResumeMessage: vi.fn(() => false),
}));
import { hasMcpAskResumeMessage } from './mcpAskResumeMessage';
import {
isIdempotentAcpPermissionResolveError,
reconcileAcpPermissionStatusesInMessageList,
reconcileMessageAcpPermissionStatuses,
} from './reconcileAcpPermissionStatus';
function createAcpInteraction(
overrides: Partial<AcpPermissionInteraction> = {},
): AcpPermissionInteraction {
return {
intervention: {
id: 'itv-1',
revision: 1,
kind: 'approval',
status: 'pending',
sessionId: 'sess-1',
source: 'acp_permission',
engine: 'nuwaxcode',
protocol: 'acp',
callbackTarget: { kind: 'electron', targetId: '' },
schemaRef: '',
acp: {
method: 'session/request_permission',
request: {
sessionId: 'sess-1',
toolCall: {
toolCallId: 'call-1',
title: 'write',
kind: 'edit',
status: 'pending',
rawInput: { filePath: '/tmp/a.txt' },
},
options: [],
},
},
createdAt: 100,
},
responseStatus: 'pending',
executeId: 'call-1',
...overrides,
} as AcpPermissionInteraction;
}
describe('reconcileAcpPermissionStatus', () => {
it('marks pending permission as submitted when matching tool finished in processingList', () => {
const message = {
id: 'assistant',
acpPermissionInteractions: [createAcpInteraction()],
processingList: [
{
executeId: 'call-1',
name: 'write',
type: AgentComponentTypeEnum.ToolCall,
status: ProcessingEnum.FINISHED,
result: null,
targetId: -1,
cardBindConfig: null,
subEventType: null,
},
],
} as unknown as MessageInfo;
const reconciled = reconcileMessageAcpPermissionStatuses(message);
expect(reconciled.acpPermissionInteractions?.[0].responseStatus).toBe(
'submitted',
);
});
it('marks permission submitted when MCP ask resume exists for linked requestId', () => {
vi.mocked(hasMcpAskResumeMessage).mockReturnValueOnce(true);
const askPermission = createAcpInteraction({
intervention: {
...createAcpInteraction().intervention,
acp: {
method: 'session/request_permission',
request: {
sessionId: 'sess-1',
toolCall: {
toolCallId: 'call-ask',
title: 'ask',
kind: 'other',
status: 'pending',
rawInput: {
schemaVersion: 'nuwax.mcp_ask.v1',
requestId: 'ask-1',
title: '毕业论文 PPT 前置信息收集',
ui: {
version: 'nuwax.interaction.v1',
presentation: 'inline',
title: '毕业论文 PPT 前置信息收集',
fields: [],
},
},
},
options: [],
},
},
},
executeId: 'call-ask',
});
const messageList = [
{
id: 'assistant',
acpPermissionInteractions: [askPermission],
},
{
id: 'user-resume',
text: '我已填写「毕业论文 PPT 前置信息收集」',
},
] as unknown as MessageInfo[];
const reconciled = reconcileAcpPermissionStatusesInMessageList(messageList);
expect(reconciled[0].acpPermissionInteractions?.[0].responseStatus).toBe(
'submitted',
);
});
it('sub replay leaves only one pending after reconcile when one tool finished', () => {
const first = createAcpInteraction({
intervention: {
...createAcpInteraction().intervention,
id: 'itv-first',
createdAt: 100,
acp: {
method: 'session/request_permission',
request: {
sessionId: 'sess-1',
toolCall: {
toolCallId: 'call-first',
title: 'a',
kind: 'edit',
status: 'pending',
rawInput: {},
},
options: [],
},
},
},
executeId: 'call-first',
});
const second = createAcpInteraction({
intervention: {
...createAcpInteraction().intervention,
id: 'itv-second',
createdAt: 200,
acp: {
method: 'session/request_permission',
request: {
sessionId: 'sess-1',
toolCall: {
toolCallId: 'call-second',
title: 'b',
kind: 'edit',
status: 'pending',
rawInput: {},
},
options: [],
},
},
},
executeId: 'call-second',
});
const messageList = [
{
id: 'assistant',
acpPermissionInteractions: [first, second],
processingList: [
{
executeId: 'call-first',
status: ProcessingEnum.FINISHED,
},
],
},
] as unknown as MessageInfo[];
const reconciled = reconcileAcpPermissionStatusesInMessageList(messageList);
const pending = reconciled[0].acpPermissionInteractions?.filter(
(item) => item.responseStatus === 'pending',
);
expect(pending).toHaveLength(1);
expect(pending?.[0].intervention.id).toBe('itv-second');
});
it('keeps still-pending permission when tool has not finished', () => {
const message = {
id: 'assistant',
acpPermissionInteractions: [createAcpInteraction()],
processingList: [
{
executeId: 'call-1',
name: 'write',
type: AgentComponentTypeEnum.ToolCall,
status: ProcessingEnum.EXECUTING,
result: null,
targetId: -1,
cardBindConfig: null,
subEventType: null,
},
],
} as unknown as MessageInfo;
const reconciled = reconcileMessageAcpPermissionStatuses(message);
expect(reconciled.acpPermissionInteractions?.[0].responseStatus).toBe(
'pending',
);
});
it('detects idempotent resolve errors', () => {
expect(
isIdempotentAcpPermissionResolveError(
new Error('pending permission not found'),
),
).toBe(true);
expect(isIdempotentAcpPermissionResolveError('already_resolved')).toBe(
true,
);
expect(isIdempotentAcpPermissionResolveError('network timeout')).toBe(
false,
);
});
});

View File

@@ -0,0 +1,206 @@
import { ProcessingEnum } from '@/types/enums/common';
import type {
MessageInfo,
ProcessingInfo,
} from '@/types/interfaces/conversationInfo';
import type { AcpPermissionInteraction } from '../types/acpIntervention';
import type { McpAskInteraction } from '../types/mcpAskIntervention';
import {
hasMcpAskResumeMessage,
sortMessagesByConversationIndex,
} from './mcpAskResumeMessage';
function readMcpAskRequestId(rawInput: unknown): string | undefined {
if (!rawInput || typeof rawInput !== 'object') {
return undefined;
}
const input = rawInput as Record<string, unknown>;
return typeof input.requestId === 'string' && input.requestId
? input.requestId
: undefined;
}
function getToolCallId(
interaction: AcpPermissionInteraction,
): string | undefined {
return interaction.intervention.acp?.request?.toolCall?.toolCallId;
}
function isProcessingFinishedForTool(
processingList: ProcessingInfo[] | undefined,
toolCallId: string,
permissionExecuteId?: string,
): boolean {
if (!processingList?.length) {
return false;
}
for (const item of processingList) {
if (item.status !== ProcessingEnum.FINISHED) {
continue;
}
if (permissionExecuteId && item.executeId === permissionExecuteId) {
return true;
}
if (item.executeId === toolCallId) {
return true;
}
const result = item.result as Record<string, unknown> | null | undefined;
const input = (result?.input ?? undefined) as
| Record<string, unknown>
| undefined;
if (input) {
const matchedId = input.toolCallId ?? input.tool_call_id;
if (matchedId === toolCallId) {
return true;
}
}
}
return false;
}
function buildMcpAskInteractionFromRawInput(
rawInput: unknown,
toolCallId: string,
): McpAskInteraction | null {
if (!rawInput || typeof rawInput !== 'object') {
return null;
}
const input = rawInput as Record<string, unknown>;
const requestId = readMcpAskRequestId(rawInput);
const title = typeof input.title === 'string' ? input.title : undefined;
const ui = input.ui;
if (!requestId || !title || !ui || typeof ui !== 'object') {
return null;
}
return {
input: {
toolName: 'nuwax_ask_question',
schemaVersion: 'nuwax.mcp_ask.v2',
requestId,
revision: 1,
sessionId: '',
title,
ui: ui as McpAskInteraction['input']['ui'],
},
toolCallId,
responseStatus: 'pending',
};
}
function shouldMarkAcpPermissionSubmitted(
interaction: AcpPermissionInteraction,
message: MessageInfo,
contextMessageList: MessageInfo[],
containingMessageIndex?: number,
): boolean {
const status = interaction.responseStatus ?? 'pending';
if (status === 'submitted' || status === 'failed') {
return false;
}
const toolCallId = getToolCallId(interaction);
if (
toolCallId &&
isProcessingFinishedForTool(
message.processingList,
toolCallId,
interaction.executeId,
)
) {
return true;
}
const rawInput = interaction.intervention.acp?.request?.toolCall?.rawInput;
if (toolCallId && readMcpAskRequestId(rawInput)) {
const syntheticAsk = buildMcpAskInteractionFromRawInput(
rawInput,
toolCallId,
);
if (
syntheticAsk &&
hasMcpAskResumeMessage(contextMessageList, syntheticAsk, {
containingMessageIndex,
})
) {
return true;
}
}
return false;
}
/**
* 根据 processingList 完成态与 MCP Ask resume 消息,将已解决的 ACP 审批标为 submitted。
* 用于 sub 流重放、跨页签审批后自动关闭对应卡片。
*/
export function reconcileMessageAcpPermissionStatuses(
message: MessageInfo,
contextMessageList?: MessageInfo[],
): MessageInfo {
const interactions = message.acpPermissionInteractions;
if (!interactions?.length) {
return message;
}
const context = sortMessagesByConversationIndex(
contextMessageList ?? [message],
);
const containingMessageIndex = context.findIndex(
(item) => item.id === message.id || item.index === message.index,
);
let changed = false;
const nextInteractions = interactions.map((interaction) => {
if (
!shouldMarkAcpPermissionSubmitted(
interaction as AcpPermissionInteraction,
message,
context,
containingMessageIndex >= 0 ? containingMessageIndex : undefined,
)
) {
return interaction;
}
changed = true;
return {
...interaction,
responseStatus: 'submitted' as const,
};
});
if (!changed) {
return message;
}
return {
...message,
acpPermissionInteractions: nextInteractions,
};
}
export function reconcileAcpPermissionStatusesInMessageList(
messageList: MessageInfo[],
): MessageInfo[] {
return messageList.map((message) =>
reconcileMessageAcpPermissionStatuses(message, messageList),
);
}
/** notify-resolved 幂等Host 侧 pending 已不存在时视为已审批成功 */
export function isIdempotentAcpPermissionResolveError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '');
const normalized = message.toLowerCase();
return (
normalized.includes('not_found') ||
normalized.includes('not found') ||
normalized.includes('already_resolved') ||
normalized.includes('already resolved') ||
normalized.includes('permission not found') ||
normalized.includes('err_permission_not_found') ||
normalized.includes('gone')
);
}

View File

@@ -0,0 +1,122 @@
import { AgentComponentTypeEnum } from '@/types/enums/agent';
import { MessageStatusEnum, ProcessingEnum } from '@/types/enums/common';
import { describe, expect, it } from 'vitest';
import { reconcileFinalMessageState } from './reconcileFinalMessageState';
describe('reconcileFinalMessageState', () => {
it('hydrates missed ACP permission events from final component results', () => {
const patched = reconcileFinalMessageState(
{
id: 'msg-1',
status: MessageStatusEnum.Complete,
} as any,
{
componentExecuteResults: [
{
id: -1,
name: 'Backend.Sandbox.Event.RequestPermission',
type: AgentComponentTypeEnum.Event,
startTime: 1,
endTime: 1,
success: null,
error: null,
data: null,
innerExecuteInfo: null,
executeId: 'call-write-1',
kind: null,
locations: null,
input: {
request_permission_request: {
sessionId: 'sess-final',
toolCall: {
toolCallId: 'call-write-1',
kind: 'edit',
status: 'pending',
title: '写入文件',
rawInput: {
filepath: '/tmp/demo.txt',
diff: '+++ /tmp/demo.txt',
},
},
options: [
{
optionId: 'once',
kind: 'allow_once',
name: 'Allow once',
},
],
},
tool_call_id: 'call-write-1',
},
},
],
} as any,
);
const interaction = patched.acpPermissionInteractions?.[0];
expect(interaction?.intervention.acp.request.sessionId).toBe('sess-final');
expect(interaction?.intervention.acp.request.toolCall.toolCallId).toBe(
'call-write-1',
);
expect(interaction?.responseStatus).toBe('pending');
});
it('reconciles final execute results and clears orphan executing processing items', () => {
const patched = reconcileFinalMessageState(
{
id: 'msg-2',
processingList: [
{
executeId: 'call-write-1',
name: '写入文件',
type: AgentComponentTypeEnum.ToolCall,
status: ProcessingEnum.EXECUTING,
result: null,
targetId: -1,
cardBindConfig: null,
subEventType: null,
},
{
executeId: 'call-stuck-2',
name: '编辑文件',
type: AgentComponentTypeEnum.ToolCall,
status: ProcessingEnum.EXECUTING,
result: null,
targetId: -1,
cardBindConfig: null,
subEventType: null,
},
],
} as any,
{
componentExecuteResults: [
{
id: -1,
name: '写入文件',
type: AgentComponentTypeEnum.ToolCall,
startTime: 1,
endTime: 2,
success: true,
error: null,
data: null,
innerExecuteInfo: null,
executeId: 'call-write-1',
kind: 'edit',
locations: [{ path: '/tmp/demo.txt' }],
input: {
filePath: '/tmp/demo.txt',
content: 'hello',
},
},
],
} as any,
);
expect(patched.processingList?.[0].status).toBe(ProcessingEnum.FINISHED);
expect(patched.processingList?.[0].result?.input).toEqual({
filePath: '/tmp/demo.txt',
content: 'hello',
});
expect(patched.processingList?.[1].status).toBe(ProcessingEnum.FAILED);
});
});

View File

@@ -0,0 +1,137 @@
import {
AgentComponentTypeEnum,
ConversationEventTypeEnum,
} from '@/types/enums/agent';
import { ProcessingEnum } from '@/types/enums/common';
import type {
ConversationChatResponse,
ConversationFinalResult,
ExecuteResultInfo,
MessageInfo,
ProcessingInfo,
} from '@/types/interfaces/conversationInfo';
import { processInterventionSsePatch } from './processInterventionSsePatch';
function inferProcessingStatus(result: ExecuteResultInfo): ProcessingEnum {
return result.success === false
? ProcessingEnum.FAILED
: ProcessingEnum.FINISHED;
}
function inferSubEventType(
result: ExecuteResultInfo,
): ProcessingInfo['subEventType'] | 'ASK_QUESTION' | 'REQUEST_PERMISSION' {
if (result.name === 'Backend.Sandbox.Event.AskQuestion') {
return 'ASK_QUESTION';
}
if (result.name === 'Backend.Sandbox.Event.RequestPermission') {
return 'REQUEST_PERMISSION';
}
return null;
}
function toProcessingData(result: ExecuteResultInfo) {
return {
targetId: -1,
name: result.name,
originalTitle: null,
type: result.type,
status: inferProcessingStatus(result),
executingMessage: null,
result,
cardBindConfig: null,
cardData: null,
pageArgConfig: null,
subEventType: inferSubEventType(result),
};
}
function patchInterventionsFromExecuteResults(
currentMessage: MessageInfo,
finalResult: ConversationFinalResult,
): MessageInfo {
return (finalResult.componentExecuteResults || []).reduce((message, item) => {
const patch = processInterventionSsePatch(
{
completed: false,
data: toProcessingData(item),
error: '',
eventType: ConversationEventTypeEnum.PROCESSING,
requestId: '',
} as ConversationChatResponse,
message,
);
return patch || message;
}, currentMessage);
}
function reconcileProcessingList(
currentList: ProcessingInfo[] | undefined,
finalResult: ConversationFinalResult,
): ProcessingInfo[] {
const nextList = [...(currentList || [])];
const resultMap = new Map(
(finalResult.componentExecuteResults || [])
.filter((item) => item.executeId)
.map((item) => [item.executeId as string, item]),
);
const seenExecuteIds = new Set<string>();
for (let i = 0; i < nextList.length; i += 1) {
const item = nextList[i];
const executeId = item.executeId;
if (executeId && resultMap.has(executeId)) {
const executeResult = resultMap.get(executeId)!;
seenExecuteIds.add(executeId);
nextList[i] = {
...item,
name: executeResult.name,
type: executeResult.type as AgentComponentTypeEnum,
status: inferProcessingStatus(executeResult),
result: executeResult,
subEventType: inferSubEventType(
executeResult,
) as ProcessingInfo['subEventType'],
};
continue;
}
if (item.status === ProcessingEnum.EXECUTING) {
nextList[i] = {
...item,
status: ProcessingEnum.FAILED,
};
}
}
resultMap.forEach((executeResult, executeId) => {
if (seenExecuteIds.has(executeId)) {
return;
}
nextList.push({
...toProcessingData(executeResult),
executeId,
} as ProcessingInfo);
});
return nextList;
}
export function reconcileFinalMessageState(
currentMessage: MessageInfo,
finalResult: ConversationFinalResult,
): MessageInfo {
const messageWithInterventions = patchInterventionsFromExecuteResults(
currentMessage,
finalResult,
);
return {
...messageWithInterventions,
processingList: reconcileProcessingList(
messageWithInterventions.processingList,
finalResult,
),
};
}

View File

@@ -0,0 +1,104 @@
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import { describe, expect, it } from 'vitest';
import { reconcileMcpAskHydratedMessageList } from './reconcileMcpAskHydratedStatus';
const askInteraction = {
input: {
toolName: 'nuwax_ask_question' as const,
schemaVersion: 'nuwax.mcp_ask.v2',
requestId: 'ask-1',
revision: 1,
sessionId: 'session-1',
title: '历史表单',
ui: {
version: 'nuwax.interaction.v2',
presentation: 'inline' as const,
title: '历史表单',
fields: [],
},
},
toolCallId: 'call-1',
responseStatus: 'pending' as const,
};
describe('reconcileMcpAskHydratedMessageList', () => {
it('marks pending interactions as submitted when resume message exists', () => {
const askMessage = {
id: 'assistant-ask',
mcpAskInteractions: [askInteraction],
} as MessageInfo;
const resumeMessage = {
id: 'user-resume',
text: '我已填写「历史表单」,表单内容如下:\n\n主题AI',
} as MessageInfo;
const [reconciled] = reconcileMcpAskHydratedMessageList(
[askMessage],
[askMessage, resumeMessage],
);
expect(reconciled.mcpAskInteractions?.[0].responseStatus).toBe('submitted');
});
it('keeps pending interactions when no resume message exists', () => {
const askMessage = {
id: 'assistant-ask',
mcpAskInteractions: [askInteraction],
} as MessageInfo;
const [reconciled] = reconcileMcpAskHydratedMessageList([askMessage]);
expect(reconciled.mcpAskInteractions?.[0].responseStatus).toBe('pending');
});
it('does not mark a second same-title ask as submitted by the first resume', () => {
const sharedTitle = '补充回复';
const firstAsk = {
...askInteraction,
input: {
...askInteraction.input,
requestId: 'ask-first',
title: sharedTitle,
ui: {
...askInteraction.input.ui,
title: sharedTitle,
},
},
responseStatus: 'pending' as const,
};
const secondAsk = {
...askInteraction,
input: {
...askInteraction.input,
requestId: 'ask-second',
title: sharedTitle,
ui: {
...askInteraction.input.ui,
title: sharedTitle,
},
},
toolCallId: 'call-2',
responseStatus: 'pending' as const,
};
const askMessage = {
id: 'assistant-ask',
index: 1,
mcpAskInteractions: [firstAsk, secondAsk],
} as MessageInfo;
const resumeMessage = {
id: 'user-resume',
index: 2,
text: `我已填写「${sharedTitle}」,表单内容如下:\n\n主题AI\n<!--nuwax-mcp-ask-request-id:ask-first-->`,
} as MessageInfo;
const [reconciled] = reconcileMcpAskHydratedMessageList(
[askMessage],
[askMessage, resumeMessage],
);
expect(reconciled.mcpAskInteractions?.[0].responseStatus).toBe('submitted');
expect(reconciled.mcpAskInteractions?.[1].responseStatus).toBe('pending');
});
});

View File

@@ -0,0 +1,82 @@
import type { MessageInfo } from '@/types/interfaces/conversationInfo';
import type { McpAskInteraction } from '../types/mcpAskIntervention';
import {
hasMcpAskResumeMessage,
sortMessagesByConversationIndex,
} from './mcpAskResumeMessage';
function isAwaitingUserResponse(
status: McpAskInteraction['responseStatus'] | undefined,
): boolean {
const value = status ?? 'pending';
return value === 'pending' || value === 'submitting';
}
/**
* 将仍为 pending 的 hydrated 交互与完整会话上下文对齐:
* 若已存在 resume 用户消息,则标记为 submitted避免 dock 误弹。
*/
export function reconcileMessageMcpAskHydratedStatus(
message: MessageInfo,
contextMessageList: MessageInfo[],
containingMessageIndex?: number,
): MessageInfo {
const interactions = message.mcpAskInteractions;
if (!interactions?.length) {
return message;
}
const resolvedContainingMessageIndex =
containingMessageIndex ??
contextMessageList.findIndex(
(item) => item.id === message.id || item.index === message.index,
);
let changed = false;
const nextInteractions = interactions.map((interaction) => {
if (!isAwaitingUserResponse(interaction.responseStatus)) {
return interaction;
}
if (
!hasMcpAskResumeMessage(contextMessageList, interaction, {
containingMessageIndex:
resolvedContainingMessageIndex >= 0
? resolvedContainingMessageIndex
: undefined,
})
) {
return interaction;
}
changed = true;
return {
...interaction,
responseStatus: 'submitted' as const,
};
});
if (!changed) {
return message;
}
return {
...message,
mcpAskInteractions: nextInteractions,
};
}
export function reconcileMcpAskHydratedMessageList(
messageList: MessageInfo[],
contextMessageList: MessageInfo[] = messageList,
): MessageInfo[] {
const sortedContext = sortMessagesByConversationIndex(contextMessageList);
return messageList.map((message) => {
const containingMessageIndex = sortedContext.findIndex(
(item) => item.id === message.id || item.index === message.index,
);
return reconcileMessageMcpAskHydratedStatus(
message,
sortedContext,
containingMessageIndex >= 0 ? containingMessageIndex : undefined,
);
});
}

View File

@@ -0,0 +1,45 @@
@import '@/styles/token';
.diff-view-root {
flex: 1;
min-height: 0;
height: 100%;
overflow: auto;
background-color: @colorBgContainer;
-webkit-overflow-scrolling: touch;
// 与 AppDev DiffViewItem 保持一致,避免表格边框异常
table,
tr,
td,
th {
border: none !important;
box-shadow: none !important;
}
pre {
margin: 0 !important;
padding: 0 12px !important;
background: transparent !important;
border: none !important;
font-family: @fontFamilyCode !important;
font-size: @fontSizeSm !important;
white-space: pre !important;
}
td {
padding: 1px 0 !important;
line-height: 1.35 !important;
vertical-align: middle !important;
padding-right: 6px !important;
}
/* 限制行号列宽度,防止在某些情况下过宽 */
td[class*='diff-line-num'],
td[class*='diff-line-old-num'],
td[class*='diff-line-new-num'] {
width: 40px !important;
min-width: 40px !important;
max-width: 50px !important;
}
}

View File

@@ -0,0 +1,120 @@
import { getLanguageFromFile } from '@/utils/monacoConfig';
import { generateDiffFile } from '@git-diff-view/file';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import '@git-diff-view/react/styles/diff-view.css';
import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import styles from './index.less';
const cx = classNames.bind(styles);
/** diff 主题 */
export type GitDiffViewTheme = 'light' | 'dark';
export { DiffModeEnum };
export interface ChangeFileGitDiffViewProps {
/** 文件 ID用于 diff 实例刷新) */
fileId: string;
/** 文件名(用于语法高亮) */
fileName: string;
/** 原始文件内容 */
originalContent: string;
/** 修改后的文件内容 */
modifiedContent: string;
/**
* diff 主题;不传时跟随页面 `data-theme` 自动切换
* @default 跟随 document.documentElement[data-theme]
*/
diffViewTheme?: GitDiffViewTheme;
/**
* diff 展示模式:并排 / 统一
* @default DiffModeEnum.Split
*/
diffViewMode?: DiffModeEnum;
className?: string;
}
const DEFAULT_DIFF_VIEW_MODE = DiffModeEnum.Split;
/**
* 基于 @git-diff-view/react 的 Git 风格 diff 展示
* 用于源代码管理中的文件变更对比(只读)
*/
const ChangeFileGitDiffView: React.FC<ChangeFileGitDiffViewProps> = ({
fileId,
fileName,
originalContent,
modifiedContent,
diffViewTheme: diffViewThemeProp,
diffViewMode = DEFAULT_DIFF_VIEW_MODE,
className,
}) => {
const isThemeControlled = diffViewThemeProp !== undefined;
const [autoTheme, setAutoTheme] = useState<GitDiffViewTheme>('light');
/** 未传入 diffViewTheme 时,跟随页面 data-theme */
useEffect(() => {
if (isThemeControlled) {
return;
}
const root = document.documentElement;
const syncTheme = () => {
setAutoTheme(
root.getAttribute('data-theme') === 'dark' ? 'dark' : 'light',
);
};
syncTheme();
const observer = new MutationObserver(syncTheme);
observer.observe(root, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, [isThemeControlled]);
const resolvedTheme = diffViewThemeProp ?? autoTheme;
const diffFile = useMemo(() => {
const language = getLanguageFromFile(fileName);
const file = generateDiffFile(
fileName,
originalContent ?? '',
fileName,
modifiedContent ?? '',
language,
language,
);
file.initTheme(resolvedTheme);
file.init();
if (diffViewMode === DiffModeEnum.Unified) {
file.buildUnifiedDiffLines();
} else {
file.buildSplitDiffLines();
}
return file;
}, [
fileId,
fileName,
originalContent,
modifiedContent,
resolvedTheme,
diffViewMode,
]);
return (
<div className={cx(styles['diff-view-root'], className)}>
<DiffView
key={`${fileId}-${resolvedTheme}-${diffViewMode}`}
diffFile={diffFile}
diffViewMode={diffViewMode}
diffViewTheme={resolvedTheme}
diffViewHighlight
diffViewWrap
diffViewFontSize={12}
/>
</div>
);
};
export default ChangeFileGitDiffView;

View File

@@ -0,0 +1,94 @@
import { SvgIcon } from '@/components/base';
import TooltipIcon from '@/components/custom/TooltipIcon';
import { dict } from '@/services/i18nRuntime';
import { ClearOutlined, ReloadOutlined } from '@ant-design/icons';
import { Badge, Button } from 'antd';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import styles from './index.less';
const cx = classNames.bind(styles);
export interface DevLogActionsProps {
/** 最新日志块中是否包含错误(控制错误徽标与一键修复按钮) */
hasErrorInLatestBlock?: boolean;
/** 最新错误日志内容(一键修复时发送) */
latestErrorLogs?: string;
/** 对话是否进行中(禁用一键修复) */
isChatLoading?: boolean;
/** 将日志内容加入对话 */
onAddToChat?: (content: string, isAuto?: boolean) => void;
/** 一键修复前重置自动重试状态 */
onResetAutoRetry?: () => void;
/** 刷新日志 */
onRefresh?: () => void;
/** 清空日志 */
onClear?: () => void;
}
/**
* 开发服务器日志操作按钮组(一键修复 / 刷新 / 清空)
* 作为 ConversationBottomConsole 的 extra 内容嵌入头部
*/
const DevLogActions: React.FC<DevLogActionsProps> = ({
hasErrorInLatestBlock = false,
latestErrorLogs,
isChatLoading = false,
onAddToChat,
onResetAutoRetry,
onRefresh,
onClear,
}) => {
const handleFindLatestErrorLogs = useCallback(() => {
if (!onAddToChat || isChatLoading || !onResetAutoRetry) {
return;
}
onResetAutoRetry();
if (latestErrorLogs) {
onAddToChat(latestErrorLogs, true);
}
}, [onAddToChat, isChatLoading, onResetAutoRetry, latestErrorLogs]);
return (
<div className={cx(styles['console-log-actions'])}>
{hasErrorInLatestBlock && onAddToChat && (
<>
<TooltipIcon
title={dict('PC.Pages.AppDevDevLogConsole.latestLogsContainErrors')}
placement="top"
>
<Badge status="error" />
</TooltipIcon>
<Button
size="small"
danger
className={cx(styles['console-quick-fix-btn'])}
disabled={isChatLoading}
icon={
<SvgIcon name="icons-common-stars" style={{ fontSize: 12 }} />
}
onClick={handleFindLatestErrorLogs}
>
{dict('PC.Pages.AppDevDevLogConsole.quickIssueFix')}
</Button>
</>
)}
<TooltipIcon
title={dict('PC.Pages.AppDevDevLogConsole.refreshLogs')}
placement="top"
className={cx(styles['console-action-btn'])}
icon={<ReloadOutlined style={{ fontSize: 14 }} />}
onClick={onRefresh}
/>
<TooltipIcon
title={dict('PC.Pages.AppDevDevLogConsole.clearLogs')}
placement="top"
className={cx(styles['console-action-btn'])}
icon={<ClearOutlined style={{ fontSize: 14 }} />}
onClick={onClear}
/>
</div>
);
};
export default DevLogActions;

View File

@@ -0,0 +1,138 @@
@import '@/styles/color.less';
@import '@/styles/token.less';
.dev-log-panel {
height: 100%;
overflow: hidden;
background: #f5f5dc;
.log-list {
height: 100%;
overflow-y: auto;
padding: 8px;
.log-group {
display: flex;
flex-direction: row;
margin-bottom: 4px;
background: transparent;
cursor: pointer;
border-radius: 4px;
&:hover {
background: rgba(139, 69, 19, 10%);
}
.log-group-header {
padding: 4px 8px;
transition: all 0.2s ease;
.group-header-left {
display: flex;
align-items: center;
.group-timestamp {
display: inline-block;
min-width: 132px;
font-size: @fontSizeSm;
color: #8b4513;
font-family: @fontFamilyCode;
}
}
}
.log-group-content {
flex: 1;
padding: 0;
background: transparent;
.log-content-block {
background: transparent;
border: none;
border-radius: 0;
.log-text {
margin: 0;
padding: 4px 8px;
font-family: @fontFamilyCode;
font-size: @fontSizeSm;
white-space: pre-wrap;
word-break: break-all;
color: #8b4513;
background: transparent;
.log-line {
&.log-line-error {
color: #d32f2f;
}
&.log-line-warn {
color: #d46b08;
}
&.log-line-info {
color: #1890ff;
}
&.log-line-normal {
color: #8b4513;
}
}
}
}
}
}
}
.empty-logs {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: @colorTextTertiary;
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
color: @colorTextTertiary;
}
p {
margin: 0 0 16px;
font-size: @fontSize;
}
}
.loading-logs {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: @colorTextTertiary;
.loading-spinner {
font-size: 32px;
margin-bottom: 16px;
color: @colorPrimary;
animation: dev-log-spin 1s linear infinite;
}
p {
margin: 0;
font-size: @fontSize;
color: @colorTextSecondary;
}
}
}
@keyframes dev-log-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,254 @@
/**
* 开发日志面板(仅内容区,供底部合集控制台嵌入使用)
*/
import {
formatTimestampDisplay,
groupLogsByTimestamp,
type LogGroup,
} from '@/pages/AppDev/utils/devLogParser';
import { t } from '@/services/i18nRuntime';
import type { DevLogEntry } from '@/types/interfaces/appDev';
import { LogLevel } from '@/types/interfaces/appDev';
import { BugOutlined, ReloadOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
import classNames from 'classnames';
import React, { useEffect, useRef, useState } from 'react';
import styles from './index.less';
const cx = classNames.bind(styles);
export interface DevLogPanelProps {
/** 日志数据 */
logs: DevLogEntry[];
/** 是否正在加载 */
isLoading?: boolean;
/** 最后加载的行号 */
lastLine?: number;
/** 添加日志到聊天框回调 */
onAddToChat?: (content: string, isAuto?: boolean) => void;
/** 是否正在发送消息 */
isChatLoading?: boolean;
/** 获取最新错误日志(自动处理问题) */
latestErrorLogs?: string;
/** 自定义根节点 className */
className?: string;
}
/**
* 单组日志内容块
* 按日志级别着色组级别取组内最高级别error > warn > info > normal
*/
const LogContentBlock: React.FC<{ logs: DevLogEntry[] }> = ({ logs }) => {
/** 组内最高日志级别(决定整块底色) */
const getGroupLevel = () => {
if (logs.some((log) => log.level === LogLevel.ERROR)) return 'error';
if (logs.some((log) => log.level === LogLevel.WARN)) return 'warn';
if (logs.some((log) => log.level === LogLevel.INFO)) return 'info';
return 'normal';
};
/** 渲染日志行:去掉时间戳前缀与行号前缀后按级别着色 */
const renderHighlightedContent = () =>
logs.map((log, index) => {
let content = log.content.replace(
/\[\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\]\s*/,
'',
);
content = content.replace(
/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?\s*/,
'',
);
content = content.replace(/^\d+\|\s*/, '');
return (
<div
key={`${log.line}-${index}`}
className={cx(
styles['log-line'],
styles[`log-line-${log.level.toLowerCase()}`],
)}
>
<span className={cx(styles['log-content'])}>{content}</span>
</div>
);
});
return (
<div
className={cx(
styles['log-content-block'],
styles[`log-content-block-${getGroupLevel()}`],
)}
>
<div className={cx(styles['log-text'])}>{renderHighlightedContent()}</div>
</div>
);
};
/**
* 单个日志分组项(时间戳 + 内容块)
* 点击整组可将组内日志内容加入对话
*/
const LogGroupItem: React.FC<{
group: LogGroup;
onAddToChat?: (content: string, isAuto?: boolean) => void;
}> = ({ group, onAddToChat }) => {
/** 组内日志拼接为一段文本(用于加入对话) */
const groupLogs = group.logs
.map((log) => log.content)
.join('\n')
.trim();
/** 点击日志组将整组内容加入对话手动触发isAuto=false */
const handleClick = () => {
if (onAddToChat && groupLogs) {
onAddToChat(groupLogs, false);
}
};
return (
<div
className={cx(styles['log-group'])}
onClick={handleClick}
style={{ cursor: onAddToChat ? 'pointer' : 'default' }}
>
<div className={cx(styles['log-group-header'])}>
<Tooltip
title={
onAddToChat
? t('PC.Pages.AppDevDevLogConsole.clickToAddToChat')
: ''
}
>
<div className={cx(styles['group-header-left'])}>
<span className={cx(styles['group-timestamp'])}>
{formatTimestampDisplay(group.timestamp) || '\u00a0'}
</span>
</div>
</Tooltip>
</div>
<div className={cx(styles['log-group-content'])}>
<LogContentBlock logs={group.logs} />
</div>
</div>
);
};
/**
* 开发日志列表面板
*/
const DevLogPanel: React.FC<DevLogPanelProps> = ({
logs,
isLoading = false,
lastLine = 0,
onAddToChat,
isChatLoading = false,
latestErrorLogs,
className,
}) => {
/** 日志列表容器引用(用于自动滚动到底部) */
const logListRef = useRef<HTMLDivElement>(null);
/** 按时间戳分组后的日志 */
const [logGroups, setLogGroups] = useState<LogGroup[]>([]);
/** 是否显示加载态(带最短展示时长,避免闪烁) */
const [showLoading, setShowLoading] = useState(false);
/** 延迟隐藏加载态的定时器 */
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
/** 加载态开始展示的时间戳(用于计算最短展示时长) */
const loadingStartTimeRef = useRef<number | null>(null);
/** 日志变化时重新按时间戳分组 */
useEffect(() => {
setLogGroups(groupLogsByTimestamp(logs));
}, [logs]);
/**
* 加载态控制仅首次加载lastLine <= 1时显示
* 且保证至少展示 500ms避免快速加载时一闪而过
*/
useEffect(() => {
if (loadingTimerRef.current) {
clearTimeout(loadingTimerRef.current);
loadingTimerRef.current = null;
}
if (isLoading && lastLine <= 1) {
// 首次加载开始:记录起始时间并显示加载态
if (!showLoading) {
loadingStartTimeRef.current = Date.now();
setShowLoading(true);
}
} else if (!isLoading && showLoading) {
// 加载结束:不足 500ms 时延迟隐藏,补足最短展示时长
const elapsed = loadingStartTimeRef.current
? Date.now() - loadingStartTimeRef.current
: 0;
const remainingTime = Math.max(0, 500 - elapsed);
loadingTimerRef.current = setTimeout(() => {
setShowLoading(false);
loadingStartTimeRef.current = null;
}, remainingTime);
}
return () => {
if (loadingTimerRef.current) {
clearTimeout(loadingTimerRef.current);
loadingTimerRef.current = null;
}
};
}, [isLoading, lastLine, showLoading]);
/** 日志更新后自动滚动到底部setTimeout 等待 DOM 渲染完成) */
useEffect(() => {
if (logGroups.length > 0 && logListRef.current) {
setTimeout(() => {
if (logListRef.current) {
logListRef.current.scrollTop = logListRef.current.scrollHeight;
}
}, 0);
}
}, [logGroups.length, logGroups]);
/** 检测到最新错误日志时自动加入对话(对话进行中时跳过) */
useEffect(() => {
if (isChatLoading) return;
if (latestErrorLogs) {
onAddToChat?.(latestErrorLogs, true);
}
}, [logs, isChatLoading, latestErrorLogs, onAddToChat]);
return (
<div className={cx(styles['dev-log-panel'], className)}>
{showLoading ? (
/* 加载态 */
<div className={cx(styles['loading-logs'])}>
<div className={cx(styles['loading-spinner'])}>
<ReloadOutlined spin />
</div>
<p>{t('PC.Pages.AppDevDevLogConsole.loadingLogs')}</p>
</div>
) : logGroups.length > 0 ? (
/* 日志分组列表 */
<div ref={logListRef} className={cx(styles['log-list'])}>
{logGroups.map((group, index) => (
<LogGroupItem
key={`${group.timestamp}-${index}`}
group={group}
onAddToChat={onAddToChat}
/>
))}
</div>
) : (
/* 空态 */
<div className={cx(styles['empty-logs'])}>
<BugOutlined className={cx(styles['empty-icon'])} />
<p>{t('PC.Pages.AppDevDevLogConsole.noLogData')}</p>
</div>
)}
</div>
);
};
export default DevLogPanel;

View File

@@ -0,0 +1,302 @@
@import '@/styles/token';
@console-content-height: 240px;
@terminal-console-bg-light: #ffffff;
@terminal-console-fg-light: #1a1a1a;
@terminal-console-bg-dark: #000000;
@terminal-console-fg-dark: #d4d4d4;
.console {
display: flex;
flex-direction: column;
border-top: @lineWidth solid @colorSplit;
background-color: @colorBgContainer;
flex-shrink: 0;
.console-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: @paddingXs @paddingSm;
border-bottom: @lineWidth solid @colorSplit;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.console-tabs {
display: flex;
gap: @margin;
flex: 1;
min-width: 0;
}
.console-tab {
font-size: @fontSize;
color: @colorTextSecondary;
cursor: pointer;
padding: 2px 0;
transition: color 0.2s ease;
&:hover {
color: @colorPrimary;
}
&.active {
color: @colorPrimary;
}
}
.console-actions {
display: flex;
align-items: center;
gap: @marginXxs;
flex-shrink: 0;
margin-left: @marginSm;
}
.console-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: @colorTextSecondary;
border-radius: @borderRadius;
&.console-action-restart-btn {
width: auto;
height: auto;
}
&:hover {
color: @colorPrimary;
background-color: @colorPrimaryBg;
}
}
.console-log-actions {
display: flex;
align-items: center;
gap: @marginXxs;
margin-right: @marginXxs;
padding-right: @marginXxs;
border-right: @lineWidth solid @colorSplit;
}
.console-quick-fix-btn {
height: 24px;
padding: 0 8px;
font-size: @fontSizeSm;
}
&.console-expanded {
position: absolute;
inset: 0;
z-index: 10;
flex-shrink: 0;
min-height: 0;
.console-content {
flex: 1;
height: auto;
min-height: 0;
}
}
&.console-collapsed {
.console-content {
display: none;
}
}
&.console-hidden {
display: none;
}
.console-content {
position: relative;
height: @console-content-height;
flex-shrink: 0;
overflow: hidden;
background-color: transparent;
transition: background-color 0.2s ease;
}
&.console-terminal-dark .console-content {
background-color: @terminal-console-bg-dark;
}
.tab-pane {
position: absolute;
inset: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
z-index: 0;
&.tab-pane-active {
visibility: visible;
pointer-events: auto;
z-index: 1;
}
}
.console-body {
height: 100%;
overflow: auto;
padding: @paddingSm;
color: @terminal-console-fg-light;
font-family: Menlo, Monaco, Consolas, monospace;
font-size: @fontSizeSm;
line-height: @lineHeightLg;
white-space: pre-wrap;
word-break: break-all;
background-color: transparent;
transition: background-color 0.2s ease, color 0.2s ease;
}
&.console-terminal-dark .console-body {
color: @terminal-console-fg-dark;
background-color: transparent;
}
.console-empty {
color: @colorTextTertiary;
font-size: @fontSizeSm;
text-align: center;
padding: @paddingLg 0;
}
.container-retry-btn {
margin-top: @marginXxs;
}
.terminal-tab-stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.terminal-tab-stage-light {
background-color: @terminal-console-bg-light;
}
.terminal-tab-stage-dark {
background-color: @terminal-console-bg-dark;
}
.terminal-tab-overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.24s ease, visibility 0.24s ease;
&.terminal-tab-overlay-visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
&.terminal-tab-overlay-instant {
transition: none;
}
}
.terminal-tab-stage-light .terminal-tab-overlay {
background-color: @terminal-console-bg-light;
}
.terminal-tab-stage-dark .terminal-tab-overlay {
background-color: @terminal-console-bg-dark;
}
.xterm-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.terminal-embedded {
width: 100%;
height: 100%;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
outline: none !important;
overflow: hidden;
:global(.xterm) {
height: 100%;
padding: @marginXxs @paddingSm @marginXxs;
box-sizing: border-box;
}
:global(.xterm-decoration-overview-ruler) {
opacity: 0.4;
}
}
.terminal-embedded-light {
background-color: @terminal-console-bg-light;
:global(.xterm .xterm-viewport) {
background-color: @terminal-console-bg-light;
&::-webkit-scrollbar {
width: @marginXs;
}
&::-webkit-scrollbar-thumb {
background: @colorFill;
border-radius: @borderRadiusSm;
&:hover {
background: @colorPrimary;
}
}
&::-webkit-scrollbar-track {
background: @colorFillTertiary;
}
}
}
.terminal-embedded-dark {
background-color: @terminal-console-bg-dark;
:global(.xterm .xterm-viewport) {
background-color: @terminal-console-bg-dark;
&::-webkit-scrollbar {
width: @marginXs;
}
&::-webkit-scrollbar-thumb {
background: @colorFillSecondary;
border-radius: @borderRadiusSm;
&:hover {
background: @cyan;
}
}
&::-webkit-scrollbar-track {
background: @colorFillQuaternary;
}
}
}
.dev-log-pane {
height: 100%;
overflow: hidden;
}
}

View File

@@ -0,0 +1,944 @@
import { SvgIcon } from '@/components/base';
import TooltipIcon from '@/components/custom/TooltipIcon';
import { SUCCESS_CODE } from '@/constants/codes.constants';
import { dict } from '@/services/i18nRuntime';
import {
apiEnsurePod,
apiKeepalivePod,
isEnsurePodThrottledError,
} from '@/services/vncDesktop';
import type { DevLogEntry } from '@/types/interfaces/appDev';
import {
DownOutlined,
FullscreenExitOutlined,
MoonOutlined,
ReloadOutlined,
SunOutlined,
UpOutlined,
} from '@ant-design/icons';
import { useRequest } from 'ahooks';
import { Button } from 'antd';
import classNames from 'classnames';
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import type { EmbeddedConsoleTerminalRef } from '../Terminal/EmbeddedConsoleTerminal';
import EmbeddedConsoleTerminal from '../Terminal/EmbeddedConsoleTerminal';
import { DEFAULT_TERMINAL_RECONNECT } from '../Terminal/terminalReconnect';
import type { TerminalWireProtocol } from '../Terminal/type';
import DevLogPanel from './DevLogPanel';
import styles from './index.less';
import {
CONSOLE_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_APPEARANCE,
getConsoleTerminalTheme,
type TerminalAppearanceMode,
} from './terminalTheme';
export type { TerminalAppearanceMode } from './terminalTheme';
const cx = classNames.bind(styles);
/** 底部控制台布局模式 */
export type ConsoleLayoutMode = 'default' | 'expanded' | 'collapsed';
/** 开发服务器日志相关配置(网页应用) */
export interface ConversationBottomConsoleDevLogProps {
logs: DevLogEntry[];
latestErrorLogs?: string;
isLoading?: boolean;
lastLine?: number;
onAddToChat?: (content: string, isAuto?: boolean) => void;
isChatLoading?: boolean;
}
export interface ConversationBottomConsoleProps {
/** 是否显示面板 @default true */
visible?: boolean;
/** 运行日志纯文本(会话智能体) */
runtimeLogs?: string;
/** 开发服务器结构化日志;传入后日志 Tab 使用 DevLogPanel */
devLog?: ConversationBottomConsoleDevLogProps;
/** 终端 WebSocket 地址;传入后终端 Tab 渲染 XtermTerminal */
wsUrl?: string;
/**
* 会话 ID传入后组件挂载时自动调用 apiEnsurePod 启动容器,
* 容器启动成功后才允许终端发起 WebSocket 连接。
* 若不传,则不启动容器,终端在有 wsUrl 时直接连接。
*/
conversationId?: number;
/**
* 容器启动成功后是否开启保活轮询 @default true
* 网页应用开发只需要确保/重启服务,不需要轮询保活接口。
*/
enableKeepalivePolling?: boolean;
/** WebSocket 子协议ttyd 需传 ['tty'] */
wsSubprotocols?: string | string[];
/** 终端消息格式('plain' | 'ttyd' */
wireProtocol?: TerminalWireProtocol;
/** 终端主题(受控);不传时由组件内部维护 */
terminalAppearance?: TerminalAppearanceMode;
/** 终端主题默认值(非受控) */
defaultTerminalAppearance?: TerminalAppearanceMode;
/** 终端主题切换回调 */
onTerminalAppearanceChange?: (mode: TerminalAppearanceMode) => void;
/** 是否显示终端主题切换按钮 @default true */
showTerminalAppearanceToggle?: boolean;
/** 信号值变化时将 expanded 布局重置为 default如外部布局变动 */
layoutResetSignal?: number;
/** 信号值变化时切到终端 Tab 并全屏展开(如点击「终端」入口) */
expandSignal?: number;
/** 信号值变化时切到终端 Tab折叠时恢复默认高度如点击「终端」图标 */
terminalSignal?: number;
/** 信号值变化时将面板折叠为仅保留头部 */
collapseSignal?: number;
/** 信号值变化时切到日志 Tab折叠时恢复默认高度如点击「日志」图标 */
logsSignal?: number;
/** 布局模式变化回调(供外部感知折叠/展开状态) */
onLayoutModeChange?: (mode: ConsoleLayoutMode) => void;
/** 激活 Tab 变化回调(供外部感知当前显示终端还是日志) */
onActiveTabChange?: (tab: 'terminal' | 'logs') => void;
/** 打开控制台时的默认 Tab @default 'terminal' */
defaultActiveTab?: 'terminal' | 'logs';
/** 打开控制台时的默认布局模式 @default 'collapsed' */
defaultLayoutMode?: ConsoleLayoutMode;
/** 是否显示日志 Tab @default true */
showLogsTab?: boolean;
/** 头部操作区额外内容(渲染在内置按钮之前),仅日志 Tab 激活时显示 */
logsExtra?: React.ReactNode;
className?: string;
}
/**
* 底部终端 + 日志合集面板
* - 终端 TabXtermTerminalwsUrl或空态
* - 日志 TabDevLogPaneldevLog或 runtimeLogs 纯文本
*/
const ConversationBottomConsole: React.FC<ConversationBottomConsoleProps> = ({
visible = true,
runtimeLogs = '',
devLog,
wsUrl,
conversationId,
enableKeepalivePolling = true,
wsSubprotocols,
wireProtocol,
terminalAppearance: terminalAppearanceProp,
defaultTerminalAppearance = DEFAULT_TERMINAL_APPEARANCE,
onTerminalAppearanceChange,
showTerminalAppearanceToggle = true,
layoutResetSignal,
expandSignal,
terminalSignal,
collapseSignal,
logsSignal,
onLayoutModeChange,
onActiveTabChange,
defaultActiveTab = 'terminal',
defaultLayoutMode = 'collapsed',
showLogsTab = true,
logsExtra,
className,
}) => {
/** 当前激活的 Tab终端 / 日志) */
const [activeTab, setActiveTab] = useState<'terminal' | 'logs'>(
showLogsTab ? defaultActiveTab : 'terminal',
);
/** 面板布局模式default 默认高度 / expanded 全屏 / collapsed 仅保留头部 */
const [layoutMode, setLayoutMode] =
useState<ConsoleLayoutMode>(defaultLayoutMode);
/** 终端主题(非受控时的内部状态) */
const [internalAppearance, setInternalAppearance] =
useState<TerminalAppearanceMode>(defaultTerminalAppearance);
/** 终端实例引用(用于主动 refresh 修复切换 Tab 后的渲染残影) */
const terminalRef = useRef<EmbeddedConsoleTerminalRef>(null);
/** 终端当前连接状态(用于控制断连后再触发容器兜底重启) */
const terminalConnectedRef = useRef<boolean>(false);
/** 终端是否曾成功连接过(避免首次连接前误判为”断开”) */
const terminalConnectedOnceRef = useRef<boolean>(false);
/** 用户是否曾展开过终端面板(首次展开后才触发容器启动/WS连接 */
const terminalActivatedRef = useRef<boolean>(false);
/** 上一次 layoutMode 值,用于检测首次从 collapsed 展开 */
const prevLayoutModeRef = useRef<ConsoleLayoutMode>(defaultLayoutMode);
/** 终端自动重连耗尽后显示手动重连按钮 */
const [showTerminalReconnect, setShowTerminalReconnect] =
useState<boolean>(false);
/** 手动重连 loading */
const [isTerminalReconnecting, setIsTerminalReconnecting] =
useState<boolean>(false);
/** 断连后预热容器的防抖定时器(自动重连前尽量 ensurePod */
const ensurePodOnDisconnectTimerRef = useRef<number | null>(null);
/** ensure 进行中,避免 handleFirstExpand 重复触发 */
const ensureInFlightRef = useRef<boolean>(false);
/** 是否需要先启动服务再连接终端(由 conversationId 决定) */
const requiresServiceStart = Boolean(conversationId);
/**
* 容器启动状态机:
* - idle未启动 / 未传 conversationId后者表示无需容器终端可直接连 WS
* - startingapiEnsurePod 调用中,终端等待
* - running容器已就绪终端可连接保活轮询中
* - error容器启动或保活失败显示重试按钮终端不可连接
*/
const [containerStatus, setContainerStatus] = useState<
'idle' | 'starting' | 'running' | 'error'
>('idle');
const containerStatusRef = useRef(containerStatus);
containerStatusRef.current = containerStatus;
/**
* 保活轮询useRequest 自动管理定时器、页面可见性暂停/恢复)
* 使用 ref 持有 run/cancel避免闭包陷阱并保证 startContainer 引用稳定
*/
const runKeepaliveRef = useRef<(cId: number) => void>(() => {});
const stopKeepaliveRef = useRef<() => void>(() => {});
const { run: runKeepalivePodPolling, cancel: stopKeepalivePodPolling } =
useRequest(apiKeepalivePod, {
manual: true,
loadingDelay: 30000,
debounceWait: 5000,
pollingInterval: 60000, // 60 秒保活一次
pollingWhenHidden: false, // 屏幕不可见时暂停
pollingErrorRetryCount: -1, // 网络错误无限重试
// 页面重新可见时,调用 apiEnsurePod 确保容器运行
onBefore: async (params) => {
const shouldEnsureContainer =
document.visibilityState === 'visible' &&
params[0] &&
terminalConnectedOnceRef.current &&
!terminalConnectedRef.current;
if (shouldEnsureContainer) {
try {
await apiEnsurePod(params[0]);
} catch (error) {
console.error('[keepalive] apiEnsurePod failed:', error);
}
}
},
onSuccess: (result) => {
// HTTP 成功但业务码失败 → 视为保活失败,停止轮询并显示错误
if (result.code !== SUCCESS_CODE) {
setContainerStatus('error');
stopKeepaliveRef.current();
}
},
onError: (error) => {
console.error('[keepalive] Pod keepalive error:', error);
setContainerStatus('error');
stopKeepaliveRef.current();
},
});
// 同步 ref保证 startContainer / useLayoutEffect 中始终拿到最新引用
runKeepaliveRef.current = runKeepalivePodPolling;
stopKeepaliveRef.current = stopKeepalivePodPolling;
/** 启动容器并开启保活轮询 */
const startContainer = useCallback(
async (cId: number): Promise<boolean> => {
setContainerStatus('starting');
try {
const { code } = await apiEnsurePod(cId);
if (code === SUCCESS_CODE) {
setContainerStatus('running');
if (enableKeepalivePolling) {
runKeepaliveRef.current(cId);
}
return true;
} else {
setContainerStatus('error');
return false;
}
} catch (error: unknown) {
// 智能体电脑等外部刚调过 ensure 时会被 5s 限流,容器已在运行,终端可直接连接
if (isEnsurePodThrottledError(error)) {
setContainerStatus('running');
if (enableKeepalivePolling) {
runKeepaliveRef.current(cId);
}
return true;
}
setContainerStatus('error');
return false;
}
},
[enableKeepalivePolling],
);
/** 挂载时仅清理状态,不自动启动容器;等用户首次展开时触发 */
useLayoutEffect(() => {
ensureInFlightRef.current = false;
if (!conversationId) {
setContainerStatus('idle');
terminalConnectedRef.current = false;
terminalConnectedOnceRef.current = false;
return;
}
// 清理旧的保活轮询,重置状态,但不启动容器
if (enableKeepalivePolling) {
stopKeepaliveRef.current();
}
setContainerStatus('idle');
terminalConnectedRef.current = false;
terminalConnectedOnceRef.current = false;
return () => {
if (enableKeepalivePolling) {
stopKeepaliveRef.current();
}
};
}, [conversationId, enableKeepalivePolling]);
useEffect(() => {
return () => {
if (ensurePodOnDisconnectTimerRef.current) {
window.clearTimeout(ensurePodOnDisconnectTimerRef.current);
ensurePodOnDisconnectTimerRef.current = null;
}
};
}, [conversationId]);
/** 用户首次展开终端面板时,触发容器启动 / 终端直接连接 */
const handleFirstExpand = useCallback(() => {
terminalActivatedRef.current = true;
if (!conversationId || ensureInFlightRef.current) {
return;
}
const status = containerStatusRef.current;
if (status === 'starting' || status === 'running') {
return;
}
ensureInFlightRef.current = true;
void startContainer(conversationId).finally(() => {
ensureInFlightRef.current = false;
});
}, [conversationId, startContainer]);
/** 终端可见后 fit / sync / focus委托给 EmbeddedConsoleTerminal */
const syncTerminalLayoutAndFocus = useCallback(() => {
terminalRef.current?.restoreAfterVisibilityChange();
}, []);
/** 折叠恢复可见后多轮 restore覆盖布局动画与浏览器重排延迟 */
const scheduleTerminalRestoreAfterExpand = useCallback(() => {
const restore = () => terminalRef.current?.restoreAfterVisibilityChange();
window.requestAnimationFrame(() => {
restore();
window.setTimeout(restore, 50);
window.setTimeout(restore, 150);
window.setTimeout(restore, 350);
});
}, []);
/** 监听 layoutMode 变化,检测首次从 collapsed 展开 */
useEffect(() => {
const prevMode = prevLayoutModeRef.current;
// 两种情况触发首次启动:
// 1. 首次渲染时已是非折叠状态(如 defaultLayoutMode="default"
// 2. 从折叠展开为非折叠状态
const isFirstExpand =
(!terminalActivatedRef.current && layoutMode !== 'collapsed') ||
(layoutMode !== 'collapsed' && prevMode === 'collapsed');
if (isFirstExpand) {
handleFirstExpand();
}
// 从折叠恢复:终端仍连着 WS 但 xterm 在 display:none 内尺寸/焦点会失效
if (
layoutMode !== 'collapsed' &&
prevMode === 'collapsed' &&
activeTab === 'terminal' &&
wsUrl &&
visible
) {
scheduleTerminalRestoreAfterExpand();
}
prevLayoutModeRef.current = layoutMode;
}, [
activeTab,
handleFirstExpand,
layoutMode,
scheduleTerminalRestoreAfterExpand,
visible,
wsUrl,
]);
/**
* 终端连接开关(完全由内部容器状态控制):
* - 未传 conversationId无需启动服务有 wsUrl 且面板可见时直接连接
* - 传入 conversationId仅 containerStatus === 'running' 后连接
* - starting / error服务未就绪终端不可连接
* 面板折叠或整体隐藏时不建立连接,避免在 display:none 容器内 init ttyd 导致无法输入
* 例外:用户已首次展开过终端面板后,即使折叠也保持连接不断
* 终端重连失败展示重试面板时暂停自动连接
*/
const isServiceReadyForTerminal = requiresServiceStart
? containerStatus === 'running'
: true;
const terminalAutoConnect =
isServiceReadyForTerminal &&
visible &&
!showTerminalReconnect &&
(layoutMode !== 'collapsed' || terminalActivatedRef.current);
/** 上一次 visible 值(用于识别「重新打开」时机) */
const prevVisibleRef = useRef(visible);
/** 外部信号上一次值undefined 表示未消费,避免 lazy mount 时与当前 signal 相同而跳过) */
const prevLayoutResetSignalRef = useRef<number | undefined>(undefined);
const prevExpandSignalRef = useRef<number | undefined>(undefined);
const prevTerminalSignalRef = useRef<number | undefined>(undefined);
const prevCollapseSignalRef = useRef<number | undefined>(undefined);
const prevLogsSignalRef = useRef<number | undefined>(undefined);
/** 日志 Tab 是否使用结构化日志面板DevLogPanel */
const useDevLogPanel = Boolean(devLog);
/** 终端主题是否为受控模式 */
const isControlled = terminalAppearanceProp !== undefined;
const terminalAppearance = isControlled
? terminalAppearanceProp
: internalAppearance;
const isLightTerminal = terminalAppearance === 'light';
/** 面板重新打开时恢复默认 Tab若上次处于折叠状态则恢复默认高度 */
useEffect(() => {
if (visible && !prevVisibleRef.current) {
setActiveTab(defaultActiveTab);
setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : prev));
}
prevVisibleRef.current = visible;
}, [visible, defaultActiveTab]);
/** 切换终端深浅色主题(受控模式下仅触发回调) */
const handleToggleTerminalAppearance = useCallback(() => {
const next: TerminalAppearanceMode =
terminalAppearance === 'dark' ? 'light' : 'dark';
if (!isControlled) {
setInternalAppearance(next);
}
onTerminalAppearanceChange?.(next);
}, [isControlled, onTerminalAppearanceChange, terminalAppearance]);
useEffect(() => {
if (
activeTab !== 'terminal' ||
!wsUrl ||
!visible ||
layoutMode === 'collapsed' ||
(requiresServiceStart && containerStatus !== 'running')
) {
return;
}
const timer = window.setTimeout(syncTerminalLayoutAndFocus, 100);
const retryTimer = window.setTimeout(syncTerminalLayoutAndFocus, 300);
return () => {
window.clearTimeout(timer);
window.clearTimeout(retryTimer);
};
}, [
activeTab,
containerStatus,
requiresServiceStart,
layoutMode,
syncTerminalLayoutAndFocus,
wsUrl,
terminalAppearance,
visible,
]);
/** 外部信号将全屏布局重置回默认高度collapsed 状态保持不变) */
useEffect(() => {
if (
layoutResetSignal === undefined ||
layoutResetSignal === prevLayoutResetSignalRef.current
) {
return;
}
prevLayoutResetSignalRef.current = layoutResetSignal;
if (!layoutResetSignal) return;
setLayoutMode((prev) => (prev === 'expanded' ? 'default' : prev));
}, [layoutResetSignal]);
/** 外部信号:切到终端 Tab 并全屏展开 */
useEffect(() => {
if (
expandSignal === undefined ||
expandSignal === prevExpandSignalRef.current
) {
return;
}
prevExpandSignalRef.current = expandSignal;
if (!expandSignal) return;
setActiveTab('terminal');
setLayoutMode('expanded');
}, [expandSignal]);
/** 外部信号:切到终端 Tab折叠时恢复默认高度 */
useEffect(() => {
if (
terminalSignal === undefined ||
terminalSignal === prevTerminalSignalRef.current
) {
return;
}
prevTerminalSignalRef.current = terminalSignal;
if (!terminalSignal) return;
setActiveTab('terminal');
setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : prev));
}, [terminalSignal]);
/** 外部信号:折叠面板(仅保留头部) */
useEffect(() => {
if (
collapseSignal === undefined ||
collapseSignal === prevCollapseSignalRef.current
) {
return;
}
prevCollapseSignalRef.current = collapseSignal;
if (!collapseSignal) return;
setLayoutMode('collapsed');
}, [collapseSignal]);
/** 外部信号:切到日志 Tab折叠时恢复默认高度 */
useEffect(() => {
if (
logsSignal === undefined ||
!showLogsTab ||
logsSignal === prevLogsSignalRef.current
) {
return;
}
prevLogsSignalRef.current = logsSignal;
if (!logsSignal) return;
setActiveTab('logs');
setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : prev));
}, [logsSignal, showLogsTab]);
/** 布局模式变化时通知外部 */
useEffect(() => {
onLayoutModeChange?.(layoutMode);
}, [layoutMode, onLayoutModeChange]);
/** 激活 Tab 变化时通知外部 */
useEffect(() => {
onActiveTabChange?.(activeTab);
}, [activeTab, onActiveTabChange]);
/** 全屏展开 / 恢复默认高度 */
const handleToggleExpand = () => {
setLayoutMode((prev) => (prev === 'expanded' ? 'default' : 'expanded'));
};
/** 折叠到仅头部 / 恢复默认高度 */
const handleToggleCollapse = () => {
setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : 'collapsed'));
};
/**
* 手动重连:有 conversationId 时始终 ensurePod 再连终端。
* 不能仅依赖 containerStatus === 'running' 跳过启动——未开保活或保活间隔内
* 容器已停而前端状态未更新时,必须先拉起服务才能连终端。
* apiEnsurePod 对已在运行的容器是幂等的,重复调用开销可接受。
*/
const handleReconnectTerminal = useCallback(async () => {
if (!wsUrl || isTerminalReconnecting) {
return;
}
setIsTerminalReconnecting(true);
try {
if (conversationId) {
const isReady = await startContainer(conversationId);
if (!isReady) {
return;
}
}
setShowTerminalReconnect(false);
setActiveTab('terminal');
setLayoutMode((prev) => (prev === 'collapsed' ? 'default' : prev));
window.requestAnimationFrame(() => {
terminalRef.current?.reconnect(wsUrl);
});
} finally {
setIsTerminalReconnecting(false);
}
}, [conversationId, isTerminalReconnecting, startContainer, wsUrl]);
/** 容器启动失败或终端连接失败时,展示统一的重启服务面板(叠加层内容) */
const renderTerminalRetryOverlay = () => (
<div
className={cx(
styles['console-empty'],
'flex',
'flex-col',
'items-center',
'content-center',
'h-full',
'gap-8',
)}
>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={isTerminalReconnecting}
className={cx(styles['container-retry-btn'])}
onClick={handleReconnectTerminal}
>
{dict('PC.Components.ConversationBottomConsole.retryStartContainer')}
</Button>
</div>
);
/** 切换 Tab折叠状态下点击 Tab 自动恢复默认高度 */
const handleTabClick = (tab: 'terminal' | 'logs') => {
const wasCollapsed = layoutMode === 'collapsed';
setActiveTab(tab);
if (wasCollapsed) {
setLayoutMode('default');
}
if (tab === 'terminal') {
if (wasCollapsed) {
scheduleTerminalRestoreAfterExpand();
} else {
window.setTimeout(syncTerminalLayoutAndFocus, 100);
}
}
};
/** 终端 Tab终端始终挂载加载/错误态以叠加层淡入淡出展示 */
const renderTerminalTab = () => {
const showStartingOverlay = containerStatus === 'starting';
const showRetryOverlay =
containerStatus === 'error' || showTerminalReconnect;
if (!wsUrl) {
return (
<div className={cx(styles['console-body'])}>
<div
className={cx(
styles['console-empty'],
'flex',
'items-center',
'content-center',
'h-full',
)}
>
{dict('PC.Components.ConversationBottomConsole.terminalEmpty')}
</div>
</div>
);
}
return (
<div
className={cx(styles['terminal-tab-stage'], {
[styles['terminal-tab-stage-light']]: isLightTerminal,
[styles['terminal-tab-stage-dark']]: !isLightTerminal,
})}
>
<div
className={cx(styles['xterm-container'])}
onMouseDown={(event) => {
if (event.button !== 0 || showStartingOverlay || showRetryOverlay) {
return;
}
terminalRef.current?.focus();
}}
>
<EmbeddedConsoleTerminal
ref={terminalRef}
className={cx(styles['terminal-embedded'], {
[styles['terminal-embedded-light']]: isLightTerminal,
[styles['terminal-embedded-dark']]: !isLightTerminal,
})}
wsUrl={wsUrl}
wsSubprotocols={wsSubprotocols}
wireProtocol={wireProtocol}
autoConnect={terminalAutoConnect}
theme={getConsoleTerminalTheme(terminalAppearance)}
fontSize={13}
fontFamily={CONSOLE_TERMINAL_FONT_FAMILY}
lineHeight={1.35}
cursorBlink
reconnect={DEFAULT_TERMINAL_RECONNECT}
onConnect={() => {
const isReconnect = terminalConnectedOnceRef.current;
terminalConnectedRef.current = true;
terminalConnectedOnceRef.current = true;
setShowTerminalReconnect(false);
if (isReconnect) {
terminalRef.current?.getTerminal()?.write('\r\n');
}
terminalRef.current?.writeln(
'\x1b[1;38;2;22;163;74m[Terminal connected]\x1b[0m',
);
// 重连后 fit + focusshell 提示符由 EmbeddedConsoleTerminal 内 requestShellPrompt 补发
terminalRef.current?.restoreAfterVisibilityChange();
window.setTimeout(
() => terminalRef.current?.restoreAfterVisibilityChange(),
50,
);
window.setTimeout(
() => terminalRef.current?.restoreAfterVisibilityChange(),
250,
);
}}
onDisconnect={() => {
terminalConnectedRef.current = false;
terminalRef.current?.getTerminal()?.write('\r\n');
terminalRef.current?.writeln(
'\x1b[1;38;2;220;38;38m[Terminal disconnected]\x1b[0m',
);
// 仅「需先启动容器」模式(传入 conversationId才在断连后预热服务
// 未传 conversationId 时终端直连 wsUrl由 EmbeddedConsoleTerminal 自动重连即可
if (requiresServiceStart && containerStatus === 'running') {
if (ensurePodOnDisconnectTimerRef.current) {
window.clearTimeout(ensurePodOnDisconnectTimerRef.current);
}
ensurePodOnDisconnectTimerRef.current = window.setTimeout(
() => {
ensurePodOnDisconnectTimerRef.current = null;
if (!conversationId) {
return;
}
void apiEnsurePod(conversationId).catch((error) => {
console.error(
'[ConversationBottomConsole] ensurePod on disconnect failed:',
error,
);
});
},
300,
);
}
}}
onReconnectFailed={() => {
terminalConnectedRef.current = false;
setShowTerminalReconnect(true);
}}
/>
</div>
<div
className={cx(
styles['terminal-tab-overlay'],
styles['terminal-tab-overlay-instant'],
{
[styles['terminal-tab-overlay-visible']]: showStartingOverlay,
},
)}
aria-hidden={!showStartingOverlay}
>
<div
className={cx(
styles['console-empty'],
'flex',
'flex-col',
'items-center',
'content-center',
'h-full',
'gap-8',
)}
>
<span>
{dict(
'PC.Components.ConversationBottomConsole.containerStarting',
)}
</span>
</div>
</div>
<div
className={cx(styles['terminal-tab-overlay'], {
[styles['terminal-tab-overlay-visible']]: showRetryOverlay,
})}
aria-hidden={!showRetryOverlay}
>
{renderTerminalRetryOverlay()}
</div>
</div>
);
};
/** 日志 Tab 内容:有 devLog 时渲染结构化日志面板,否则展示纯文本/空态 */
const renderLogsTab = () => {
if (useDevLogPanel && devLog) {
return (
<div className={cx(styles['dev-log-pane'])}>
<DevLogPanel
logs={devLog.logs}
isLoading={devLog.isLoading}
lastLine={devLog.lastLine}
onAddToChat={devLog.onAddToChat}
isChatLoading={devLog.isChatLoading}
latestErrorLogs={devLog.latestErrorLogs}
/>
</div>
);
}
return (
<div className={cx(styles['console-body'])}>
{runtimeLogs ? (
runtimeLogs
) : (
<div
className={cx(
styles['console-empty'],
'flex',
'items-center',
'content-center',
'h-full',
)}
>
{dict('PC.Components.ConversationBottomConsole.logsEmpty')}
</div>
)}
</div>
);
};
return (
<div
className={cx(
styles.console,
{
[styles['console-expanded']]: layoutMode === 'expanded',
[styles['console-collapsed']]: layoutMode === 'collapsed',
[styles['console-hidden']]: !visible,
[styles['console-terminal-light']]: isLightTerminal,
[styles['console-terminal-dark']]: !isLightTerminal,
},
className,
)}
>
{/* 头部:左侧 Tab 切换 + 右侧操作按钮 */}
<div className={cx(styles['console-header'])}>
<div className={cx(styles['console-tabs'])}>
<span
className={cx(styles['console-tab'], {
[styles.active]:
activeTab === 'terminal' && layoutMode !== 'collapsed',
})}
onClick={() => handleTabClick('terminal')}
>
{dict('PC.Components.ConversationBottomConsole.tabTerminal')}
</span>
{showLogsTab && (
<span
className={cx(styles['console-tab'], {
[styles.active]:
activeTab === 'logs' && layoutMode !== 'collapsed',
})}
onClick={() => handleTabClick('logs')}
>
{dict('PC.Components.ConversationBottomConsole.tabLogs')}
</span>
)}
</div>
<div className={cx(styles['console-actions'])}>
{/* 页面注入的额外操作(如开发日志操作按钮组),仅日志 Tab 显示 */}
{showLogsTab && activeTab === 'logs' && logsExtra}
{/* 终端主题切换按钮 */}
{showTerminalAppearanceToggle && (
<TooltipIcon
title={
isLightTerminal
? dict(
'PC.Components.ConversationBottomConsole.terminalThemeDark',
)
: dict(
'PC.Components.ConversationBottomConsole.terminalThemeLight',
)
}
placement="top"
className={cx(styles['console-action-btn'])}
icon={
isLightTerminal ? (
<MoonOutlined style={{ fontSize: 14 }} />
) : (
<SunOutlined style={{ fontSize: 14 }} />
)
}
onClick={handleToggleTerminalAppearance}
/>
)}
{/* 全屏展开 / 恢复默认高度按钮 */}
<TooltipIcon
title={
layoutMode === 'expanded'
? dict('PC.Components.ConversationBottomConsole.restoreHeight')
: dict('PC.Components.ConversationBottomConsole.expandHeight')
}
placement="top"
className={cx(styles['console-action-btn'])}
icon={
layoutMode === 'expanded' ? (
<FullscreenExitOutlined style={{ fontSize: 16 }} />
) : (
<SvgIcon
name="icons-common-fullscreen"
style={{ fontSize: 16 }}
/>
)
}
onClick={handleToggleExpand}
/>
{/* 折叠到底部 / 恢复默认高度按钮 */}
<TooltipIcon
title={
layoutMode === 'collapsed'
? dict('PC.Components.ConversationBottomConsole.restoreHeight')
: dict('PC.Components.ConversationBottomConsole.collapsePanel')
}
placement="top"
className={cx(styles['console-action-btn'])}
icon={
layoutMode === 'collapsed' ? <UpOutlined /> : <DownOutlined />
}
onClick={handleToggleCollapse}
/>
</div>
</div>
{/* 内容区:两个 Tab 面板常驻渲染,通过样式切换显隐(保持终端连接不断开) */}
<div className={cx(styles['console-content'])}>
<div
className={cx(styles['tab-pane'], {
[styles['tab-pane-active']]: activeTab === 'terminal',
})}
aria-hidden={activeTab !== 'terminal'}
>
{renderTerminalTab()}
</div>
<div
className={cx(styles['tab-pane'], {
[styles['tab-pane-active']]: activeTab === 'logs',
})}
aria-hidden={activeTab !== 'logs'}
>
{renderLogsTab()}
</div>
</div>
</div>
);
};
export default ConversationBottomConsole;

View File

@@ -0,0 +1,75 @@
import type { ITheme } from '@xterm/xterm';
/** 底部控制台终端外观:深色 / 浅色 */
export type TerminalAppearanceMode = 'dark' | 'light';
/** 默认终端外观:浅色(白底) */
export const DEFAULT_TERMINAL_APPEARANCE: TerminalAppearanceMode = 'light';
/**
* 深色终端VS Code Dark+
* 黑底 + 高对比 ANSI对应图一
*/
export const CONSOLE_TERMINAL_THEME_DARK: ITheme = {
background: '#000000',
foreground: '#d4d4d4',
cursor: '#4ec9b0',
cursorAccent: '#000000',
selectionBackground: '#264f78',
selectionForeground: '#ffffff',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
};
/**
* 浅色终端IDE 白底风格)
* 白底 + 深色正文,青绿提示符 / 蓝色路径,对应图二
*/
export const CONSOLE_TERMINAL_THEME_LIGHT: ITheme = {
background: '#ffffff',
foreground: '#1a1a1a',
cursor: '#4078f2',
cursorAccent: '#ffffff',
selectionBackground: '#b4d7ff',
selectionForeground: '#1a1a1a',
black: '#1a1a1a',
red: '#cd3131',
green: '#098658',
yellow: '#9a6f00',
blue: '#0451a5',
magenta: '#a626a4',
cyan: '#0598bc',
white: '#fafafa',
brightBlack: '#6e7681',
brightRed: '#e51400',
brightGreen: '#0d9c6c',
brightYellow: '#bf8803',
brightBlue: '#0078d4',
brightMagenta: '#a626a4',
brightCyan: '#0598bc',
brightWhite: '#ffffff',
};
/** @deprecated 使用 CONSOLE_TERMINAL_THEME_DARK */
export const CONSOLE_TERMINAL_THEME = CONSOLE_TERMINAL_THEME_DARK;
/** 等宽字体栈,与 IDE 终端保持一致 */
export const CONSOLE_TERMINAL_FONT_FAMILY =
'"SF Mono", Menlo, Monaco, Consolas, "DejaVu Sans Mono", "Microsoft YaHei", monospace';
export const getConsoleTerminalTheme = (mode: TerminalAppearanceMode): ITheme =>
mode === 'light' ? CONSOLE_TERMINAL_THEME_LIGHT : CONSOLE_TERMINAL_THEME_DARK;

View File

@@ -0,0 +1,92 @@
@import '@/styles/color.less';
@import '@/styles/token.less';
// 右键菜单样式
.contextMenu {
background: @colorBgElevated;
border: @lineWidth solid @colorBorder;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
padding: 4px 0;
min-width: 120px;
z-index: 1000;
}
.contextMenuItem {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
// color: @colorTextBase;
font-size: @fontSizeSm;
transition: all 0.2s ease;
color: #5e5e5b;
&:hover {
background: @colorPrimaryBg;
color: @colorPrimary;
}
&.dangerMenuItem {
color: @colorError;
&:hover {
background: @colorErrorBg;
color: @colorError;
}
}
&.disabledMenuItem {
color: @colorTextQuaternary;
cursor: not-allowed;
&:hover {
background: transparent;
color: @colorTextQuaternary;
}
}
}
.contextMenuIcon {
margin-right: 8px;
font-size: @fontSizeSm;
display: flex;
align-items: center;
}
.contextMenuDivider {
height: 1px;
background: @colorBorder;
opacity: 0.6;
margin: 4px 0;
}
// 右键菜单暗色主题支持
[data-theme='dark'] {
.contextMenu {
background: @colorBgElevated;
border-color: @colorBorder;
}
.contextMenuItem {
color: @colorTextBase;
&:hover {
background: @colorPrimaryBg;
color: @colorPrimary;
}
&.dangerMenuItem {
color: @colorError;
&:hover {
background: @colorErrorBg;
color: @colorError;
}
}
}
.contextMenuDivider {
background: @colorBorder;
}
}

View File

@@ -0,0 +1,467 @@
import { dict } from '@/services/i18nRuntime';
import { isMarkdownFile } from '@/utils/common';
import {
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FileAddOutlined,
FilePdfOutlined,
FolderAddOutlined,
FolderOpenOutlined,
ImportOutlined,
UploadOutlined,
} from '@ant-design/icons';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import styles from './index.less';
import type { FileContextMenuProps } from './types';
/**
* FileContextMenu 组件
* 提供文件树右键上下文菜单功能
*/
const FileContextMenu: React.FC<FileContextMenuProps> = ({
visible,
position,
targetNode,
onClose,
onDelete,
onRename,
onUploadFiles,
onUploadFolder,
onCreateFile,
onCreateFolder,
// 导入项目
onImportProject,
importProjectLabel,
onDownloadFileByUrl,
disabledDelete = false,
useRelativePosition = false,
}) => {
/**
* 处理菜单项点击
*/
const handleMenuItemClick = useCallback(
(action: () => void) => {
action();
onClose();
},
[onClose],
);
/**
* 处理删除操作
*/
const handleDelete = useCallback(() => {
if (!targetNode) return;
handleMenuItemClick(() => {
// 创建一个模拟的 MouseEvent 对象,包含必要的方法
const mockEvent = {
stopPropagation: () => {},
preventDefault: () => {},
currentTarget: null,
target: null,
bubbles: false,
cancelable: false,
defaultPrevented: false,
eventPhase: 0,
isTrusted: false,
nativeEvent: {} as Event,
timeStamp: Date.now(),
type: 'click',
} as unknown as React.MouseEvent;
onDelete(targetNode, mockEvent);
});
}, [targetNode, onDelete, handleMenuItemClick]);
/**
* 处理重命名操作
*/
const handleRename = useCallback(() => {
if (!targetNode || !onRename) return;
handleMenuItemClick(() => {
onRename(targetNode);
});
}, [targetNode, onRename, handleMenuItemClick]);
/**
* 处理上传操作
*/
const handleUpload = useCallback(() => {
if (!onUploadFiles) return;
handleMenuItemClick(() => {
onUploadFiles(targetNode);
});
}, [targetNode, onUploadFiles, handleMenuItemClick]);
/**
* 处理上传文件夹操作
*/
const handleUploadFolder = useCallback(() => {
if (!onUploadFolder) return;
handleMenuItemClick(() => {
onUploadFolder(targetNode);
});
}, [targetNode, onUploadFolder, handleMenuItemClick]);
/**
* 处理新建文件操作
*/
const handleCreateFile = useCallback(() => {
if (!onCreateFile) return;
handleMenuItemClick(() => {
// 如果目标节点是文件夹,则在该文件夹下创建;否则在根目录创建
const parentNode =
targetNode && targetNode.type === 'folder' ? targetNode : null;
onCreateFile(parentNode);
});
}, [targetNode, onCreateFile, handleMenuItemClick]);
/**
* 处理新建文件夹操作
*/
const handleCreateFolder = useCallback(() => {
if (!onCreateFolder) return;
handleMenuItemClick(() => {
// 如果目标节点是文件夹,则在该文件夹下创建;否则在根目录创建
const parentNode =
targetNode && targetNode.type === 'folder' ? targetNode : null;
onCreateFolder(parentNode);
});
}, [targetNode, onCreateFolder, handleMenuItemClick]);
/**
* 处理下载文件操作
*/
const handleDownload = useCallback(() => {
if (!targetNode || !targetNode.fileProxyUrl) return;
handleMenuItemClick(() => {
onDownloadFileByUrl?.(targetNode);
});
}, [targetNode, handleMenuItemClick]);
/**
* 处理导出为 PDF 操作(仅 Markdown 文件)
*/
const handleExportPdf = useCallback(() => {
if (!targetNode || !targetNode.fileProxyUrl) return;
handleMenuItemClick(() => {
onDownloadFileByUrl?.(targetNode, true);
});
}, [targetNode, handleMenuItemClick, onDownloadFileByUrl]);
// 菜单 DOM 引用(必须在条件返回之前)
const menuRef = useRef<HTMLDivElement>(null);
// 调整后的菜单位置(必须在条件返回之前)
const [adjustedPosition, setAdjustedPosition] = useState(position);
// 计算并调整菜单位置,避免超出视口或容器(必须在条件返回之前)
useLayoutEffect(() => {
if (!visible || !menuRef.current) {
// 菜单不可见时,重置为原始位置
if (!visible) {
setAdjustedPosition(position);
}
return;
}
const menuElement = menuRef.current;
const menuHeight = menuElement.offsetHeight;
const menuWidth = menuElement.offsetWidth;
// 如果菜单还未渲染完成(高度为 0使用原始位置
if (menuHeight === 0) {
setAdjustedPosition(position);
return;
}
if (useRelativePosition) {
// 使用相对定位时,相对于父容器调整位置
const containerElement = menuElement.offsetParent as HTMLElement;
if (containerElement) {
const containerHeight = containerElement.clientHeight;
const containerWidth = containerElement.clientWidth;
let adjustedTop = position.y;
let adjustedLeft = position.x;
// 如果菜单底部超出容器,向上调整位置
if (position.y + menuHeight > containerHeight) {
adjustedTop = position.y - menuHeight;
// 确保调整后的菜单顶部不会超出容器顶部,且至少距离顶部 8px
adjustedTop = Math.max(8, adjustedTop);
// 如果菜单高度超过容器高度,确保菜单顶部从容器顶部开始(保留 8px 边距)
if (menuHeight > containerHeight - 16) {
adjustedTop = 8;
}
}
// 如果菜单右侧超出容器,向左调整位置
if (position.x + menuWidth > containerWidth) {
adjustedLeft = Math.max(8, containerWidth - menuWidth - 8); // 至少距离右侧 8px
}
setAdjustedPosition({ x: adjustedLeft, y: adjustedTop });
} else {
// 如果找不到容器,使用原始位置
setAdjustedPosition(position);
}
} else {
// 使用固定定位时,相对于视口调整位置
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
let adjustedTop = position.y;
let adjustedLeft = position.x;
// 如果菜单底部超出视口,向上调整位置
if (position.y + menuHeight > viewportHeight) {
adjustedTop = position.y - menuHeight;
// 确保调整后的菜单顶部不会超出视口顶部,且至少距离顶部 8px
adjustedTop = Math.max(8, adjustedTop);
// 如果菜单高度超过视口高度,确保菜单顶部从视口顶部开始(保留 8px 边距)
if (menuHeight > viewportHeight - 16) {
adjustedTop = 8;
}
}
// 如果菜单右侧超出视口,向左调整位置
if (position.x + menuWidth > viewportWidth) {
adjustedLeft = Math.max(8, viewportWidth - menuWidth - 8); // 至少距离右侧 8px
}
setAdjustedPosition({ x: adjustedLeft, y: adjustedTop });
}
}, [visible, position, useRelativePosition]);
// 如果不显示,返回 null
if (!visible) {
return null;
}
// 构建菜单项 - 根据是否有目标节点显示不同菜单
const allMenuItems = targetNode
? targetNode.type === 'folder'
? [
// 文件夹菜单项
{
key: 'createFile',
label: dict('PC.Components.FileContextMenu.newFile'),
icon: <FileAddOutlined />,
onClick: handleCreateFile,
disabled: !onCreateFile,
},
{
key: 'createFolder',
label: dict('PC.Components.FileContextMenu.newFolder'),
icon: <FolderAddOutlined />,
onClick: handleCreateFolder,
disabled: !onCreateFolder,
},
{
key: 'divider1',
type: 'divider' as const,
},
{
key: 'rename',
label: dict('PC.Components.FileContextMenu.rename'),
icon: <EditOutlined />,
onClick: handleRename,
disabled: !onRename,
},
{
key: 'upload',
label: dict('PC.Components.FileContextMenu.uploadFile'),
icon: <UploadOutlined />,
onClick: handleUpload,
disabled: !onUploadFiles || targetNode?.name?.startsWith('.'),
},
...(onUploadFolder
? [
{
key: 'uploadFolder',
label: dict('PC.Components.FileContextMenu.uploadFolder'),
icon: <FolderOpenOutlined />,
onClick: handleUploadFolder,
disabled:
!onUploadFolder || targetNode?.name?.startsWith('.'),
},
]
: []),
{
key: 'divider2',
type: 'divider' as const,
},
{
key: 'delete',
label: dict('PC.Components.FileContextMenu.delete'),
icon: <DeleteOutlined />,
onClick: handleDelete,
danger: true,
},
]
: [
// 文件菜单项(不包含新建选项)
{
key: 'rename',
label: dict('PC.Components.FileContextMenu.rename'),
icon: <EditOutlined />,
onClick: handleRename,
disabled: !onRename,
},
// 只有当 fileProxyUrl 存在且不为空时才显示下载选项
...(targetNode?.fileProxyUrl
? [
{
key: 'download',
label: dict('PC.Components.FileContextMenu.download'),
icon: <DownloadOutlined />,
onClick: handleDownload,
},
// 如果是 Markdown 或 HTML 文件,显示导出为 PDF 选项
...(isMarkdownFile(targetNode?.name || '') ||
targetNode?.name?.endsWith('.html') ||
targetNode?.name?.endsWith('.htm')
? [
{
key: 'exportPdf',
label: dict('PC.Components.FileContextMenu.exportPdf'),
icon: <FilePdfOutlined />,
onClick: handleExportPdf,
},
]
: []),
]
: []),
{
key: 'upload',
label: dict('PC.Components.FileContextMenu.uploadFile'),
icon: <UploadOutlined />,
onClick: handleUpload,
disabled: !onUploadFiles || targetNode?.name?.startsWith('.'),
},
...(onUploadFolder
? [
{
key: 'uploadFolder',
label: dict('PC.Components.FileContextMenu.uploadFolder'),
icon: <FolderOpenOutlined />,
onClick: handleUploadFolder,
disabled:
!onUploadFolder || targetNode?.name?.startsWith('.'),
},
]
: []),
{
key: 'divider',
type: 'divider' as const,
},
{
key: 'delete',
label: dict('PC.Components.FileContextMenu.delete'),
icon: <DeleteOutlined />,
onClick: handleDelete,
danger: true,
},
]
: [
// 空白区域菜单项
{
key: 'createFile',
label: dict('PC.Components.FileContextMenu.newFile'),
icon: <FileAddOutlined />,
onClick: handleCreateFile,
disabled: !onCreateFile,
},
{
key: 'createFolder',
label: dict('PC.Components.FileContextMenu.newFolder'),
icon: <FolderAddOutlined />,
onClick: handleCreateFolder,
disabled: !onCreateFolder,
},
// 只有当 onImportProject 存在时才显示导入选项(技能详情页导入技能 / AppDev 导入项目)
...(onImportProject
? [
{
key: 'importProject',
label:
importProjectLabel ??
dict('PC.Components.FileContextMenu.importSkill'),
icon: <ImportOutlined />,
onClick: onImportProject,
},
]
: []),
{
key: 'divider1',
type: 'divider' as const,
},
{
key: 'upload',
label: dict('PC.Components.FileContextMenu.uploadFile'),
icon: <UploadOutlined />,
onClick: handleUpload,
disabled: !onUploadFiles,
},
...(onUploadFolder
? [
{
key: 'uploadFolder',
label: dict('PC.Components.FileContextMenu.uploadFolder'),
icon: <FolderOpenOutlined />,
onClick: handleUploadFolder,
disabled: !onUploadFolder,
},
]
: []),
];
// 如果对于SKILL.md文件禁用删除功能和重命名功能过滤掉删除菜单项、重命名菜单项和相关 divider
const menuItems = disabledDelete
? allMenuItems.filter(
(item) =>
item.key !== 'delete' &&
item.key !== 'rename' &&
item.key !== 'divider' &&
item.key !== 'divider2',
)
: allMenuItems;
return (
<div
ref={menuRef}
className={styles.contextMenu}
style={{
position: useRelativePosition ? 'absolute' : 'fixed',
left: adjustedPosition.x,
top: adjustedPosition.y,
zIndex: 1000,
}}
onClick={(e) => e.stopPropagation()}
>
{menuItems.map((item, index) => {
if (item.type === 'divider') {
return <div key={index} className={styles.contextMenuDivider} />;
}
return (
<div
key={item.key}
className={`${styles.contextMenuItem} ${
item.danger ? styles.dangerMenuItem : ''
} ${item.disabled ? styles.disabledMenuItem : ''}`}
onClick={item.disabled ? undefined : item.onClick}
>
<span className={styles.contextMenuIcon}>{item.icon}</span>
<span>{item.label}</span>
</div>
);
})}
</div>
);
};
export default FileContextMenu;

View File

@@ -0,0 +1,55 @@
import type { FileNode } from '@/types/interfaces/appDev';
/**
* 右键菜单项类型
*/
export interface ContextMenuItem {
/** 菜单项唯一标识 */
key: string;
/** 菜单项显示文本 */
label: string;
/** 菜单项图标 */
icon: React.ReactNode;
/** 点击回调函数 */
onClick: () => void;
/** 是否为危险操作(如删除) */
danger?: boolean;
/** 是否禁用 */
disabled?: boolean;
}
/**
* FileContextMenu 组件属性接口
*/
export interface FileContextMenuProps {
/** 是否显示菜单 */
visible: boolean;
/** 菜单位置 */
position: { x: number; y: number };
/** 目标文件/文件夹节点 */
targetNode: FileNode | null;
/** 是否禁用删除功能(为 true 时隐藏删除菜单项和 divider */
disabledDelete?: boolean;
/** 关闭菜单回调 */
onClose: () => void;
/** 删除文件回调 */
onDelete: (node: FileNode, event: React.MouseEvent) => void;
/** 重命名文件回调 */
onRename?: (node: FileNode) => void;
/** 上传多个文件回调(直接调用,不弹窗) */
onUploadFiles?: (node: FileNode | null) => void;
/** 上传文件夹回调(保留子目录结构) */
onUploadFolder?: (node: FileNode | null) => void;
/** 新建文件回调 */
onCreateFile?: (parentNode: FileNode | null) => void;
/** 新建文件夹回调 */
onCreateFolder?: (parentNode: FileNode | null) => void;
/** 导入项目回调 */
onImportProject?: () => void;
/** 导入项目菜单项文案(不传时使用默认的「导入技能」文案) */
importProjectLabel?: string;
/** 通过URL下载文件回调 */
onDownloadFileByUrl?: (node: FileNode, exportAsPdf?: boolean) => void;
/** 是否使用相对定位(相对于父容器),默认为 false使用 fixed 定位) */
useRelativePosition?: boolean;
}

View File

@@ -0,0 +1,135 @@
@import '@/styles/color.less';
@import '@/styles/token.less';
// 文件树样式
.fileTree {
max-width: 100%;
flex: 1;
width: 100%;
padding: 8px;
box-sizing: border-box;
overflow: auto;
user-select: none;
// 内联重命名样式
.inlineRenameInput {
flex: 1;
min-width: 0; // 允许输入框收缩
max-width: 100%; // 限制最大宽度
font-size: @fontSize;
height: 22px;
user-select: text;
}
}
.folderItem {
margin-bottom: 0;
min-width: fit-content; // 允许内容撑开宽度以支持横向滚动
white-space: nowrap;
}
.folderHeader {
display: flex;
align-items: center;
padding: 4px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s ease;
min-width: fit-content; // 允许内容撑开宽度以支持横向滚动
&:hover {
background: rgba(0, 0, 0, 5%);
}
&.activeFolder {
background: @colorPrimaryBg;
}
.folderIcon {
margin-right: 4px;
flex-shrink: 0; // 图标不收缩
// color: @colorTextBase;
color: #5e5e5b;
transition: transform 0.2s ease;
&.expanded {
transform: rotate(90deg);
}
}
.folderName {
height: 22px;
// color: @colorTextBase;
color: #5e5e5b;
font-weight: @fontWeightStrong;
font-size: @fontSize;
font-family: @fontFamilyCode;
flex: 1;
min-width: 0; // 允许文本收缩
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.fileList {
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: slide-down 0.3s ease-out;
}
@keyframes slide-down {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
.fileItem {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
font-size: 13px;
color: #5e5e5b;
min-width: fit-content; // 允许内容撑开宽度以支持横向滚动
white-space: nowrap;
svg {
font-size: 16px;
flex-shrink: 0; // 图标不收缩
}
&:hover {
background: rgba(0, 0, 0, 5%);
}
&.activeFile {
background: @colorPrimaryBg;
}
}
.fileName {
height: 22px;
color: #5e5e5b;
font-size: @fontSize;
font-family: @fontFamilyCode;
white-space: nowrap;
&.hiddenFile {
color: @colorTextQuaternary;
font-style: italic;
}
}
.no-files {
color: @colorTextQuaternary;
}

View File

@@ -0,0 +1,471 @@
import SvgIcon from '@/components/base/SvgIcon';
import Loading from '@/components/custom/Loading';
import { dict } from '@/services/i18nRuntime';
import { FileNode } from '@/types/interfaces/appDev';
import { findFileNode } from '@/utils/appDevUtils';
import { getFileIcon } from '@/utils/fileTree';
import type { InputRef } from 'antd';
import { Input } from 'antd';
import classNames from 'classnames';
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import styles from './index.less';
import type { FileTreeProps, FileTreeRef } from './types';
const cx = classNames.bind(styles);
/**
* 文件树组件
* 提供文件树展示、数据资源管理和折叠/展开功能
*/
const FileTree = forwardRef<FileTreeRef, FileTreeProps>(
(
{
files,
fileTreeDataLoading,
taskAgentSelectedFileId,
selectedFileId,
selectedFolderId = '',
// 正在重命名的节点
renamingNode,
// 取消重命名回调
onCancelRename,
// 文件选择回调
onFileSelect,
// 重命名文件回调
onConfirmRenameFile,
// 右键菜单回调
onContextMenu,
},
ref,
) => {
// 重命名值
const [renameValue, setRenameValue] = useState<string>('');
const renameInputRef = useRef<InputRef>(null);
// 已展开的文件夹ID集合
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
() =>
// 初次渲染时自动展开第一层文件夹,后续文件列表变更时不重置,避免已展开的节点被折叠
new Set(
(files || [])
.filter((node) => node.type === 'folder')
.map((node) => node.id),
),
);
useImperativeHandle(
ref,
() => ({
collapseAll: () => setExpandedFolders(new Set()),
}),
[],
);
/**
* 切换文件夹展开状态,用于展开/折叠回调
* 当展开文件夹时,如果文件夹下有文件且当前没有选中任何文件,则自动选中第一个文件
*/
const onToggleFolder = useCallback(
(folderId: string) => {
setExpandedFolders((prev) => {
const newExpanded = new Set(prev);
const wasExpanded = newExpanded.has(folderId);
// 如果已展开则删除,否则添加,实现切换效果
if (wasExpanded) {
newExpanded.delete(folderId);
} else {
newExpanded.add(folderId);
// 当文件夹展开时,检查是否需要自动选中第一个文件
// 只有当当前没有选中任何文件时,才自动选中
if (!selectedFileId) {
// 查找该文件夹节点
const folderNode = findFileNode(folderId, files || []);
if (
folderNode &&
folderNode.children &&
folderNode.children.length > 0
) {
// 查找第一个文件(非隐藏文件,跳过以 . 开头的文件)
const firstFile = folderNode.children.find(
(child) =>
child.type === 'file' && !child.name.startsWith('.'),
);
if (firstFile) {
// 使用 setTimeout 确保状态更新后再触发文件选择
setTimeout(() => {
onFileSelect(firstFile.id);
}, 0);
}
}
}
}
return newExpanded;
});
},
[files, selectedFileId, onFileSelect],
);
/**
* 取消重命名
*/
const cancelRename = () => {
const trimmedValue = renameValue.trim();
const shouldRemove = renamingNode?.status === 'create' && !trimmedValue;
onCancelRename({
removeIfNew: shouldRemove,
node: renamingNode || null,
});
setRenameValue('');
};
/**
* 确认重命名
*/
const confirmRename = () => {
if (!renamingNode) return;
const trimmedValue = renameValue.trim();
if (!trimmedValue || trimmedValue === renamingNode.name) {
cancelRename();
return;
}
// 验证文件名
const invalidChars = /[/\\:*?"<>|]/;
if (invalidChars.test(trimmedValue)) {
// 这里可以显示错误提示
return;
}
// 恢复数据状态
cancelRename();
// 异步执行重命名操作
try {
onConfirmRenameFile(renamingNode, trimmedValue);
} catch (error) {
// 如果重命名失败,可以考虑恢复原名字或显示错误提示
console.error('Rename failed:', error);
}
};
/**
* 处理重命名输入框键盘事件
*/
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
confirmRename();
} else if (e.key === 'Escape') {
cancelRename();
}
},
[confirmRename, cancelRename],
);
/**
* 处理重命名输入框失焦
*/
const handleRenameBlur = useCallback(() => {
// 延迟执行,避免与点击事件冲突
setTimeout(() => {
if (renamingNode) {
// 对于新建节点status === 'create'),根据输入值决定是创建还是取消
if (renamingNode.status === 'create') {
const trimmedValue = renameValue.trim();
// 如果输入了有效名称,则确认创建;否则取消并移除临时节点
if (trimmedValue) {
confirmRename();
} else {
cancelRename();
}
} else {
// 其它场景(普通重命名)失焦仍然走确认逻辑
confirmRename();
}
}
}, 100);
}, [renamingNode, renameValue, confirmRename, cancelRename]);
// 重命名输入框自动聚焦
useEffect(() => {
// 当进入重命名 / 新建状态,且输入框已经渲染到 DOM 中时,自动聚焦并选中
if (renamingNode && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
if (renamingNode) {
setRenameValue(renamingNode.name);
}
}, [renamingNode, expandedFolders]);
/**
* 当进入新建状态时,自动展开其父级及祖先文件夹
* 确保在折叠状态下新建文件/文件夹也能立刻可见
*/
useEffect(() => {
if (!renamingNode || renamingNode.status !== 'create') {
return;
}
const parentPath = renamingNode.parentPath;
if (!parentPath) return;
const parts = parentPath.split('/').filter(Boolean);
setExpandedFolders((prev) => {
const next = new Set(prev);
let currentPath = '';
parts.forEach((part) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
next.add(currentPath);
});
return next;
});
}, [renamingNode]);
/**
* 根据 taskAgentSelectedFileId 自动展开包含该文件的文件夹路径
* 当 taskAgentSelectedFileId 和 files 都不为空时,展开所有父级文件夹
*/
useEffect(() => {
// 如果 taskAgentSelectedFileId 或 files 为空,则不处理
if (!taskAgentSelectedFileId || !files || files.length === 0) {
return;
}
// 查找选中的文件节点
const selectedFileNode = findFileNode(taskAgentSelectedFileId, files);
if (!selectedFileNode) {
return;
}
// 如果选中的文件节点是文件夹
if (selectedFileNode.type === 'folder') {
// 如果文件夹有子节点,则展开文件夹,并选中第一个子节点
if (selectedFileNode?.children?.length) {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(selectedFileNode?.id);
return next;
});
}
return;
}
// 获取所有父级文件夹ID
// 通过路径分割直接获取所有父级路径,避免重复查找节点
const getParentFolderIds = (filePath: string): string[] => {
const parentIds: string[] = [];
const pathParts = filePath.split('/').filter(Boolean);
// 如果是根目录文件,则没有父级文件夹
if (pathParts.length <= 1) {
return parentIds;
}
// 从根目录开始,逐步构建所有父级路径
// 例如folder1/folder2/file.txt -> ['folder1', 'folder1/folder2']
let currentPath = '';
for (let i = 0; i < pathParts.length - 1; i++) {
currentPath = currentPath
? `${currentPath}/${pathParts[i]}`
: pathParts[i];
parentIds.push(currentPath);
}
return parentIds;
};
// 获取所有需要展开的文件夹ID
const parentFolderIds = getParentFolderIds(
selectedFileNode.path || selectedFileNode.id,
);
// 如果有父级文件夹,则展开它们
if (parentFolderIds.length > 0) {
setExpandedFolders((prev) => {
const next = new Set(prev);
parentFolderIds.forEach((folderId) => {
next.add(folderId);
});
return next;
});
}
}, [taskAgentSelectedFileId, files]);
/**
* 渲染文件树节点
*/
const renderFileTreeNode = useCallback(
(node: FileNode, level: number = 0) => {
const isExpanded = expandedFolders.has(node.id);
// 文件夹与文件选中互斥:选中文件夹时仅高亮文件夹,预览仍由 selectedFileId 驱动
const isSelected =
node.type === 'folder'
? selectedFolderId === node.id
: selectedFileId === node.id && !selectedFolderId;
const isRenaming = renamingNode?.id === node.id;
const nodeKey = node.id;
// 文件夹节点
if (node.type === 'folder') {
return (
<div
key={nodeKey}
className={styles.folderItem}
style={{ marginLeft: level * 8 }}
>
<div
className={cx(styles.folderHeader, {
[styles.activeFolder]: isSelected,
})}
onClick={() => {
if (isRenaming) {
return;
}
onToggleFolder(node.id);
onFileSelect(node.id, { selectFolder: true });
}}
onContextMenu={(e) => onContextMenu(e, node)}
>
<SvgIcon
name="icons-common-caret_right"
style={{ fontSize: '16px' }}
className={`${styles.folderIcon} ${
isExpanded ? styles.expanded : ''
}`}
/>
{isRenaming ? (
<Input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleRenameBlur}
className={styles.inlineRenameInput}
size="small"
/>
) : (
<span className={cx(styles.folderName, 'text-ellipsis')}>
{node.name}
</span>
)}
</div>
{isExpanded && node.children && (
<div className={styles.fileList}>
{node.children.map((child: any) =>
renderFileTreeNode(child, level + 1),
)}
</div>
)}
</div>
);
} else {
return (
<div
key={nodeKey}
className={`${styles.fileItem} ${
isSelected ? styles.activeFile : ''
}`}
onClick={() => {
// 跳过以"."为前缀的隐藏文件和重命名模式
// if (node.name.startsWith('.') || isRenaming) {
// return;
// }
// 重命名模式下,不进行文件选择
if (isRenaming) {
return;
}
onFileSelect(node.id);
}}
onContextMenu={(e) => onContextMenu(e, node)}
style={{ marginLeft: level * 8 }}
>
{/* 文件图标 */}
{getFileIcon(node.name)}
{/* 重命名输入框 */}
{isRenaming ? (
<Input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleRenameBlur}
className={styles.inlineRenameInput}
size="small"
/>
) : (
// 文件名
<span
className={`${styles.fileName} ${
node.name.startsWith('.') ? styles.hiddenFile : ''
}`}
>
{node.name}
</span>
)}
</div>
);
}
},
[
expandedFolders,
selectedFileId,
selectedFolderId,
renamingNode,
renameValue,
onToggleFolder,
onFileSelect,
onContextMenu,
handleRenameKeyDown,
handleRenameBlur,
],
);
return (
<div
className={styles.fileTree}
onContextMenu={(e) => onContextMenu(e, null)}
>
{/* 文件树数据加载状态 */}
{fileTreeDataLoading && !files?.length ? (
<div
className={cx('flex', 'content-center', 'items-center', 'h-full')}
>
<Loading />
</div>
) : files?.length > 0 ? (
files?.map((node: FileNode) => renderFileTreeNode(node))
) : (
<div
className={cx(
styles['no-files'],
'flex',
'content-center',
'items-center',
'h-full',
)}
>
{dict('PC.Components.FileTree.noFiles')}
</div>
)}
</div>
);
},
);
FileTree.displayName = 'FileTree';
export default FileTree;

View File

@@ -0,0 +1,47 @@
import type { FileNode } from '@/types/interfaces/appDev';
/**
* 页面应用开发文件树组件属性接口
*/
export interface FileTreeProps {
/** 文件树数据 */
files: FileNode[];
/** 文件树数据加载状态 */
fileTreeDataLoading?: boolean;
/** 通用型智能体会话中点击选中的文件ID */
taskAgentSelectedFileId?: string;
/** 当前选中的文件ID */
selectedFileId: string;
/** 当前选中的文件夹 ID仅用于树高亮 */
selectedFolderId?: string;
/** 正在重命名的节点 */
renamingNode?: FileNode | null;
/** 取消重命名回调
* 当 removeIfNew 为 true 且 node.status === 'create' 时,父组件应删除该临时节点
*/
onCancelRename: (options?: {
removeIfNew?: boolean;
node?: FileNode | null;
}) => void;
/** 右键菜单回调 */
onContextMenu: (e: React.MouseEvent, node: FileNode | null) => void;
/** 文件选择回调selectFolder 为 true 时仅选中文件夹(不切换预览) */
onFileSelect: (fileId: string, options?: { selectFolder?: boolean }) => void;
/** 重命名文件回调 */
onConfirmRenameFile: (node: FileNode, newName: string) => void;
}
/** FileTree 组件对外暴露的方法 */
export interface FileTreeRef {
/** 折叠全部文件夹 */
collapseAll: () => void;
}

View File

@@ -0,0 +1,148 @@
@import '@/styles/token.less';
.search-view {
width: 100%;
display: flex;
align-items: center;
padding: 0 8px;
// 搜索栏样式
.search-bar {
width: 100%;
position: relative;
z-index: 10;
.search-input {
box-sizing: border-box;
border: none;
padding: 8px;
border-radius: 8px;
background: rgba(12, 20, 102, 4%);
}
.search-icon {
color: @colorTextPlaceholder;
font-size: 16px;
}
// 输入框聚焦态样式(不改变整体尺寸)
:global(.ant-input-outlined:focus-within) {
box-shadow: none;
outline: 0;
}
}
// 搜索结果下拉列表样式
.search-dropdown {
position: absolute;
left: 0;
right: 0;
top: 32px;
margin: 0 8px;
max-height: 400px;
overflow-y: auto;
background: #2d2d2d;
border-radius: @borderRadius;
box-shadow: 0 4px 12px rgba(0, 0, 0, 30%);
z-index: 1000;
// 自定义滚动条样式
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 20%);
border-radius: 4px;
&:hover {
background: rgba(255, 255, 255, 30%);
}
}
}
// 搜索结果项样式
.search-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
user-select: none;
transition: background-color 0.15s ease;
border-bottom: 1px solid rgba(255, 255, 255, 5%);
&:last-child {
border-bottom: none;
border-radius: 0 0 @borderRadius @borderRadius;
}
&:hover {
background: rgba(255, 255, 255, 8%);
}
&.search-item-selected {
background: rgba(255, 255, 255, 15%);
}
.file-icon {
display: flex;
align-items: center;
margin-right: 12px;
flex-shrink: 0;
:global(svg) {
width: 16px;
height: 16px;
}
}
.file-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.file-name {
color: #e5e5e5;
font-size: @fontSize;
font-family: @fontFamilyCode;
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.file-path {
color: #8c8c8c;
font-size: @fontSizeSm;
font-family: @fontFamilyCode;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
}
// 暂无数据提示样式
.search-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 12px;
min-height: 60px;
.empty-text {
color: #8c8c8c;
font-size: @fontSize;
font-family: @fontFamily;
text-align: center;
}
}
}

Some files were not shown because too many files have changed in this diff Show More