fix(mcp): add OAuth skip endpoint and streamline in-chat interaction cards

Add a dedicated cancel route so users can skip mid-conversation OAuth without
token verification, and restyle OAuth/approval cards to match AgentStreamDisplay
with compact inline actions.
This commit is contained in:
wizardchen
2026-06-29 23:20:00 +08:00
committed by lyingbug
parent 505195865a
commit b5a1c88f75
10 changed files with 499 additions and 508 deletions
+5 -1
View File
@@ -226,7 +226,11 @@ export async function resolveToolApproval(
// the token exists before unblocking the paused tool call. // the token exists before unblocking the paused tool call.
export async function resolveMCPOAuth( export async function resolveMCPOAuth(
pendingId: string, pendingId: string,
body: { service_id: string } body: { service_id: string; decision?: 'authorize' | 'cancel' }
): Promise<void> { ): Promise<void> {
await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}`, body) await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}`, body)
} }
export async function cancelMCPOAuth(pendingId: string): Promise<void> {
await post(`/api/v1/agent/mcp-oauth-resolutions/${encodeURIComponent(pendingId)}/cancel`, {})
}
+18 -2
View File
@@ -5035,11 +5035,17 @@ export default {
agentStream: { agentStream: {
toolApproval: { toolApproval: {
banner: 'This MCP tool requires human approval. Review parameters before execution.', banner: 'This MCP tool requires human approval. Review parameters before execution.',
waiting: 'Awaiting review · {target}',
targetWithTool: '{service} › {tool}',
titleWithTarget: 'Review · {service} › {tool}',
resolvedApproved: 'Approved · {target}',
resolvedRejected: 'Rejected · {target}',
service: 'Service', service: 'Service',
tool: 'Tool', tool: 'Tool',
argsLabel: 'Arguments', argsLabel: 'Arguments',
argsModified: 'Modified', argsModified: 'Modified',
countdown: 'About {seconds}s remaining', countdown: 'About {seconds}s remaining',
countdownShort: '{seconds}s',
approve: 'Approve & run', approve: 'Approve & run',
reject: 'Reject', reject: 'Reject',
approvedTag: 'Approved', approvedTag: 'Approved',
@@ -5051,15 +5057,25 @@ export default {
}, },
mcpOAuth: { mcpOAuth: {
banner: 'This MCP service requires OAuth authorization before it can be used', banner: 'This MCP service requires OAuth authorization before it can be used',
waiting: 'Awaiting authorization · {target}',
targetWithTool: '{service} › {tool}',
titleWithService: 'OAuth · {service}',
titleWithTool: 'OAuth · {service} › {tool}',
resolvedAuthorized: 'Authorized · {target}',
resolvedTimedOut: 'Timed out · {target}',
resolvedCanceled: 'Skipped · {target}',
desc: 'Authorizing opens a new window to sign in. The tool call resumes automatically once authorization succeeds.', desc: 'Authorizing opens a new window to sign in. The tool call resumes automatically once authorization succeeds.',
authorize: 'Authorize', authorize: 'Authorize',
skip: 'Skip',
countdown: 'About {seconds}s remaining', countdown: 'About {seconds}s remaining',
countdownShort: '{seconds}s',
authorizedTag: 'Authorized', authorizedTag: 'Authorized',
timedOutTag: 'Authorization timed out', timedOutTag: 'Authorization timed out',
canceledTag: 'Canceled', canceledTag: 'Canceled',
authorizedToast: 'Authorized, resuming', authorizedToast: 'Authorized. Resuming…',
startFailed: 'Failed to start authorization', startFailed: 'Failed to start authorization',
resumeFailed: 'Failed to resume execution, please retry', resumeFailed: 'Failed to resume. Please try again.',
skipFailed: 'Failed to skip. Please try again.',
}, },
tools: { tools: {
searchKnowledge: 'Knowledge Search', searchKnowledge: 'Knowledge Search',
+16
View File
@@ -5052,11 +5052,17 @@ export default {
agentStream: { agentStream: {
toolApproval: { toolApproval: {
banner: '이 MCP 도구는 수동 승인이 필요합니다. 실행 전 매개변수를 확인하세요.', banner: '이 MCP 도구는 수동 승인이 필요합니다. 실행 전 매개변수를 확인하세요.',
waiting: '승인 대기 · {target}',
targetWithTool: '{service} › {tool}',
titleWithTarget: '검토 · {service} › {tool}',
resolvedApproved: '승인됨 · {target}',
resolvedRejected: '거부됨 · {target}',
service: '서비스', service: '서비스',
tool: '도구', tool: '도구',
argsLabel: '인수', argsLabel: '인수',
argsModified: '수정됨', argsModified: '수정됨',
countdown: '약 {seconds}초 남음', countdown: '약 {seconds}초 남음',
countdownShort: '{seconds}초',
approve: '승인 후 실행', approve: '승인 후 실행',
reject: '거부', reject: '거부',
approvedTag: '승인됨', approvedTag: '승인됨',
@@ -5068,15 +5074,25 @@ export default {
}, },
mcpOAuth: { mcpOAuth: {
banner: '이 MCP 서비스는 OAuth 인증 후에 사용할 수 있습니다', banner: '이 MCP 서비스는 OAuth 인증 후에 사용할 수 있습니다',
waiting: '인증 대기 · {target}',
targetWithTool: '{service} › {tool}',
titleWithService: 'OAuth · {service}',
titleWithTool: 'OAuth · {service} › {tool}',
resolvedAuthorized: '인증됨 · {target}',
resolvedTimedOut: '시간 초과 · {target}',
resolvedCanceled: '건너뜀 · {target}',
desc: '인증을 누르면 새 창에서 로그인합니다. 인증이 완료되면 이 도구 호출이 자동으로 계속됩니다.', desc: '인증을 누르면 새 창에서 로그인합니다. 인증이 완료되면 이 도구 호출이 자동으로 계속됩니다.',
authorize: '인증하기', authorize: '인증하기',
skip: '건너뛰기',
countdown: '약 {seconds}초 남음', countdown: '약 {seconds}초 남음',
countdownShort: '{seconds}초',
authorizedTag: '인증됨', authorizedTag: '인증됨',
timedOutTag: '인증 시간 초과', timedOutTag: '인증 시간 초과',
canceledTag: '취소됨', canceledTag: '취소됨',
authorizedToast: '인증 성공, 계속 진행 중', authorizedToast: '인증 성공, 계속 진행 중',
startFailed: '인증 시작 실패', startFailed: '인증 시작 실패',
resumeFailed: '실행 재개 실패, 다시 시도하세요', resumeFailed: '실행 재개 실패, 다시 시도하세요',
skipFailed: '건너뛰기 실패, 다시 시도하세요',
}, },
tools: { tools: {
searchKnowledge: '지식베이스 검색', searchKnowledge: '지식베이스 검색',
+16
View File
@@ -4552,11 +4552,17 @@ export default {
agentStream: { agentStream: {
toolApproval: { toolApproval: {
banner: 'Этот инструмент MCP требует подтверждения. Проверьте параметры.', banner: 'Этот инструмент MCP требует подтверждения. Проверьте параметры.',
waiting: 'Ожидание проверки · {target}',
targetWithTool: '{service} › {tool}',
titleWithTarget: 'Проверка · {service} › {tool}',
resolvedApproved: 'Подтверждено · {target}',
resolvedRejected: 'Отклонено · {target}',
service: 'Сервис', service: 'Сервис',
tool: 'Инструмент', tool: 'Инструмент',
argsLabel: 'Аргументы', argsLabel: 'Аргументы',
argsModified: 'Изменено', argsModified: 'Изменено',
countdown: 'Осталось около {seconds} с', countdown: 'Осталось около {seconds} с',
countdownShort: '{seconds} с',
approve: 'Подтвердить и выполнить', approve: 'Подтвердить и выполнить',
reject: 'Отклонить', reject: 'Отклонить',
approvedTag: 'Подтверждено', approvedTag: 'Подтверждено',
@@ -4568,15 +4574,25 @@ export default {
}, },
mcpOAuth: { mcpOAuth: {
banner: 'Этот MCP-сервис требует авторизации OAuth перед использованием', banner: 'Этот MCP-сервис требует авторизации OAuth перед использованием',
waiting: 'Ожидание авторизации · {target}',
targetWithTool: '{service} › {tool}',
titleWithService: 'OAuth · {service}',
titleWithTool: 'OAuth · {service} › {tool}',
resolvedAuthorized: 'Авторизовано · {target}',
resolvedTimedOut: 'Тайм-аут · {target}',
resolvedCanceled: 'Пропущено · {target}',
desc: 'Авторизация откроет новое окно для входа. После успешной авторизации вызов инструмента продолжится автоматически.', desc: 'Авторизация откроет новое окно для входа. После успешной авторизации вызов инструмента продолжится автоматически.',
authorize: 'Авторизовать', authorize: 'Авторизовать',
skip: 'Пропустить',
countdown: 'Осталось примерно {seconds}с', countdown: 'Осталось примерно {seconds}с',
countdownShort: '{seconds} с',
authorizedTag: 'Авторизовано', authorizedTag: 'Авторизовано',
timedOutTag: 'Время авторизации истекло', timedOutTag: 'Время авторизации истекло',
canceledTag: 'Отменено', canceledTag: 'Отменено',
authorizedToast: 'Авторизация успешна, продолжаем', authorizedToast: 'Авторизация успешна, продолжаем',
startFailed: 'Не удалось начать авторизацию', startFailed: 'Не удалось начать авторизацию',
resumeFailed: 'Не удалось возобновить выполнение, повторите попытку', resumeFailed: 'Не удалось возобновить выполнение, повторите попытку',
skipFailed: 'Не удалось пропустить, повторите попытку',
}, },
tools: { tools: {
searchKnowledge: 'Поиск по базе знаний', searchKnowledge: 'Поиск по базе знаний',
+16
View File
@@ -5048,11 +5048,17 @@ export default {
agentStream: { agentStream: {
toolApproval: { toolApproval: {
banner: "该 MCP 工具已标记为「需人工审核」,确认参数后再执行", banner: "该 MCP 工具已标记为「需人工审核」,确认参数后再执行",
waiting: "等待审核 · {target}",
targetWithTool: "{service} › {tool}",
titleWithTarget: "人工审核 · {service} › {tool}",
resolvedApproved: "已通过 · {target}",
resolvedRejected: "已拒绝 · {target}",
service: "服务", service: "服务",
tool: "工具", tool: "工具",
argsLabel: "调用参数", argsLabel: "调用参数",
argsModified: "已修改", argsModified: "已修改",
countdown: "剩余约 {seconds} 秒", countdown: "剩余约 {seconds} 秒",
countdownShort: "{seconds}s",
approve: "通过并执行", approve: "通过并执行",
reject: "拒绝", reject: "拒绝",
approvedTag: "已通过", approvedTag: "已通过",
@@ -5064,15 +5070,25 @@ export default {
}, },
mcpOAuth: { mcpOAuth: {
banner: "该 MCP 服务需要 OAuth 授权后才能调用", banner: "该 MCP 服务需要 OAuth 授权后才能调用",
waiting: "等待授权 · {target}",
targetWithTool: "{service} › {tool}",
titleWithService: "OAuth 授权 · {service}",
titleWithTool: "OAuth 授权 · {service} › {tool}",
resolvedAuthorized: "已授权 · {target}",
resolvedTimedOut: "授权超时 · {target}",
resolvedCanceled: "已跳过 · {target}",
desc: "点击授权将打开新窗口完成登录,授权成功后会自动继续本次工具调用。", desc: "点击授权将打开新窗口完成登录,授权成功后会自动继续本次工具调用。",
authorize: "去授权", authorize: "去授权",
skip: "跳过",
countdown: "剩余约 {seconds} 秒", countdown: "剩余约 {seconds} 秒",
countdownShort: "{seconds}s",
authorizedTag: "已授权", authorizedTag: "已授权",
timedOutTag: "授权超时", timedOutTag: "授权超时",
canceledTag: "已取消", canceledTag: "已取消",
authorizedToast: "授权成功,正在继续", authorizedToast: "授权成功,正在继续",
startFailed: "发起授权失败", startFailed: "发起授权失败",
resumeFailed: "恢复执行失败,请重试", resumeFailed: "恢复执行失败,请重试",
skipFailed: "跳过失败,请重试",
}, },
tools: { tools: {
searchKnowledge: "知识库检索", searchKnowledge: "知识库检索",
@@ -1,52 +1,36 @@
<template> <template>
<div class="oauth-card" :class="cardClass"> <div class="action-card interaction-inline" :class="cardClass">
<!-- Status strip --> <div class="action-header no-results">
<div class="oauth-strip"> <div class="action-title">
<span class="oauth-strip-icon"> <t-icon class="action-title-icon" :name="headerIcon" />
<t-icon v-if="!resolved" name="lock-on" /> <span class="action-name">{{ titleLine }}</span>
<t-icon v-else-if="authorized" name="check-circle-filled" /> <span v-if="!resolved" class="inline-actions">
<t-icon v-else name="close-circle-filled" /> <button
</span> type="button"
<span class="oauth-strip-text"> class="inline-action"
<template v-if="!resolved">{{ $t('agentStream.mcpOAuth.banner') }}</template> :disabled="canceling || authorizing"
<template v-else-if="authorized">{{ $t('agentStream.mcpOAuth.authorizedTag') }}</template> @click="skip"
<template v-else-if="timedOut">{{ $t('agentStream.mcpOAuth.timedOutTag') }}</template> >
<template v-else>{{ $t('agentStream.mcpOAuth.canceledTag') }}</template> {{ $t('agentStream.mcpOAuth.skip') }}
</span> </button>
<span v-if="!resolved && secondsLeft >= 0" class="oauth-strip-timer" :class="timerClass"> <span class="inline-dot">·</span>
<t-icon name="time" /> <button
{{ formatCountdown(secondsLeft) }} type="button"
</span> class="inline-action is-primary"
</div> :disabled="authorizing || canceling"
@click="authorize"
<!-- Identity row --> >
<div class="oauth-identity"> {{ $t('agentStream.mcpOAuth.authorize') }}
<span class="ident-service">{{ serviceName }}</span> </button>
<template v-if="mcpToolName"> </span>
<t-icon name="chevron-right" class="ident-sep" /> <span
<span class="ident-tool">{{ mcpToolName }}</span> v-if="!resolved && secondsLeft >= 0"
</template> class="action-timer"
</div> :class="timerClass"
>
<div v-if="!resolved" class="oauth-desc">{{ $t('agentStream.mcpOAuth.desc') }}</div> {{ formatCountdown(secondsLeft) }}
</span>
<!-- Footer (pending) --> </div>
<div v-if="!resolved" class="oauth-footer">
<span class="oauth-spacer" />
<t-button
theme="primary"
size="small"
:loading="authorizing"
:disabled="authorizing"
@click="authorize"
>
{{ $t('agentStream.mcpOAuth.authorize') }}
</t-button>
</div>
<!-- Footer (resolved) -->
<div v-else class="oauth-resolved-footer">
<span v-if="resolveReason" class="oauth-resolved-reason">{{ resolveReason }}</span>
</div> </div>
</div> </div>
</template> </template>
@@ -56,6 +40,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next' import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import {
cancelMCPOAuth,
getMCPOAuthAuthorizeURL, getMCPOAuthAuthorizeURL,
getMCPOAuthStatus, getMCPOAuthStatus,
resolveMCPOAuth, resolveMCPOAuth,
@@ -79,6 +64,7 @@ const props = defineProps<{
const { t } = useI18n() const { t } = useI18n()
const authorizing = ref(false) const authorizing = ref(false)
const canceling = ref(false)
const now = ref(Date.now()) const now = ref(Date.now())
let clock: ReturnType<typeof setInterval> | null = null let clock: ReturnType<typeof setInterval> | null = null
let poll: ReturnType<typeof setInterval> | null = null let poll: ReturnType<typeof setInterval> | null = null
@@ -100,15 +86,37 @@ const timerClass = computed(() => {
return '' return ''
}) })
const headerIcon = computed(() => {
if (!props.resolved) return 'lock-on'
if (props.authorized) return 'check-circle'
return 'close-circle'
})
const targetLabel = computed(() => (
props.mcpToolName
? t('agentStream.mcpOAuth.targetWithTool', { service: props.serviceName, tool: props.mcpToolName })
: props.serviceName
))
const titleLine = computed(() => {
if (!props.resolved) {
return t('agentStream.mcpOAuth.waiting', { target: targetLabel.value })
}
if (props.authorized) {
return t('agentStream.mcpOAuth.resolvedAuthorized', { target: targetLabel.value })
}
if (props.timedOut) return t('agentStream.mcpOAuth.resolvedTimedOut', { target: targetLabel.value })
return t('agentStream.mcpOAuth.resolvedCanceled', { target: targetLabel.value })
})
const cardClass = computed(() => ({ const cardClass = computed(() => ({
'is-resolved': !!props.resolved, 'action-pending': !props.resolved,
'is-authorized': !!props.resolved && !!props.authorized, 'action-success': !!props.resolved && !!props.authorized,
'is-failed': !!props.resolved && !props.authorized, 'action-error': !!props.resolved && !props.authorized,
'is-pending': !props.resolved,
})) }))
function formatCountdown(s: number): string { function formatCountdown(s: number): string {
if (s < 60) return t('agentStream.mcpOAuth.countdown', { seconds: s }) if (s < 60) return t('agentStream.mcpOAuth.countdownShort', { seconds: s })
const m = Math.floor(s / 60) const m = Math.floor(s / 60)
const r = s % 60 const r = s % 60
return `${m}:${r.toString().padStart(2, '0')}` return `${m}:${r.toString().padStart(2, '0')}`
@@ -121,6 +129,19 @@ function stopPoll() {
} }
} }
const skip = async () => {
if (props.resolved || canceling.value) return
canceling.value = true
try {
await cancelMCPOAuth(props.pendingId)
} catch (e: any) {
const msg = e?.response?.data?.error?.message || e?.message || t('agentStream.mcpOAuth.skipFailed')
MessagePlugin.error(msg)
} finally {
canceling.value = false
}
}
const authorize = async () => { const authorize = async () => {
if (props.resolved || authorizing.value) return if (props.resolved || authorizing.value) return
authorizing.value = true authorizing.value = true
@@ -137,7 +158,6 @@ const authorize = async () => {
return return
} }
const popup = window.open(authUrl, 'mcp_oauth', 'width=600,height=720') const popup = window.open(authUrl, 'mcp_oauth', 'width=600,height=720')
// Poll until the user authorizes (status flips) or closes the popup.
poll = window.setInterval(async () => { poll = window.setInterval(async () => {
const closed = !popup || popup.closed const closed = !popup || popup.closed
let ok = false let ok = false
@@ -150,7 +170,7 @@ const authorize = async () => {
stopPoll() stopPoll()
try { popup?.close() } catch { /* cross-origin close may throw */ } try { popup?.close() } catch { /* cross-origin close may throw */ }
try { try {
await resolveMCPOAuth(props.pendingId, { service_id: props.serviceId }) await resolveMCPOAuth(props.pendingId, { service_id: props.serviceId, decision: 'authorize' })
MessagePlugin.success(t('agentStream.mcpOAuth.authorizedToast')) MessagePlugin.success(t('agentStream.mcpOAuth.authorizedToast'))
} catch (e: any) { } catch (e: any) {
const msg = e?.response?.data?.error?.message || e?.message || t('agentStream.mcpOAuth.resumeFailed') const msg = e?.response?.data?.error?.message || e?.message || t('agentStream.mcpOAuth.resumeFailed')
@@ -182,151 +202,5 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@warning-rgb: 237, 122, 11; @import './agent-interaction-card.less';
@success-rgb: 7, 192, 95;
@danger-rgb: 232, 80, 91;
.oauth-card {
--strip-color: var(--td-brand-color);
--strip-rgb: 0, 82, 217;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
transition: border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
display: flex;
flex-direction: column;
position: relative;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--strip-color);
transition: background-color 0.2s ease;
}
&.is-authorized {
--strip-color: var(--td-success-color);
--strip-rgb: @success-rgb;
opacity: 0.94;
}
&.is-failed {
--strip-color: var(--td-error-color);
--strip-rgb: @danger-rgb;
opacity: 0.94;
}
}
.oauth-strip {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px 7px 14px;
font-size: 12px;
font-weight: 500;
color: var(--strip-color);
background: rgba(var(--strip-rgb), 0.06);
border-bottom: 1px solid var(--td-component-stroke);
.oauth-strip-icon {
display: inline-flex;
align-items: center;
.t-icon {
font-size: 14px;
}
}
.oauth-strip-text {
flex: 1;
color: var(--strip-color);
}
.oauth-strip-timer {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.04);
color: var(--td-text-color-secondary);
font-variant-numeric: tabular-nums;
font-weight: 500;
.t-icon {
font-size: 12px;
}
&.timer-warning {
color: var(--td-warning-color);
background: rgba(@warning-rgb, 0.1);
}
&.timer-critical {
color: var(--td-error-color);
background: rgba(@danger-rgb, 0.12);
animation: timerPulse 1.2s ease-in-out infinite;
}
}
}
.oauth-identity {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px 6px 14px;
font-size: 13px;
flex-wrap: wrap;
.ident-service {
color: var(--td-text-color-secondary);
font-weight: 500;
}
.ident-sep {
color: var(--td-text-color-placeholder);
font-size: 12px;
}
.ident-tool {
color: var(--td-brand-color);
font-weight: 600;
font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 13px;
}
}
.oauth-desc {
padding: 0 12px 4px 14px;
font-size: 12px;
line-height: 1.6;
color: var(--td-text-color-secondary);
}
.oauth-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 12px 14px;
}
.oauth-spacer {
flex: 1;
}
.oauth-resolved-footer {
padding: 6px 12px 10px 14px;
font-size: 12px;
color: var(--td-text-color-secondary);
.oauth-resolved-reason {
color: var(--td-text-color-secondary);
}
&:empty {
display: none;
}
}
@keyframes timerPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
</style> </style>
@@ -1,80 +1,60 @@
<template> <template>
<div class="approval-card" :class="cardClass"> <div class="action-card interaction-inline" :class="cardClass">
<!-- Status strip --> <div class="action-header no-results">
<div class="approval-strip"> <div class="action-title">
<span class="approval-strip-icon"> <t-icon class="action-title-icon" :name="headerIcon" />
<t-icon v-if="!resolved" name="info-circle-filled" /> <span class="action-name">{{ titleLine }}</span>
<t-icon v-else-if="approved" name="check-circle-filled" /> <span
<t-icon v-else name="close-circle-filled" /> v-if="!resolved && secondsLeft >= 0"
</span> class="action-timer"
<span class="approval-strip-text"> :class="timerClass"
<template v-if="!resolved">{{ $t('agentStream.toolApproval.banner') }}</template> >
<template v-else-if="approved">{{ $t('agentStream.toolApproval.approvedTag') }}</template> {{ formatCountdown(secondsLeft) }}
<template v-else>{{ $t('agentStream.toolApproval.rejectedTag') }}</template>
</span>
<span v-if="!resolved && secondsLeft >= 0" class="approval-strip-timer" :class="timerClass">
<t-icon name="time" />
{{ formatCountdown(secondsLeft) }}
</span>
</div>
<!-- Identity row -->
<div class="approval-identity">
<span class="ident-service">{{ serviceName }}</span>
<t-icon name="chevron-right" class="ident-sep" />
<span class="ident-tool">{{ mcpToolName }}</span>
</div>
<div v-if="description" class="approval-desc">{{ description }}</div>
<!-- Args (editable while pending, read-only after resolve) -->
<div class="approval-args">
<div class="approval-args-label">
<span class="args-label-text">{{ $t('agentStream.toolApproval.argsLabel') }}</span>
<span v-if="!resolved && !isJsonValid" class="args-status args-invalid">
<t-icon name="error-circle" /> {{ $t('agentStream.toolApproval.invalidJson') }}
</span> </span>
<span v-else-if="!resolved && isJsonValid && argsDirty" class="args-status args-dirty"> </div>
</div>
<div v-if="!resolved" class="interaction-panel">
<div v-if="!isJsonValid || argsDirty" class="interaction-args-meta">
<span v-if="!isJsonValid" class="args-status args-invalid">
<t-icon name="error-circle" />
{{ $t('agentStream.toolApproval.invalidJson') }}
</span>
<span v-else-if="argsDirty" class="args-status args-dirty">
{{ $t('agentStream.toolApproval.argsModified') }} {{ $t('agentStream.toolApproval.argsModified') }}
</span> </span>
</div> </div>
<t-textarea <t-textarea
v-if="!resolved"
v-model="argsText" v-model="argsText"
class="approval-args-input" class="interaction-args-input"
:autosize="{ minRows: 3, maxRows: 14 }" :autosize="{ minRows: 2, maxRows: 8 }"
placeholder="{}" placeholder="{}"
/> />
<pre v-else class="approval-args-readonly"><code>{{ argsText }}</code></pre> <div class="interaction-panel-actions">
<button
type="button"
class="inline-action"
:disabled="submitting"
@click="submit('reject')"
>
{{ $t('agentStream.toolApproval.reject') }}
</button>
<span class="inline-dot">·</span>
<button
type="button"
class="inline-action is-primary"
:disabled="submitting || !isJsonValid"
@click="submit('approve')"
>
{{ $t('agentStream.toolApproval.approve') }}
</button>
</div>
</div> </div>
<!-- Footer (pending) --> <div v-else-if="argsText" class="interaction-panel">
<div v-if="!resolved" class="approval-footer"> <div class="detail-output-wrapper">
<span class="approval-spacer" /> <div class="detail-output">{{ argsText }}</div>
<t-button </div>
theme="default"
variant="outline"
size="small"
:loading="submitting && pendingDecision === 'reject'"
:disabled="submitting"
@click="submit('reject')"
>
{{ $t('agentStream.toolApproval.reject') }}
</t-button>
<t-button
theme="primary"
size="small"
:loading="submitting && pendingDecision === 'approve'"
:disabled="submitting || !isJsonValid"
@click="submit('approve')"
>
{{ $t('agentStream.toolApproval.approve') }}
</t-button>
</div>
<!-- Footer (resolved) -->
<div v-else class="approval-resolved-footer">
<span v-if="resolveReason" class="approval-resolved-reason">{{ resolveReason }}</span>
</div> </div>
</div> </div>
</template> </template>
@@ -111,7 +91,6 @@ function formatJson(raw: string): string {
const initialArgs = formatJson(props.argsJson || '{}') const initialArgs = formatJson(props.argsJson || '{}')
const argsText = ref(initialArgs) const argsText = ref(initialArgs)
const submitting = ref(false) const submitting = ref(false)
const pendingDecision = ref<'approve' | 'reject' | null>(null)
const now = ref(Date.now()) const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
@@ -144,15 +123,33 @@ const timerClass = computed(() => {
return '' return ''
}) })
const headerIcon = computed(() => {
if (!props.resolved) return 'secured'
if (props.approved) return 'check-circle'
return 'close-circle'
})
const targetLabel = computed(() => (
t('agentStream.toolApproval.targetWithTool', {
service: props.serviceName,
tool: props.mcpToolName,
})
))
const titleLine = computed(() => {
if (!props.resolved) return t('agentStream.toolApproval.waiting', { target: targetLabel.value })
if (props.approved) return t('agentStream.toolApproval.resolvedApproved', { target: targetLabel.value })
return t('agentStream.toolApproval.resolvedRejected', { target: targetLabel.value })
})
const cardClass = computed(() => ({ const cardClass = computed(() => ({
'is-resolved': !!props.resolved, 'action-pending': !props.resolved,
'is-approved': !!props.resolved && !!props.approved, 'action-success': !!props.resolved && !!props.approved,
'is-rejected': !!props.resolved && !props.approved, 'action-error': !!props.resolved && !props.approved,
'is-pending': !props.resolved,
})) }))
function formatCountdown(s: number): string { function formatCountdown(s: number): string {
if (s < 60) return t('agentStream.toolApproval.countdown', { seconds: s }) if (s < 60) return t('agentStream.toolApproval.countdownShort', { seconds: s })
const m = Math.floor(s / 60) const m = Math.floor(s / 60)
const r = s % 60 const r = s % 60
return `${m}:${r.toString().padStart(2, '0')}` return `${m}:${r.toString().padStart(2, '0')}`
@@ -169,9 +166,8 @@ onBeforeUnmount(() => {
}) })
const submit = async (decision: 'approve' | 'reject') => { const submit = async (decision: 'approve' | 'reject') => {
if (props.resolved) return if (props.resolved || submitting.value) return
submitting.value = true submitting.value = true
pendingDecision.value = decision
try { try {
let modified: Record<string, unknown> | undefined let modified: Record<string, unknown> | undefined
if (decision === 'approve') { if (decision === 'approve') {
@@ -193,238 +189,10 @@ const submit = async (decision: 'approve' | 'reject') => {
MessagePlugin.error(msg) MessagePlugin.error(msg)
} finally { } finally {
submitting.value = false submitting.value = false
pendingDecision.value = null
} }
} }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@warning-rgb: 237, 122, 11; @import './agent-interaction-card.less';
@success-rgb: 7, 192, 95;
@danger-rgb: 232, 80, 91;
.approval-card {
--strip-color: var(--td-warning-color);
--strip-rgb: @warning-rgb;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
transition: border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
display: flex;
flex-direction: column;
position: relative;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--strip-color);
transition: background-color 0.2s ease;
}
&.is-pending {
box-shadow: 0 1px 6px rgba(@warning-rgb, 0.08);
}
&.is-approved {
--strip-color: var(--td-success-color);
--strip-rgb: @success-rgb;
opacity: 0.94;
}
&.is-rejected {
--strip-color: var(--td-error-color);
--strip-rgb: @danger-rgb;
opacity: 0.94;
}
}
.approval-strip {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px 7px 14px;
font-size: 12px;
font-weight: 500;
color: var(--strip-color);
background: rgba(var(--strip-rgb), 0.06);
border-bottom: 1px solid var(--td-component-stroke);
.approval-strip-icon {
display: inline-flex;
align-items: center;
.t-icon {
font-size: 14px;
}
}
.approval-strip-text {
flex: 1;
color: var(--strip-color);
}
.approval-strip-timer {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.04);
color: var(--td-text-color-secondary);
font-variant-numeric: tabular-nums;
font-weight: 500;
.t-icon {
font-size: 12px;
}
&.timer-warning {
color: var(--td-warning-color);
background: rgba(@warning-rgb, 0.1);
}
&.timer-critical {
color: var(--td-error-color);
background: rgba(@danger-rgb, 0.12);
animation: timerPulse 1.2s ease-in-out infinite;
}
}
}
.approval-identity {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px 6px 14px;
font-size: 13px;
flex-wrap: wrap;
.ident-service {
color: var(--td-text-color-secondary);
font-weight: 500;
}
.ident-sep {
color: var(--td-text-color-placeholder);
font-size: 12px;
}
.ident-tool {
color: var(--td-brand-color);
font-weight: 600;
font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 13px;
}
}
.approval-desc {
padding: 0 12px 4px 14px;
font-size: 12px;
line-height: 1.6;
color: var(--td-text-color-secondary);
}
.approval-args {
padding: 8px 12px 0 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.approval-args-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
.args-label-text {
color: var(--td-text-color-placeholder);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 11px;
font-weight: 500;
}
.args-status {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: auto;
font-size: 11px;
.t-icon {
font-size: 12px;
}
}
.args-invalid {
color: var(--td-error-color);
}
.args-dirty {
color: var(--td-warning-color);
}
}
.approval-args-input {
:deep(.t-textarea__inner) {
font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 12px;
line-height: 1.7;
background: var(--td-bg-color-secondarycontainer);
border-color: var(--td-component-stroke);
color: var(--td-text-color-primary);
padding: 8px 10px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
border-color: var(--td-brand-color-hover);
}
&:focus,
&:focus-visible {
border-color: var(--td-brand-color);
box-shadow: 0 0 0 2px rgba(@success-rgb, 0.12);
}
}
}
.approval-args-readonly {
margin: 0;
padding: 8px 10px;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-stroke);
border-radius: 4px;
font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 12px;
line-height: 1.7;
color: var(--td-text-color-primary);
white-space: pre-wrap;
word-break: break-word;
max-height: 180px;
overflow: auto;
}
.approval-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 12px 14px;
}
.approval-spacer {
flex: 1;
}
.approval-resolved-footer {
padding: 6px 12px 10px 14px;
font-size: 12px;
color: var(--td-text-color-secondary);
min-height: 0;
.approval-resolved-reason {
color: var(--td-text-color-secondary);
}
&:empty {
display: none;
}
}
@keyframes timerPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
</style> </style>
@@ -0,0 +1,190 @@
// Inline interaction rows for OAuth / tool-approval in agent stream.
.action-card.interaction-inline {
gap: 0;
}
.action-header {
display: flex;
align-items: center;
min-height: 24px;
padding: 0;
cursor: default;
user-select: none;
}
.action-card {
background: transparent;
border: 0;
box-shadow: none;
display: flex;
flex-direction: column;
&.action-error .action-title-icon.t-icon {
color: var(--td-error-color);
}
&.action-success .action-title-icon.t-icon {
color: var(--td-success-color);
}
}
.inline-dot {
color: var(--td-text-color-placeholder);
font-size: 12px;
line-height: 1;
user-select: none;
}
.inline-action {
appearance: none;
border: 0;
background: transparent;
padding: 0;
margin: 0;
font-size: var(--agent-step-summary-size, 13px);
line-height: 1.55;
color: var(--td-text-color-placeholder);
cursor: pointer;
transition: color 0.15s ease;
&:hover:not(:disabled) {
color: var(--td-text-color-secondary);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&.is-primary {
color: var(--td-brand-color);
&:hover:not(:disabled) {
color: var(--td-brand-color-hover);
}
}
}
.action-title {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
flex-wrap: wrap;
row-gap: 2px;
.action-title-icon.t-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: var(--agent-step-icon-color, var(--td-text-color-placeholder));
}
.action-name {
font-size: var(--agent-step-text-size, 14px);
line-height: 1.55;
font-weight: 400;
color: var(--td-text-color-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.inline-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.action-timer {
margin-left: auto;
font-size: 12px;
line-height: 1.55;
font-variant-numeric: tabular-nums;
color: var(--td-text-color-placeholder);
white-space: nowrap;
flex-shrink: 0;
&.timer-warning {
color: var(--td-warning-color);
}
&.timer-critical {
color: var(--td-error-color);
}
}
}
.interaction-panel {
padding: 4px 0 0 30px;
display: flex;
flex-direction: column;
gap: 6px;
}
.interaction-args-input {
:deep(.t-textarea__inner) {
font-family: var(--app-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
line-height: 1.55;
background: var(--td-bg-color-container);
border-color: var(--td-component-stroke);
color: var(--td-text-color-primary);
padding: 8px 10px;
border-radius: 6px;
min-height: 48px;
}
}
.interaction-args-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--td-text-color-placeholder);
.args-status {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
}
.args-invalid {
color: var(--td-error-color);
}
.args-dirty {
color: var(--td-warning-color);
}
}
.detail-output-wrapper {
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
overflow: hidden;
.detail-output {
font-family: var(--app-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--td-text-color-primary);
padding: 10px 12px;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
max-height: 160px;
overflow: auto;
background: var(--td-bg-color-container);
}
}
.interaction-panel-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
+90
View File
@@ -211,6 +211,8 @@ type resolveMCPOAuthBody struct {
// ServiceID is the MCP service the pending prompt belongs to; used to // ServiceID is the MCP service the pending prompt belongs to; used to
// verify the user actually holds a token before resuming the agent. // verify the user actually holds a token before resuming the agent.
ServiceID string `json:"service_id" binding:"required"` ServiceID string `json:"service_id" binding:"required"`
// Decision is "authorize" (default) or "cancel" when the user skips OAuth.
Decision string `json:"decision"`
} }
// ResolveMCPOAuth resumes an agent run that paused on an in-conversation OAuth // ResolveMCPOAuth resumes an agent run that paused on an in-conversation OAuth
@@ -257,6 +259,43 @@ func (h *MCPOAuthHandler) ResolveMCPOAuth(c *gin.Context) {
return return
} }
decision := strings.TrimSpace(strings.ToLower(body.Decision))
if decision == "" {
decision = "authorize"
}
switch decision {
case "cancel", "reject", "skip":
if err := h.gate.Resolve(tenantID, userID, pendingID, approval.Decision{
Approved: false,
Reason: "user canceled",
}); err != nil {
switch {
case stderrors.Is(err, approval.ErrPendingNotFound):
c.Error(errors.NewNotFoundError("pending authorization not found or already completed"))
case stderrors.Is(err, approval.ErrAlreadyResolved):
c.Error(errors.NewBadRequestError("pending authorization already resolved (timeout / cancel raced your action)"))
case stderrors.Is(err, approval.ErrTenantMismatch):
c.Error(errors.NewBadRequestError("tenant mismatch"))
case stderrors.Is(err, approval.ErrUserMismatch):
c.Error(errors.NewBadRequestError("user mismatch: only the session owner may resolve this prompt"))
default:
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"pending_id": secutils.SanitizeForLog(pendingID),
})
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
case "authorize":
// continue below
default:
c.Error(errors.NewBadRequestError("decision must be authorize or cancel"))
return
}
// Only resume once the user genuinely holds a token; otherwise the retry // Only resume once the user genuinely holds a token; otherwise the retry
// would just fail again with another authorization-required error. // would just fail again with another authorization-required error.
authorized, err := h.oauth.IsAuthorized(ctx, tenantID, userID, serviceID) authorized, err := h.oauth.IsAuthorized(ctx, tenantID, userID, serviceID)
@@ -289,3 +328,54 @@ func (h *MCPOAuthHandler) ResolveMCPOAuth(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"success": true}) c.JSON(http.StatusOK, gin.H{"success": true})
} }
// CancelMCPOAuth lets the user skip an in-conversation OAuth prompt without
// completing authorization. This unblocks the paused agent with a denial.
//
// CancelMCPOAuth godoc
// @Summary 跳过对话内 MCP OAuth 授权
// @Description 用户主动跳过 OAuth 授权,解除 Agent 阻塞
// @Tags MCP服务
// @Produce json
// @Param pending_id path string true "待授权 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} errors.AppError
// @Security Bearer
// @Router /agent/mcp-oauth-resolutions/{pending_id}/cancel [post]
func (h *MCPOAuthHandler) CancelMCPOAuth(c *gin.Context) {
ctx := c.Request.Context()
pendingID := c.Param("pending_id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
userID, _ := types.UserIDFromContext(ctx)
if tenantID == 0 || strings.TrimSpace(userID) == "" {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
if h.gate == nil {
c.Error(errors.NewInternalServerError("OAuth gate is not configured"))
return
}
if err := h.gate.Resolve(tenantID, userID, pendingID, approval.Decision{
Approved: false,
Reason: "user canceled",
}); err != nil {
switch {
case stderrors.Is(err, approval.ErrPendingNotFound):
c.Error(errors.NewNotFoundError("pending authorization not found or already completed"))
case stderrors.Is(err, approval.ErrAlreadyResolved):
c.Error(errors.NewBadRequestError("pending authorization already resolved (timeout / cancel raced your action)"))
case stderrors.Is(err, approval.ErrTenantMismatch):
c.Error(errors.NewBadRequestError("tenant mismatch"))
case stderrors.Is(err, approval.ErrUserMismatch):
c.Error(errors.NewBadRequestError("user mismatch: only the session owner may resolve this prompt"))
default:
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"pending_id": secutils.SanitizeForLog(pendingID),
})
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
+1
View File
@@ -890,6 +890,7 @@ func RegisterMCPServiceRoutes(
// Resume an agent run paused on an in-conversation MCP OAuth prompt. // Resume an agent run paused on an in-conversation MCP OAuth prompt.
// Same tenant-member (Viewer+) gating rationale as tool-approvals. // Same tenant-member (Viewer+) gating rationale as tool-approvals.
agentTool.POST("/mcp-oauth-resolutions/:pending_id", g.Viewer(), oauthHandler.ResolveMCPOAuth) agentTool.POST("/mcp-oauth-resolutions/:pending_id", g.Viewer(), oauthHandler.ResolveMCPOAuth)
agentTool.POST("/mcp-oauth-resolutions/:pending_id/cancel", g.Viewer(), oauthHandler.CancelMCPOAuth)
} }
} }