feat: ship permission/approval UI with mobile support and context usage ring
This commit is contained in:
parent
e814e89e32
commit
34a6577f3a
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
v-if="!isMobileViewport"
|
||||
class="resize-handle"
|
||||
@mousedown="startResize('right', $event)"
|
||||
></div>
|
||||
<ToolApprovalPanel
|
||||
v-if="!isMobileViewport"
|
||||
:collapsed="rightCollapsed"
|
||||
:width="rightWidth"
|
||||
:approvals="pendingToolApprovals"
|
||||
:deciding-approval-ids="decidingApprovalIds"
|
||||
@approve="approveToolApproval"
|
||||
@reject="rejectToolApproval"
|
||||
@close="rightCollapsed = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PersonalizationDrawer v-if="personalizationStore.visible" />
|
||||
@ -609,6 +625,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="mobile-panel-overlay">
|
||||
<div
|
||||
v-if="isMobileViewport && activeMobileOverlay === 'approval'"
|
||||
class="mobile-panel-overlay mobile-panel-overlay--right"
|
||||
@click.self="closeMobileOverlay"
|
||||
>
|
||||
<div class="mobile-panel-sheet mobile-panel-sheet--approval">
|
||||
<ToolApprovalPanel
|
||||
class="mobile-overlay-content"
|
||||
:collapsed="false"
|
||||
:width="Math.min(rightWidth || 420, 460)"
|
||||
:approvals="pendingToolApprovals"
|
||||
:deciding-approval-ids="decidingApprovalIds"
|
||||
@approve="approveToolApproval"
|
||||
@reject="rejectToolApproval"
|
||||
@close="closeMobileOverlay"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>URL:</strong>${escapeHtml(url)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>路径:</strong>${escapeHtml(oldPath)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>操作:</strong>${escapeHtml(operation)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>等待时间:</strong>${seconds}秒</div>`;
|
||||
@ -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 || '';
|
||||
|
||||
|
||||
@ -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, any>) => 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 = '<div class="tool-result-meta">';
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>URL:</strong>${escapeHtml(url)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>路径:</strong>${escapeHtml(oldPath)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>操作:</strong>${escapeHtml(operation)}</div>`;
|
||||
@ -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 = '<div class="tool-result-meta">';
|
||||
html += `<div><strong>等待时间:</strong>${seconds}秒</div>`;
|
||||
@ -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 || '';
|
||||
|
||||
|
||||
@ -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')"
|
||||
/>
|
||||
<div class="permission-switcher" @click.stop>
|
||||
<button
|
||||
type="button"
|
||||
class="permission-switcher__btn"
|
||||
:disabled="!isConnected || streamingMessage"
|
||||
@click="$emit('toggle-permission-menu')"
|
||||
>
|
||||
<span>权限:{{ currentPermissionLabel }}</span>
|
||||
<span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }">›</span>
|
||||
</button>
|
||||
<div v-if="permissionMenuOpen" class="permission-switcher__menu">
|
||||
<button
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="permission-switcher__item"
|
||||
:class="{ active: option.value === currentPermissionMode }"
|
||||
@click="$emit('change-permission-mode', option.value)"
|
||||
>
|
||||
<span class="permission-switcher__item-label">{{ option.label }}</span>
|
||||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-usage-switcher" @click.stop>
|
||||
<button
|
||||
type="button"
|
||||
class="context-usage-switcher__btn"
|
||||
:disabled="!isConnected"
|
||||
@click="$emit('toggle-token-panel')"
|
||||
>
|
||||
<span
|
||||
class="context-usage-ring"
|
||||
:style="{
|
||||
'--context-progress': `${contextUsagePercent}%`,
|
||||
'--context-ring-color': contextUsageColor
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="context-usage-ring__inner"></span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="context-usage-switcher__tooltip">
|
||||
<div>{{ contextUsagePercentLabel }}已用</div>
|
||||
<div>{{ contextUsageCompactText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch, nextTick } from 'vue';
|
||||
import { onMounted, ref, watch, nextTick, computed } from 'vue';
|
||||
import QuickMenu from '@/components/input/QuickMenu.vue';
|
||||
import { useInputStore } from '@/stores/input';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
|
||||
defineOptions({ name: 'InputComposer' });
|
||||
|
||||
@ -166,10 +215,13 @@ const emit = defineEmits([
|
||||
'toggle-focus-panel',
|
||||
'toggle-token-panel',
|
||||
'compress-conversation',
|
||||
'toggle-approval-panel',
|
||||
'file-selected',
|
||||
'remove-image',
|
||||
'remove-video',
|
||||
'open-review'
|
||||
'open-review',
|
||||
'toggle-permission-menu',
|
||||
'change-permission-mode'
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
@ -200,6 +252,7 @@ const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
supportsImage?: boolean;
|
||||
supportsVideo?: boolean;
|
||||
contextWindow?: number | null;
|
||||
}>;
|
||||
currentModelKey: string;
|
||||
selectedImages?: string[];
|
||||
@ -212,9 +265,14 @@ const props = defineProps<{
|
||||
blockTokenPanel?: boolean;
|
||||
blockCompressConversation?: boolean;
|
||||
blockConversationReview?: boolean;
|
||||
currentPermissionMode: 'readonly' | 'approval' | 'unrestricted';
|
||||
permissionMenuOpen: boolean;
|
||||
permissionOptions: Array<{ value: string; label: string; description: string }>;
|
||||
currentContextTokens: number;
|
||||
}>();
|
||||
|
||||
const inputStore = useInputStore();
|
||||
const personalizationStore = usePersonalizationStore();
|
||||
const stadiumShellOuter = ref<HTMLElement | null>(null);
|
||||
const compactInputShell = ref<HTMLElement | null>(null);
|
||||
const stadiumInput = ref<HTMLTextAreaElement | null>(null);
|
||||
@ -284,6 +342,72 @@ const triggerQuickUpload = () => {
|
||||
emit('quick-upload');
|
||||
};
|
||||
|
||||
const currentPermissionLabel = computed(() => {
|
||||
const matched = (props.permissionOptions || []).find(
|
||||
(item) => item.value === props.currentPermissionMode
|
||||
);
|
||||
return matched ? matched.label : props.currentPermissionMode;
|
||||
});
|
||||
|
||||
const modelContextWindow = computed(() => {
|
||||
const list = Array.isArray(props.modelOptions) ? props.modelOptions : [];
|
||||
const current = list.find((item) => item.key === props.currentModelKey);
|
||||
return Number(current?.contextWindow || 0);
|
||||
});
|
||||
|
||||
const autoDeepCompressEnabled = computed(() => {
|
||||
return !!personalizationStore?.form?.auto_deep_compress_enabled;
|
||||
});
|
||||
|
||||
const deepCompressLimit = computed(() => {
|
||||
const custom = Number(personalizationStore?.form?.deep_compress_trigger_tokens || 0);
|
||||
if (custom > 0) return custom;
|
||||
return 150000;
|
||||
});
|
||||
|
||||
const contextUsageLimit = computed(() => {
|
||||
if (autoDeepCompressEnabled.value) {
|
||||
return deepCompressLimit.value;
|
||||
}
|
||||
if (modelContextWindow.value > 0) {
|
||||
return modelContextWindow.value;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const contextUsagePercent = computed(() => {
|
||||
const limit = Number(contextUsageLimit.value || 0);
|
||||
if (limit <= 0) return 0;
|
||||
const current = Math.max(0, Number(props.currentContextTokens || 0));
|
||||
return Math.max(0, Math.min(100, (current / limit) * 100));
|
||||
});
|
||||
|
||||
const contextUsagePercentLabel = computed(() => `${Math.round(contextUsagePercent.value)}%`);
|
||||
|
||||
const contextUsageColor = computed(() => {
|
||||
const percent = contextUsagePercent.value;
|
||||
if (percent < 40) return '#16a34a';
|
||||
if (percent <= 80) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
});
|
||||
|
||||
const formatCompactTokens = (value: number) => {
|
||||
const num = Math.max(0, Number(value || 0));
|
||||
if (num >= 1000) {
|
||||
const raw = num / 1000;
|
||||
const text = raw >= 100 ? Math.round(raw).toString() : raw.toFixed(1).replace(/\.0$/, '');
|
||||
return `${text}k`;
|
||||
}
|
||||
return `${Math.round(num)}`;
|
||||
};
|
||||
|
||||
const contextUsageCompactText = computed(() => {
|
||||
const current = formatCompactTokens(props.currentContextTokens || 0);
|
||||
const limit = Number(contextUsageLimit.value || 0);
|
||||
if (limit <= 0) return `${current}/--`;
|
||||
return `${current}/${formatCompactTokens(limit)}`;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
stadiumShellOuter,
|
||||
compactInputShell,
|
||||
|
||||
@ -121,6 +121,14 @@
|
||||
>
|
||||
{{ compressing ? '压缩中...' : '压缩对话' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-entry submenu-entry"
|
||||
@click="$emit('toggle-approval-panel')"
|
||||
:disabled="!currentConversationId"
|
||||
>
|
||||
审批面板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@ -176,6 +184,7 @@ defineEmits<{
|
||||
(event: 'realtime-terminal'): void;
|
||||
(event: 'toggle-token-panel'): void;
|
||||
(event: 'compress-conversation'): void;
|
||||
(event: 'toggle-approval-panel'): void;
|
||||
(event: 'toggle-mode-menu'): void;
|
||||
(event: 'select-run-mode', mode: 'fast' | 'thinking' | 'deep'): void;
|
||||
(event: 'toggle-model-menu'): void;
|
||||
|
||||
171
static/src/components/panels/ToolApprovalPanel.vue
Normal file
171
static/src/components/panels/ToolApprovalPanel.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<aside
|
||||
class="sidebar right-sidebar tool-approval-panel"
|
||||
:class="{ collapsed }"
|
||||
:style="{ width: collapsed ? '0px' : width + 'px' }"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<h3 class="icon-label">工具审批 ({{ approvals.length }})</h3>
|
||||
<button type="button" class="approval-close-btn" aria-label="关闭审批面板" @click="$emit('close')">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="approval-panel-body" v-if="!collapsed">
|
||||
<div v-if="!approvals.length" class="no-files">暂无待审批操作</div>
|
||||
<div v-else class="approval-list">
|
||||
<div v-for="item in approvals" :key="item.approval_id" class="approval-card">
|
||||
<div class="approval-card__title">{{ item.tool_name }}</div>
|
||||
<div class="approval-card__summary">{{ getToolLabel(item.tool_name) }}</div>
|
||||
|
||||
<div v-if="isEditPreview(item)" class="approval-edit-preview">
|
||||
<div class="approval-path">{{ item.preview?.file_path || item.preview?.resolved_path }}</div>
|
||||
<div class="tool-result-diff scrollable approval-diff">
|
||||
<div
|
||||
class="diff-line diff-remove"
|
||||
v-for="(line, idx) in getEditOldLines(item)"
|
||||
:key="`o-${item.approval_id}-${idx}`"
|
||||
>
|
||||
- {{ line }}
|
||||
</div>
|
||||
<div
|
||||
class="diff-line diff-add"
|
||||
v-for="(line, idx) in getEditNewLines(item)"
|
||||
:key="`n-${item.approval_id}-${idx}`"
|
||||
>
|
||||
+ {{ line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isWritePreview(item)" class="approval-edit-preview">
|
||||
<div class="approval-path">{{ item.preview?.file_path }}</div>
|
||||
<div class="tool-result-diff scrollable approval-diff">
|
||||
<div
|
||||
class="diff-line diff-add"
|
||||
v-for="(line, idx) in getWriteLines(item)"
|
||||
:key="`w-${item.approval_id}-${idx}`"
|
||||
>
|
||||
+ {{ line }}
|
||||
</div>
|
||||
<div class="approval-note" v-if="isWriteTruncated(item)">
|
||||
内容过长,已截断展示
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isPathOnlyPreview(item)" class="approval-kv">
|
||||
<div>
|
||||
<strong>路径:</strong
|
||||
><span class="approval-value approval-value--path">{{ resolvePath(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isRenamePreview(item)" class="approval-kv">
|
||||
<div>
|
||||
<strong>重命名:</strong
|
||||
><span class="approval-value approval-value--path"
|
||||
>{{ item.preview?.old_path }} → {{ item.preview?.new_path }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
v-else-if="isCommandPreview(item)"
|
||||
class="approval-lines approval-lines--cmd"
|
||||
>{{ item.preview?.command || '' }}</pre>
|
||||
|
||||
<div v-else class="approval-kv">
|
||||
<div><strong>工具:</strong>{{ item.tool_name }}</div>
|
||||
<div v-if="item.preview?.summary"><strong>说明:</strong>{{ item.preview.summary }}</div>
|
||||
</div>
|
||||
|
||||
<div class="approval-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="approval-btn approval-btn--approve"
|
||||
:disabled="isDeciding(item.approval_id)"
|
||||
@click="$emit('approve', item.approval_id)"
|
||||
>
|
||||
运行
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="approval-btn approval-btn--reject"
|
||||
:disabled="isDeciding(item.approval_id)"
|
||||
@click="$emit('reject', item.approval_id)"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ToolApprovalPanel' });
|
||||
|
||||
const props = defineProps<{
|
||||
collapsed: boolean;
|
||||
width: number;
|
||||
approvals: Array<any>;
|
||||
decidingApprovalIds?: string[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(event: 'approve', approvalId: string): void;
|
||||
(event: 'reject', approvalId: string): void;
|
||||
(event: 'close'): void;
|
||||
}>();
|
||||
|
||||
const isEditPreview = (item: any) => item?.tool_name === 'edit_file' && item?.preview?.edit_context;
|
||||
const isCommandPreview = (item: any) => ['run_command', 'terminal_input'].includes(item?.tool_name);
|
||||
const isWritePreview = (item: any) => item?.tool_name === 'write_file';
|
||||
const isRenamePreview = (item: any) => item?.tool_name === 'rename_file';
|
||||
const isPathOnlyPreview = (item: any) =>
|
||||
['create_file', 'create_folder', 'delete_file'].includes(item?.tool_name);
|
||||
|
||||
const isDeciding = (approvalId: string) =>
|
||||
Array.isArray(props.decidingApprovalIds) && props.decidingApprovalIds.includes(approvalId);
|
||||
|
||||
const resolvePath = (item: any) => {
|
||||
return item?.preview?.path || item?.preview?.file_path || item?.arguments?.path || item?.arguments?.file_path || '';
|
||||
};
|
||||
|
||||
const getToolLabel = (toolName: string) => {
|
||||
const name = String(toolName || '');
|
||||
const map: Record<string, string> = {
|
||||
run_command: '执行命令',
|
||||
terminal_input: '终端输入',
|
||||
create_file: '创建文件',
|
||||
create_folder: '创建文件夹',
|
||||
delete_file: '删除文件',
|
||||
rename_file: '重命名文件',
|
||||
write_file: '写入文件',
|
||||
edit_file: '编辑文件'
|
||||
};
|
||||
return map[name] || name || '待审批操作';
|
||||
};
|
||||
|
||||
const getWriteLines = (item: any): string[] => {
|
||||
const text = String(item?.preview?.content_preview || '');
|
||||
return text.split('\n');
|
||||
};
|
||||
|
||||
const getEditOldLines = (item: any): string[] => {
|
||||
const rows = Array.isArray(item?.preview?.edit_context?.old) ? item.preview.edit_context.old : [];
|
||||
return rows.map((row: any) => String(row?.content ?? ''));
|
||||
};
|
||||
|
||||
const getEditNewLines = (item: any): string[] => {
|
||||
const rows = Array.isArray(item?.preview?.edit_context?.new) ? item.preview.edit_context.new : [];
|
||||
return rows.map((row: any) => String(row?.content ?? ''));
|
||||
};
|
||||
|
||||
const isWriteTruncated = (item: any): boolean => {
|
||||
const text = String(item?.preview?.content_preview || '');
|
||||
const length = Number(item?.preview?.content_length || 0);
|
||||
return length > text.length;
|
||||
};
|
||||
</script>
|
||||
@ -424,6 +424,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else-if="activeTab === 'workspace'"
|
||||
key="workspace"
|
||||
class="personal-page behavior-page"
|
||||
data-tutorial="personal-page-workspace"
|
||||
>
|
||||
<div class="behavior-section">
|
||||
<div class="behavior-field">
|
||||
<div class="behavior-field-header">
|
||||
<span class="field-title">默认权限</span>
|
||||
<p class="field-desc">
|
||||
新建对话时的默认权限模式。权限会与对话绑定,切换对话会自动切换对应权限。
|
||||
</p>
|
||||
</div>
|
||||
<div class="run-mode-options">
|
||||
<button
|
||||
v-for="option in permissionModeOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="run-mode-card"
|
||||
:class="{ active: form.default_permission_mode === option.id }"
|
||||
:aria-pressed="form.default_permission_mode === option.id"
|
||||
@click.prevent="setDefaultPermissionMode(option.id)"
|
||||
>
|
||||
<div class="run-mode-card-header">
|
||||
<span class="run-mode-title">{{ option.label }}</span>
|
||||
</div>
|
||||
<p class="run-mode-desc">{{ option.desc }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="personal-actions-row">
|
||||
<div class="personal-form-actions card-aligned">
|
||||
<div class="personal-status-group">
|
||||
<transition name="personal-status-fade">
|
||||
<span class="status success" v-if="status">{{ status }}</span>
|
||||
</transition>
|
||||
<transition name="personal-status-fade">
|
||||
<span class="status error" v-if="error">{{ error }}</span>
|
||||
</transition>
|
||||
</div>
|
||||
<button type="submit" class="primary" :disabled="saving">
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else-if="activeTab === 'usage'"
|
||||
key="usage"
|
||||
@ -1328,6 +1377,7 @@ const {
|
||||
const baseTabs = [
|
||||
{ id: 'preferences', label: '个性化设置', description: '称呼、语气与注意事项' },
|
||||
{ id: 'model', label: '模型偏好', description: '默认模型选择' },
|
||||
{ id: 'workspace', label: '工作区设置', description: '默认权限模式' },
|
||||
{ id: 'usage', label: '用量统计', description: '对话累计数据' },
|
||||
{ id: 'app-update', label: '软件更新', description: '检查并下载新版 APK' },
|
||||
{ id: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
|
||||
@ -1340,6 +1390,7 @@ const baseTabs = [
|
||||
type PersonalTab =
|
||||
| 'preferences'
|
||||
| 'model'
|
||||
| 'workspace'
|
||||
| 'usage'
|
||||
| 'app-update'
|
||||
| 'behavior'
|
||||
@ -1369,6 +1420,7 @@ const activeTab = ref<PersonalTab>('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[] = [];
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/* 文件树 */
|
||||
|
||||
282
static/src/styles/components/panels/_right-panel.scss
Normal file
282
static/src/styles/components/panels/_right-panel.scss
Normal file
@ -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);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user