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:
@@ -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`, {})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '지식베이스 검색',
|
||||||
|
|||||||
@@ -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: 'Поиск по базе знаний',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user