From 34a6577f3a8d6e8490467c3762d3e04f8d74b509 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 11 Apr 2026 04:07:55 +0800 Subject: [PATCH] feat: ship permission/approval UI with mobile support and context usage ring --- static/src/App.vue | 39 ++- static/src/app/components.ts | 2 + static/src/app/methods/conversation.ts | 5 + static/src/app/methods/resources.ts | 3 + static/src/app/methods/taskPolling.ts | 44 +++ static/src/app/methods/ui.ts | 175 ++++++++++- static/src/app/state.ts | 21 ++ .../components/chat/actions/ToolAction.vue | 48 +-- .../components/chat/actions/toolRenderers.ts | 48 +-- static/src/components/input/InputComposer.vue | 128 +++++++- static/src/components/input/QuickMenu.vue | 9 + .../components/panels/ToolApprovalPanel.vue | 171 +++++++++++ .../personalization/PersonalizationDrawer.vue | 66 ++++ static/src/stores/personalization.ts | 18 ++ static/src/stores/ui.ts | 2 +- .../styles/components/input/_composer.scss | 181 +++++++++++ .../styles/components/overlays/_overlays.scss | 17 +- .../components/panels/_focus-panel.scss | 101 ------- .../styles/components/panels/_left-panel.scss | 8 +- .../components/panels/_right-panel.scss | 282 ++++++++++++++++++ static/src/styles/index.scss | 2 +- static/src/utils/chatDisplay.ts | 7 + 22 files changed, 1233 insertions(+), 144 deletions(-) create mode 100644 static/src/components/panels/ToolApprovalPanel.vue delete mode 100644 static/src/styles/components/panels/_focus-panel.scss create mode 100644 static/src/styles/components/panels/_right-panel.scss diff --git a/static/src/App.vue b/static/src/App.vue index 2e3cb93..7c57ac2 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -276,6 +276,10 @@ :block-token-panel="policyUiBlocks.block_token_panel" :block-compress-conversation="policyUiBlocks.block_compress_conversation" :block-conversation-review="policyUiBlocks.block_conversation_review" + :current-permission-mode="currentPermissionMode" + :permission-menu-open="permissionMenuOpen" + :permission-options="permissionModeOptions" + :current-context-tokens="currentContextTokens" @update:input-message="inputSetMessage" @input-change="handleInputChange" @input-focus="handleInputFocus" @@ -295,21 +299,33 @@ @toggle-focus-panel="handleFocusPanelToggleClick" @toggle-token-panel="handleTokenPanelToggleClick" @compress-conversation="handleCompressConversationClick" + @toggle-approval-panel="handleApprovalPanelToggleClick" @file-selected="handleFileSelected" @pick-images="openImagePicker" @pick-video="openVideoPicker" @remove-image="handleRemoveImage" @remove-video="handleRemoveVideo" @open-review="openReviewDialog" + @toggle-permission-menu="togglePermissionMenu" + @change-permission-mode="changePermissionMode" /> -
+ @@ -609,6 +625,27 @@ + + +
+
+ +
+
+
diff --git a/static/src/app/components.ts b/static/src/app/components.ts index 54e2e8e..3a581f0 100644 --- a/static/src/app/components.ts +++ b/static/src/app/components.ts @@ -2,6 +2,7 @@ import { defineAsyncComponent } from 'vue'; import ChatArea from '../components/chat/ChatArea.vue'; import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue'; import LeftPanel from '../components/panels/LeftPanel.vue'; +import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue'; import TokenDrawer from '../components/token/TokenDrawer.vue'; import QuickMenu from '../components/input/QuickMenu.vue'; import InputComposer from '../components/input/InputComposer.vue'; @@ -31,6 +32,7 @@ export const appComponents = { ChatArea, ConversationSidebar, LeftPanel, + ToolApprovalPanel, TokenDrawer, PersonalizationDrawer, QuickMenu, diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index 990e2ff..a5da580 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -90,6 +90,7 @@ export const conversationMethods = { this.inputSetToolMenuOpen(false); this.inputSetQuickMenuOpen(false); this.modeMenuOpen = false; + this.permissionMenuOpen = false; this.inputSetLineCount(1); this.inputSetMultiline(false); this.inputClearMessage(); @@ -100,6 +101,8 @@ export const conversationMethods = { this.conversationHasImages = false; this.toolSetSettingsLoading(false); this.toolSetSettings([]); + this.pendingToolApprovals = []; + this.decidingApprovalIds = []; debugLog('前端状态重置完成'); this._scrollListenerReady = false; @@ -363,6 +366,8 @@ export const conversationMethods = { // 4. 立即加载历史和统计,确保列表切换后界面同步更新 await this.fetchAndDisplayHistory(); + this.fetchPermissionMode(); + this.fetchPendingToolApprovals(); this.fetchConversationTokenStatistics(); this.updateCurrentContextTokens(); traceLog('loadConversation:after-history', { diff --git a/static/src/app/methods/resources.ts b/static/src/app/methods/resources.ts index f7fdb5d..8176af6 100644 --- a/static/src/app/methods/resources.ts +++ b/static/src/app/methods/resources.ts @@ -139,6 +139,9 @@ export const resourceMethods = { if (status && typeof status.model_key === 'string') { this.modelSet(status.model_key); } + if (status && typeof status.permission_mode === 'string') { + this.currentPermissionMode = status.permission_mode; + } if (status && typeof status.has_images !== 'undefined') { this.conversationHasImages = !!status.has_images; } diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 91ed587..f51947f 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -248,6 +248,12 @@ export const taskPollingMethods = { case 'update_action': this.handleToolUpdateAction(eventData, eventIdx); break; + case 'tool_approval_required': + this.handleToolApprovalRequired(eventData, eventIdx); + break; + case 'tool_approval_resolved': + this.handleToolApprovalResolved(eventData, eventIdx); + break; case 'append_payload': this.handleAppendPayload(eventData, eventIdx); @@ -789,6 +795,7 @@ export const taskPollingMethods = { targetAction.tool.content = data.content; } + this.$forceUpdate(); this.conditionalScrollToBottom(); @@ -797,6 +804,43 @@ export const taskPollingMethods = { } }, + handleToolApprovalRequired(data: any) { + const approval = data?.approval; + if (!approval || !approval.approval_id) { + return; + } + if (!Array.isArray(this.pendingToolApprovals)) { + this.pendingToolApprovals = []; + } + const idx = this.pendingToolApprovals.findIndex( + (item: any) => item && item.approval_id === approval.approval_id + ); + if (idx >= 0) { + this.pendingToolApprovals.splice(idx, 1, approval); + } else { + this.pendingToolApprovals.push(approval); + } + this.rightCollapsed = false; + if (this.rightWidth < this.minPanelWidth) { + this.rightWidth = this.minPanelWidth; + } + this.$forceUpdate(); + }, + + handleToolApprovalResolved(data: any) { + const approvalId = data?.approval_id; + if (!approvalId || !Array.isArray(this.pendingToolApprovals)) { + return; + } + this.pendingToolApprovals = this.pendingToolApprovals.filter( + (item: any) => item && item.approval_id !== approvalId + ); + if (!this.pendingToolApprovals.length) { + this.rightCollapsed = true; + } + this.$forceUpdate(); + }, + handleAppendPayload(data: any) { debugLog('[TaskPolling] 文件追加:', data.path); this.chatAddAppendPayloadAction(data); diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 5256283..70b856a 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -121,6 +121,9 @@ export const uiMethods = { if (!this.isMobileViewport) { return; } + if (target === 'approval') { + this.fetchPendingToolApprovals(); + } if (this.activeMobileOverlay === target) { this.closeMobileOverlay(); return; @@ -607,6 +610,150 @@ export const uiMethods = { } }, + getPermissionModeLabel(mode) { + const options = Array.isArray(this.permissionModeOptions) ? this.permissionModeOptions : []; + const hit = options.find((item) => item.value === mode); + return hit ? hit.label : mode || '未知'; + }, + + togglePermissionMenu() { + if (!this.isConnected) { + return; + } + this.permissionMenuOpen = !this.permissionMenuOpen; + }, + + closePermissionMenu() { + this.permissionMenuOpen = false; + }, + + async changePermissionMode(mode) { + const target = String(mode || '').trim().toLowerCase(); + if (!target || target === this.currentPermissionMode) { + this.closePermissionMenu(); + return; + } + try { + const response = await fetch('/api/permission-mode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ mode: target }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.message || payload?.error || '切换权限失败'); + } + this.currentPermissionMode = payload.mode || target; + this.uiPushToast({ + title: '权限已切换', + message: `当前权限:${this.getPermissionModeLabel(this.currentPermissionMode)}`, + type: 'success', + duration: 1800 + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error || '切换权限失败'); + this.uiPushToast({ + title: '切换权限失败', + message: msg, + type: 'error' + }); + } finally { + this.closePermissionMenu(); + } + }, + + async fetchPermissionMode() { + try { + const response = await fetch('/api/permission-mode'); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + return; + } + if (typeof payload.mode === 'string') { + this.currentPermissionMode = payload.mode; + } + } catch (_error) { + // ignore + } + }, + + async fetchPendingToolApprovals() { + if (!this.currentConversationId) { + this.pendingToolApprovals = []; + return; + } + try { + const response = await fetch( + `/api/tool-approvals/pending?conversation_id=${encodeURIComponent(this.currentConversationId)}` + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + return; + } + const items = Array.isArray(payload.items) ? payload.items : []; + this.pendingToolApprovals = items; + if (items.length > 0) { + this.rightCollapsed = false; + if (this.rightWidth < this.minPanelWidth) { + this.rightWidth = this.minPanelWidth; + } + } + } catch (_error) { + // ignore + } + }, + + async decideToolApproval(approvalId, decision) { + const id = String(approvalId || '').trim(); + if (!id) { + return; + } + if (!Array.isArray(this.decidingApprovalIds)) { + this.decidingApprovalIds = []; + } + if (!this.decidingApprovalIds.includes(id)) { + this.decidingApprovalIds.push(id); + } + try { + const response = await fetch(`/api/tool-approvals/${encodeURIComponent(id)}/decision`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ decision }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.message || payload?.error || '提交审批失败'); + } + this.pendingToolApprovals = (this.pendingToolApprovals || []).filter( + (item) => item && item.approval_id !== id + ); + if (!this.pendingToolApprovals.length) { + this.rightCollapsed = true; + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error || '审批失败'); + this.uiPushToast({ + title: '审批失败', + message: msg, + type: 'error' + }); + } finally { + this.decidingApprovalIds = (this.decidingApprovalIds || []).filter((item) => item !== id); + } + }, + + approveToolApproval(approvalId) { + return this.decideToolApproval(approvalId, 'approved'); + }, + + rejectToolApproval(approvalId) { + return this.decideToolApproval(approvalId, 'rejected'); + }, + handleInputChange() { this.autoResizeInput(); }, @@ -614,6 +761,7 @@ export const uiMethods = { handleInputFocus() { this.inputSetFocused(true); this.closeQuickMenu(); + this.closePermissionMenu(); }, handleInputBlur() { @@ -640,6 +788,18 @@ export const uiMethods = { this.toggleFocusPanel(); }, + handleApprovalPanelToggleClick() { + if (!this.currentConversationId) { + return; + } + if (this.isMobileViewport) { + this.fetchPendingToolApprovals(); + this.openMobileOverlay('approval'); + return; + } + this.toggleApprovalPanel(); + }, + handleTokenPanelToggleClick() { if (!this.currentConversationId) { return; @@ -821,7 +981,7 @@ export const uiMethods = { }, handleClickOutsideQuickMenu(event) { - if (!this.quickMenuOpen) { + if (!this.quickMenuOpen && !this.permissionMenuOpen) { return; } const shell = @@ -830,6 +990,7 @@ export const uiMethods = { return; } this.closeQuickMenu(); + this.closePermissionMenu(); }, handleClickOutsideHeaderMenu(event) { @@ -909,6 +1070,16 @@ export const uiMethods = { } }, + toggleApprovalPanel() { + this.rightCollapsed = !this.rightCollapsed; + if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) { + this.rightWidth = this.minPanelWidth; + } + if (!this.rightCollapsed) { + this.fetchPendingToolApprovals(); + } + }, + addSystemMessage(content) { console.log('[DEBUG_SYSTEM][ui] addSystemMessage 入参', { content }); const systemNoticeLabel = parseSystemNoticeLabel(content); @@ -1341,6 +1512,8 @@ export const uiMethods = { this.agentVersion = statusData.version || this.agentVersion; this.thinkingMode = !!statusData.thinking_mode; this.applyStatusSnapshot(statusData); + this.fetchPermissionMode(); + this.fetchPendingToolApprovals(); // 立即更新配额和运行模式,避免等待其他慢接口 this.fetchUsageQuota(); const modelStore = useModelStore(); diff --git a/static/src/app/state.ts b/static/src/app/state.ts index e0699b3..6b9d346 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -79,6 +79,27 @@ export function dataState() { mobileViewportQuery: null, modeMenuOpen: false, modelMenuOpen: false, + permissionMenuOpen: false, + currentPermissionMode: 'unrestricted', + permissionModeOptions: [ + { + value: 'readonly', + label: '只读', + description: '仅允许读取/搜索类工具,禁止修改工作区' + }, + { + value: 'approval', + label: '批准', + description: '对工作区文件进行修改的工具需用户批准后才会执行' + }, + { + value: 'unrestricted', + label: '无限制', + description: '保持当前默认行为,不额外拦截' + } + ], + pendingToolApprovals: [], + decidingApprovalIds: [], imageEntries: [], imageLoading: false, videoEntries: [], diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index 46f62ef..e287a35 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -87,6 +87,20 @@ function formatBytes(bytes: number): string { return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; } +function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string { + const state = String(result?.status || props.action?.tool?.status || '').toLowerCase(); + if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') { + return '待审批'; + } + if (state === 'rejected') { + return '✗ 已拒绝'; + } + if (state === 'cancelled') { + return '✗ 已取消'; + } + return result?.success ? successLabel : failedLabel; +} + function renderEnhancedToolResult(): string { const tool = props.action.tool; const result = tool.result; @@ -203,7 +217,7 @@ function renderWebSearch(result: any, args: any): string { function renderExtractWebpage(result: any, args: any): string { const url = args.url || result.url || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const content = result.content || result.text || ''; let html = '
'; @@ -223,7 +237,7 @@ function renderExtractWebpage(result: any, args: any): string { function renderSaveWebpage(result: any, args: any): string { const url = args.url || result.url || ''; const path = result.path || args.save_path || ''; - const status = result.success ? '✓ 已保存' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已保存'); let html = '
'; html += `
URL:${escapeHtml(url)}
`; @@ -237,7 +251,7 @@ function renderSaveWebpage(result: any, args: any): string { // ===== 文件编辑类 ===== function renderCreateFile(result: any, args: any): string { const path = args.path || result.path || ''; - const status = result.success ? '✓ 已创建' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已创建'); let html = '
'; html += `
路径:${escapeHtml(path)}
`; @@ -249,7 +263,7 @@ function renderCreateFile(result: any, args: any): string { function renderWriteFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; - const status = result.success ? '✓ 已写入' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已写入'); const content = args.content || ''; const isAppend = args.append || false; @@ -279,7 +293,7 @@ function renderWriteFile(result: any, args: any): string { function renderEditFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; - const status = result.success ? '✓ 已编辑' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已编辑'); // 兼容两种数据格式: // 1. 新格式:args.replacements 数组 @@ -355,7 +369,7 @@ function renderEditFile(result: any, args: any): string { function renderDeleteFile(result: any, args: any): string { const path = args.path || result.path || ''; - const status = result.success ? '✓ 已删除' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已删除'); let html = '
'; html += `
路径:${escapeHtml(path)}
`; @@ -368,7 +382,7 @@ function renderDeleteFile(result: any, args: any): string { function renderRenameFile(result: any, args: any): string { const oldPath = args.old_path || args.source || result.old_path || ''; const newPath = args.new_path || args.destination || result.new_path || ''; - const status = result.success ? '✓ 已重命名' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已重命名'); let html = '
'; html += `
路径:${escapeHtml(oldPath)}
`; @@ -382,7 +396,7 @@ function renderRenameFile(result: any, args: any): string { // ===== 阅读聚焦类 ===== function renderReadFile(result: any, args: any): string { const path = args.path || result.path || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const size = result.size || result.file_size || 0; const readType = result.type || 'read'; @@ -462,7 +476,7 @@ function renderReadFile(result: any, args: any): string { function renderVlmAnalyze(result: any, args: any): string { const path = args.image_path || args.path || result.path || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const analysis = result.analysis || result.result || ''; let html = '
'; @@ -482,7 +496,7 @@ function renderVlmAnalyze(result: any, args: any): string { function renderOcrImage(result: any, args: any): string { const path = args.image_path || args.path || result.path || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const size = result.size || result.file_size || 0; const text = result.text || result.ocr_text || ''; const imageUrl = result.image_url || result.url || ''; @@ -511,7 +525,7 @@ function renderOcrImage(result: any, args: any): string { function renderViewImage(result: any, args: any): string { const path = args.image_path || args.path || result.path || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const size = result.size || result.file_size || 0; const imageUrl = result.image_url || result.url || ''; @@ -534,7 +548,7 @@ function renderViewImage(result: any, args: any): string { function renderTerminalSession(result: any, args: any): string { const operation = args.operation || args.action || 'start'; const sessionName = args.session_name || args.name || result.session_name || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); let html = '
'; html += `
操作:${escapeHtml(operation)}
`; @@ -567,7 +581,7 @@ function renderTerminalInput(result: any, args: any): string { function renderTerminalSnapshot(result: any, args: any): string { const sessionName = args.session_name || args.name || result.session_name || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const startLine = args.start_line || 0; const endLine = args.end_line || 0; const content = result.content || result.output || ''; @@ -592,7 +606,7 @@ function renderTerminalSnapshot(result: any, args: any): string { function renderSleep(result: any, args: any): string { const seconds = args.seconds || args.duration || 0; - const status = result.success ? '✓ 完成' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 完成'); let html = '
'; html += `
等待时间:${seconds}秒
`; @@ -690,7 +704,7 @@ function renderUpdateMemory(result: any, args: any): string { // ===== 待办事项类 ===== function renderTodoCreate(result: any, args: any): string { - const status = result.success ? '✓ 已创建' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已创建'); const title = args.title || result.title || ''; const tasks = args.tasks || result.tasks || []; @@ -714,7 +728,7 @@ function renderTodoCreate(result: any, args: any): string { } function renderTodoUpdate(result: any, args: any): string { - const status = result.success ? '✓ 已更新' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已更新'); const title = args.title || result.title || ''; const tasks = result.tasks || args.tasks || []; @@ -740,7 +754,7 @@ function renderTodoUpdate(result: any, args: any): string { // ===== 彩蛋类 ===== function renderEasterEgg(result: any, args: any): string { - const status = result.success ? '✓ 已触发' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已触发'); const type = result.type || args.type || ''; const content = result.content || result.message || ''; diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index 7a65699..350b791 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -14,6 +14,20 @@ export function formatBytes(bytes: number): string { return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; } +function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string { + const state = String(result?.status || '').toLowerCase(); + if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') { + return '待审批'; + } + if (state === 'rejected') { + return '✗ 已拒绝'; + } + if (state === 'cancelled') { + return '✗ 已取消'; + } + return result?.success ? successLabel : failedLabel; +} + export function renderEnhancedToolResult( action: any, formatSearchTopic: (filters: Record) => string, @@ -144,7 +158,7 @@ function renderWebSearch( function renderExtractWebpage(result: any, args: any): string { const url = args.url || result.url || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const content = result.content || result.text || ''; let html = '
'; @@ -164,7 +178,7 @@ function renderExtractWebpage(result: any, args: any): string { function renderSaveWebpage(result: any, args: any): string { const url = args.url || result.url || ''; const path = result.path || args.save_path || ''; - const status = result.success ? '✓ 已保存' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已保存'); let html = '
'; html += `
URL:${escapeHtml(url)}
`; @@ -178,7 +192,7 @@ function renderSaveWebpage(result: any, args: any): string { // 文件编辑类渲染函数 function renderCreateFile(result: any, args: any): string { const path = args.path || result.path || ''; - const status = result.success ? '✓ 已创建' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已创建'); let html = '
'; html += `
路径:${escapeHtml(path)}
`; @@ -190,7 +204,7 @@ function renderCreateFile(result: any, args: any): string { function renderWriteFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; - const status = result.success ? '✓ 已写入' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已写入'); const content = args.content || ''; const isAppend = args.append || false; @@ -220,7 +234,7 @@ function renderWriteFile(result: any, args: any): string { function renderEditFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; - const status = result.success ? '✓ 已编辑' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已编辑'); // 兼容两种数据格式: // 1. 新格式:args.replacements 数组 @@ -296,7 +310,7 @@ function renderEditFile(result: any, args: any): string { function renderDeleteFile(result: any, args: any): string { const path = args.path || result.path || ''; - const status = result.success ? '✓ 已删除' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已删除'); let html = '
'; html += `
路径:${escapeHtml(path)}
`; @@ -309,7 +323,7 @@ function renderDeleteFile(result: any, args: any): string { function renderRenameFile(result: any, args: any): string { const oldPath = args.old_path || args.source || result.old_path || ''; const newPath = args.new_path || args.destination || result.new_path || ''; - const status = result.success ? '✓ 已重命名' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已重命名'); let html = '
'; html += `
路径:${escapeHtml(oldPath)}
`; @@ -323,7 +337,7 @@ function renderRenameFile(result: any, args: any): string { // 阅读聚焦类渲染函数 function renderReadFile(result: any, args: any): string { const path = args.path || result.path || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const size = result.size || result.file_size || 0; const readType = result.type || 'read'; @@ -403,7 +417,7 @@ function renderReadFile(result: any, args: any): string { function renderVlmAnalyze(result: any, args: any): string { const path = args.image_path || args.path || result.path || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const analysis = result.analysis || result.result || ''; let html = '
'; @@ -423,7 +437,7 @@ function renderVlmAnalyze(result: any, args: any): string { function renderOcrImage(result: any, args: any): string { const path = args.image_path || args.path || result.path || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const size = result.size || result.file_size || 0; const text = result.text || result.ocr_text || ''; const imageUrl = result.image_url || result.url || ''; @@ -452,7 +466,7 @@ function renderOcrImage(result: any, args: any): string { function renderViewImage(result: any, args: any): string { const path = args.image_path || args.path || result.path || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const size = result.size || result.file_size || 0; const imageUrl = result.image_url || result.url || ''; @@ -475,7 +489,7 @@ function renderViewImage(result: any, args: any): string { function renderTerminalSession(result: any, args: any): string { const operation = args.operation || args.action || 'start'; const sessionName = args.session_name || args.name || result.session_name || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); let html = '
'; html += `
操作:${escapeHtml(operation)}
`; @@ -508,7 +522,7 @@ function renderTerminalInput(result: any, args: any): string { function renderTerminalSnapshot(result: any, args: any): string { const sessionName = args.session_name || args.name || result.session_name || ''; - const status = result.success ? '✓ 成功' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 成功'); const startLine = args.start_line || 0; const endLine = args.end_line || 0; const content = result.content || result.output || ''; @@ -533,7 +547,7 @@ function renderTerminalSnapshot(result: any, args: any): string { function renderSleep(result: any, args: any): string { const seconds = args.seconds || args.duration || 0; - const status = result.success ? '✓ 完成' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 完成'); let html = '
'; html += `
等待时间:${seconds}秒
`; @@ -614,7 +628,7 @@ function renderUpdateMemory(result: any, args: any): string { // 待办事项类渲染函数 function renderTodoCreate(result: any, args: any): string { - const status = result.success ? '✓ 已创建' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已创建'); const todoList = result.todo_list || {}; const title = todoList.title || args.title || ''; const tasks = todoList.tasks || args.tasks || []; @@ -640,7 +654,7 @@ function renderTodoCreate(result: any, args: any): string { } function renderTodoUpdate(result: any): string { - const status = result.success ? '✓ 已更新' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已更新'); const todoList = result.todo_list || {}; const title = todoList.title || ''; const tasks = todoList.tasks || []; @@ -670,7 +684,7 @@ function renderTodoUpdate(result: any): string { // 彩蛋类渲染函数 function renderEasterEgg(result: any, args: any): string { - const status = result.success ? '✓ 已触发' : '✗ 失败'; + const status = formatToolStatusLabel(result, '✓ 已触发'); const type = result.type || args.type || ''; const content = result.content || result.message || ''; diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index a37404c..6f54a7d 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -131,16 +131,65 @@ @toggle-focus-panel="$emit('toggle-focus-panel')" @toggle-token-panel="$emit('toggle-token-panel')" @compress-conversation="$emit('compress-conversation')" + @toggle-approval-panel="$emit('toggle-approval-panel')" @open-review="$emit('open-review')" /> +
+ +
+ +
+
+
+ +
+
{{ contextUsagePercentLabel }}已用
+
{{ contextUsageCompactText }}
+
+
diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 390ddde..d87010d 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -424,6 +424,55 @@
+
+
+
+
+ 默认权限 +

+ 新建对话时的默认权限模式。权限会与对话绑定,切换对话会自动切换对应权限。 +

+
+
+ +
+
+ +
+
+
+ + {{ status }} + + + {{ error }} + +
+ +
+
+
+
('preferences'); const swipeState = ref<{ startY: number; active: boolean }>({ startY: 0, active: false }); type RunModeValue = 'fast' | 'thinking' | 'deep' | null; +type PermissionModeValue = 'readonly' | 'approval' | 'unrestricted'; type CompressionField = | 'shallow_compress_trigger_tokens' | 'shallow_compress_keep_recent_tools' @@ -1388,6 +1440,16 @@ const runModeOptions: Array<{ { id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' } ]; +const permissionModeOptions: Array<{ + id: PermissionModeValue; + label: string; + desc: string; +}> = [ + { id: 'readonly', label: '只读', desc: '仅允许读取/检索类工具,修改操作将被拒绝。' }, + { id: 'approval', label: '批准', desc: '对工作区文件进行修改的工具需人工批准后执行。' }, + { id: 'unrestricted', label: '无限制', desc: '工具按常规流程直接执行。' } +]; + const policyStore = usePolicyStore(); const modelStore = useModelStore(); @@ -1733,6 +1795,10 @@ const setDefaultModel = (value: string) => { personalization.setDefaultModel(value); }; +const setDefaultPermissionMode = (value: PermissionModeValue) => { + personalization.setDefaultPermissionMode(value); +}; + const checkModeModelConflict = (mode: RunModeValue, model: string | null): boolean => { const found = (filteredModelOptions.value || []).find((item: any) => item.value === model); const warnings: string[] = []; diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index 0158709..a53a51f 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -3,6 +3,7 @@ import { useModelStore } from './model'; export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal'; type RunMode = 'fast' | 'thinking' | 'deep'; +type PermissionMode = 'readonly' | 'approval' | 'unrestricted'; interface PersonalForm { enabled: boolean; @@ -25,6 +26,7 @@ interface PersonalForm { thinking_interval: number | null; disabled_tool_categories: string[]; default_run_mode: RunMode | null; + default_permission_mode: PermissionMode; default_model: string | null; image_compression: string; auto_shallow_compress_enabled: boolean; @@ -89,6 +91,7 @@ const defaultForm = (): PersonalForm => ({ thinking_interval: null, disabled_tool_categories: [], default_run_mode: null, + default_permission_mode: 'unrestricted', default_model: 'kimi-k2.5', image_compression: 'original', auto_shallow_compress_enabled: false, @@ -244,6 +247,12 @@ export const usePersonalizationStore = defineStore('personalization', { RUN_MODE_OPTIONS.includes(data.default_run_mode as RunMode) ? (data.default_run_mode as RunMode) : null, + default_permission_mode: + data.default_permission_mode === 'readonly' || + data.default_permission_mode === 'approval' || + data.default_permission_mode === 'unrestricted' + ? data.default_permission_mode + : 'unrestricted', default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel, image_compression: typeof data.image_compression === 'string' ? data.image_compression : 'original', @@ -478,6 +487,15 @@ export const usePersonalizationStore = defineStore('personalization', { }; this.clearFeedback(); }, + setDefaultPermissionMode(mode: PermissionMode) { + const allowed: PermissionMode[] = ['readonly', 'approval', 'unrestricted']; + const target = allowed.includes(mode) ? mode : 'unrestricted'; + this.form = { + ...this.form, + default_permission_mode: target + }; + this.clearFeedback(); + }, setDefaultModel(model: string | null) { const modelStore = useModelStore(); const allowed = new Set((modelStore.models || []).map((m) => m.key)); diff --git a/static/src/stores/ui.ts b/static/src/stores/ui.ts index a3741f7..5d2b5f3 100644 --- a/static/src/stores/ui.ts +++ b/static/src/stores/ui.ts @@ -2,7 +2,7 @@ import { defineStore } from 'pinia'; type PanelMode = 'files' | 'todo' | 'subAgents' | 'backgroundCommands'; type ResizingPanel = 'left' | 'right' | null; -type MobileOverlayTarget = 'conversation' | 'workspace' | 'focus' | null; +type MobileOverlayTarget = 'conversation' | 'workspace' | 'focus' | 'approval' | null; interface QuotaToast { message: string; diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index 9b07128..09efb81 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -14,6 +14,7 @@ .compact-input-area { display: flex; justify-content: center; + padding-bottom: 0; pointer-events: none; } @@ -23,6 +24,186 @@ pointer-events: auto; } +.permission-switcher { + position: absolute; + left: 20px; + bottom: -28px; + z-index: 35; +} + +.context-usage-switcher { + position: absolute; + right: 35px; + bottom: -28px; + z-index: 35; +} + +.context-usage-switcher__btn { + border: none; + background: transparent; + padding: 0; + width: 28px; + height: 28px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: none; +} + +.context-usage-switcher__btn:hover:not(:disabled) { + background: var(--theme-chip-bg); +} + +.context-usage-switcher__btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.context-usage-ring { + --context-progress: 0%; + --context-ring-color: var(--claude-accent); + width: 18px; + height: 18px; + border-radius: 50%; + background: + conic-gradient(var(--context-ring-color) var(--context-progress), rgba(127, 135, 146, 0.28) 0); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.context-usage-ring__inner { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--theme-surface-soft); + border: 1px solid var(--theme-control-border); +} + +.context-usage-switcher__tooltip { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + min-width: 94px; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--theme-control-border); + background: var(--theme-surface-soft); + box-shadow: var(--theme-shadow-mid); + color: var(--claude-text); + font-size: 12px; + line-height: 1.35; + opacity: 0; + transform: translateY(4px); + pointer-events: none; + transition: opacity 0.16s ease, transform 0.16s ease; +} + +.context-usage-switcher:hover .context-usage-switcher__tooltip { + opacity: 1; + transform: translateY(0); +} + +.permission-switcher__btn { + border: none; + background: transparent; + color: var(--claude-text); + border-radius: 999px; + padding: 4px 12px; + font-size: 14px; + line-height: 1.2; + cursor: pointer; + box-shadow: none; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.permission-switcher__btn:hover:not(:disabled) { + color: var(--claude-text); + text-decoration: none; + background: var(--theme-chip-bg); + box-shadow: none; +} + +.permission-switcher__btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.permission-switcher__menu { + position: absolute; + left: 0; + bottom: calc(100% + 8px); + width: 250px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border: 1px solid var(--theme-control-border); + border-radius: 14px; + background: var(--theme-surface-soft); + box-shadow: var(--theme-shadow-mid); +} + +.permission-switcher__caret { + font-size: 14px; + color: var(--claude-text-secondary); + transform: rotate(90deg); + transition: transform 0.18s ease; +} + +.permission-switcher__caret.open { + transform: rotate(270deg); +} + +.permission-switcher__item { + border: none; + background: transparent; + text-align: left; + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + color: var(--claude-text); + display: flex; + flex-direction: column; + gap: 2px; +} + +.permission-switcher__item:hover { + background: var(--theme-chip-bg); +} + +.permission-switcher__item.active { + background: var(--theme-tab-active); +} + +.permission-switcher__item-label { + font-size: 13px; + font-weight: 600; +} + +.permission-switcher__item-desc { + font-size: 11px; + color: var(--claude-text-secondary); + line-height: 1.35; +} + +@media (max-width: 768px) { + .permission-switcher { + left: 20px; + bottom: -28px; + } + + .context-usage-switcher { + right: 25px; + bottom: -28px; + } +} + .stadium-shell { --stadium-radius: 24px; position: relative; diff --git a/static/src/styles/components/overlays/_overlays.scss b/static/src/styles/components/overlays/_overlays.scss index ea307fe..de866ab 100644 --- a/static/src/styles/components/overlays/_overlays.scss +++ b/static/src/styles/components/overlays/_overlays.scss @@ -1762,6 +1762,12 @@ max-width: 100vw; } +.mobile-panel-sheet--approval { + background: var(--claude-panel); + width: min(460px, 92vw); + max-width: 92vw; +} + .mobile-panel-sheet--focus { background: var(--claude-panel); } @@ -1827,7 +1833,8 @@ padding: 12px 16px 0; } - .mobile-panel-sheet .focused-files { + .mobile-panel-sheet .focused-files, + .mobile-panel-sheet .approval-panel-body { padding-top: 8px; } } @@ -3511,6 +3518,14 @@ body[data-theme='dark'] { background: #353535; } + .approval-close-btn { + background: #2a2a2a; + } + + .approval-close-btn:hover { + background: #353535; + } + /* 表格 */ .text-output .text-content table { background: #2a2a2a; diff --git a/static/src/styles/components/panels/_focus-panel.scss b/static/src/styles/components/panels/_focus-panel.scss deleted file mode 100644 index 5614606..0000000 --- a/static/src/styles/components/panels/_focus-panel.scss +++ /dev/null @@ -1,101 +0,0 @@ -/* 聚焦文件面板 */ -.sidebar.right-sidebar { - display: flex; - flex-direction: column; -} - -.sidebar.right-sidebar .sidebar-header { - padding: 20px 20px 8px; - border-bottom: 1px solid var(--claude-border); - background: var(--claude-panel); - position: sticky; - top: 0; - z-index: 5; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.sidebar.right-sidebar .sidebar-header h3 { - font-size: 15px; - font-weight: 600; - color: var(--claude-text); - margin: 0; -} - -.focused-files { - flex: 1 1 auto; - padding: 12px 20px 20px; - overflow-y: auto; -} - -.focus-close-btn { - width: 32px; - height: 32px; - border: none; - border-radius: 16px; - background: rgba(0, 0, 0, 0.08); - color: var(--claude-text); - font-size: 20px; - cursor: pointer; -} - -.no-files { - text-align: center; - color: var(--claude-text-secondary); - padding: 60px 20px; - font-size: 14px; -} - -.file-tabs { - display: flex; - flex-direction: column; - gap: 12px; -} - -.file-tab { - border: 1px solid var(--claude-border); - border-radius: 12px; - overflow: hidden; - background: rgba(255, 255, 255, 0.75); - box-shadow: 0 12px 28px rgba(61, 57, 41, 0.08); -} - -.tab-header { - background: var(--theme-chip-bg); - padding: 10px 16px; - display: flex; - justify-content: space-between; - align-items: center; - font-size: 14px; - border-bottom: 1px solid var(--claude-border); -} - -.file-name { - font-weight: 500; - color: var(--claude-text); -} - -.file-size { - color: var(--claude-text-secondary); - font-size: 12px; -} - -.file-content { - max-height: 320px; - overflow-y: auto; - background: #ffffff; -} - -.file-content pre { - margin: 0; - padding: 16px; -} - -.file-content code { - font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; - font-size: 13px; - line-height: 1.5; - color: #1f2227; -} diff --git a/static/src/styles/components/panels/_left-panel.scss b/static/src/styles/components/panels/_left-panel.scss index f6774cc..c2e952f 100644 --- a/static/src/styles/components/panels/_left-panel.scss +++ b/static/src/styles/components/panels/_left-panel.scss @@ -368,11 +368,15 @@ min-width: 0 !important; border-left: none; overflow: hidden; + opacity: 0; + transform: translateX(8px); } .sidebar.right-sidebar.collapsed .sidebar-header, -.sidebar.right-sidebar.collapsed .focused-files { - display: none; +.sidebar.right-sidebar.collapsed .focused-files, +.sidebar.right-sidebar.collapsed .approval-panel-body { + opacity: 0; + pointer-events: none; } /* 文件树 */ diff --git a/static/src/styles/components/panels/_right-panel.scss b/static/src/styles/components/panels/_right-panel.scss new file mode 100644 index 0000000..74d6b77 --- /dev/null +++ b/static/src/styles/components/panels/_right-panel.scss @@ -0,0 +1,282 @@ +/* 聚焦文件面板 */ +.sidebar.right-sidebar { + display: flex; + flex-direction: column; + background: var(--theme-surface-soft); + transition: + width 0.28s cubic-bezier(0.4, 0, 0.2, 1), + min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.22s ease, + transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sidebar.right-sidebar .sidebar-header { + padding: 16px 20px 8px; + border-bottom: none; + background: transparent; + position: static; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sidebar.right-sidebar .sidebar-header h3 { + font-size: 15px; + font-weight: 600; + color: var(--claude-text); + margin: 0; +} + +.focused-files { + flex: 1 1 auto; + padding: 10px 20px 20px; + overflow-y: auto; +} + +.approval-panel-body { + flex: 1 1 auto; + padding: 10px 20px 20px; + overflow-y: auto; +} + +.focus-close-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 16px; + background: rgba(0, 0, 0, 0.08); + color: var(--claude-text); + font-size: 20px; + cursor: pointer; +} + +.approval-close-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 16px; + background: rgba(0, 0, 0, 0.08); + color: var(--claude-text); + font-size: 20px; + cursor: pointer; +} + +.no-files { + text-align: center; + color: var(--claude-text-secondary); + padding: 60px 20px; + font-size: 14px; +} + +.file-tabs { + display: flex; + flex-direction: column; + gap: 12px; +} + +.file-tab { + border: 1px solid var(--claude-border); + border-radius: 12px; + overflow: hidden; + background: rgba(255, 255, 255, 0.75); + box-shadow: 0 12px 28px rgba(61, 57, 41, 0.08); +} + +.tab-header { + background: var(--theme-chip-bg); + padding: 10px 16px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; + border-bottom: 1px solid var(--claude-border); +} + +.file-name { + font-weight: 500; + color: var(--claude-text); +} + +.file-size { + color: var(--claude-text-secondary); + font-size: 12px; +} + +.file-content { + max-height: 320px; + overflow-y: auto; + background: #ffffff; +} + +.file-content pre { + margin: 0; + padding: 16px; +} + +.file-content code { + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + font-size: 13px; + line-height: 1.5; + color: #1f2227; +} + +.tool-approval-panel .approval-panel-body { + padding-top: 14px; +} + +.approval-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.approval-card { + border: 1px solid var(--claude-border); + border-radius: 12px; + background: var(--theme-surface-soft); + box-shadow: var(--theme-shadow-soft); + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.approval-card__title { + font-size: 13px; + font-weight: 700; + color: var(--claude-text); +} + +.approval-card__summary { + font-size: 12px; + color: var(--claude-text-secondary); +} + +.approval-path { + font-size: 12px; + color: var(--claude-text-secondary); + word-break: break-all; +} + +.approval-lines { + margin: 0; + padding: 8px; + border-radius: 8px; + font-size: 11px; + line-height: 1.45; + overflow: auto; + max-height: 200px; + white-space: pre-wrap; + word-break: break-word; + background: var(--theme-surface-muted); + scrollbar-width: none; + -ms-overflow-style: none; +} + +.approval-lines::-webkit-scrollbar { + width: 0; + height: 0; +} + +.approval-diff { + margin-top: 12px; + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 12px; + line-height: 1.5; + max-height: 400px; + overflow-y: auto; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + padding: 8px; + background: rgba(0, 0, 0, 0.01); +} + +.approval-kv { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--claude-text-secondary); + background: var(--theme-surface-muted); + border: 1px solid var(--theme-control-border); + border-radius: 8px; + padding: 8px; +} + +.approval-kv strong { + white-space: nowrap; +} + +.approval-value { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.approval-note { + padding: 6px 8px; + font-size: 11px; + color: var(--claude-text-secondary); +} + +.approval-lines--old { + background: color-mix(in srgb, var(--claude-warning) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--claude-warning) 50%, transparent); +} + +.approval-lines--new { + background: color-mix(in srgb, var(--claude-success) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--claude-success) 50%, transparent); +} + +.approval-lines--cmd, +.approval-lines--generic { + background: var(--theme-surface-muted); +} + +.tool-approval-panel .diff-line { + padding: 2px 4px; + margin: 1px 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +.tool-approval-panel .diff-remove { + background: rgba(255, 0, 0, 0.1); + color: #c00; +} + +.tool-approval-panel .diff-add { + background: rgba(0, 255, 0, 0.1); + color: #0a0; +} + +.approval-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.approval-btn { + border: none; + border-radius: 8px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; +} + +.approval-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.approval-btn--approve { + color: var(--theme-surface-strong); + background: var(--claude-success); +} + +.approval-btn--reject { + color: var(--theme-surface-strong); + background: var(--claude-accent-strong); +} diff --git a/static/src/styles/index.scss b/static/src/styles/index.scss index 736c858..adfba46 100644 --- a/static/src/styles/index.scss +++ b/static/src/styles/index.scss @@ -5,7 +5,7 @@ @use './layout/panels'; @use './components/sidebar/conversation'; @use './components/panels/left-panel'; -@use './components/panels/focus-panel'; +@use './components/panels/right-panel'; @use './components/panels/resource-panel'; @use './components/chat/chat-area'; @use './components/chat/virtual-monitor'; diff --git a/static/src/utils/chatDisplay.ts b/static/src/utils/chatDisplay.ts index e8d0903..6993aac 100644 --- a/static/src/utils/chatDisplay.ts +++ b/static/src/utils/chatDisplay.ts @@ -185,6 +185,13 @@ export function getToolStatusText(tool: any, opts?: { intentEnabled?: boolean }) const label = RUNNING_STATUS_TEXTS[tool.name] || tool.display_name || tool.name || ''; return label ? label : '调用工具中'; } + if ( + tool.status === 'awaiting_approval' || + tool.status === 'pending_approval' || + tool.status === 'pending' + ) { + return '等待审批...'; + } if (tool.status === 'completed') { if (tool.name === 'read_file') { return describeReadFileResult(tool);