From 111fa8c92e8d98e4c57ddb8d2b1fe018ce5d31c8 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Sat, 7 Mar 2026 14:55:38 +0800 Subject: [PATCH] feat: refactor composer to two-layer layout, add session permission & context indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer restructure: - MessageInput internal footer now only contains: file button, model selector, slash command button, and send button - Removed mode toggle (Code/Plan) from composer area (logic preserved) - New ChatComposerActionBar below input with three slots: left (Design Agent), center (permission selector), right (context indicator) New components: - SlashCommandButton: inserts `/` at cursor, reuses existing slash command detection and popover logic - ChatPermissionSelector: session-level permission toggle (default / full_access) with danger confirmation dialog - ContextUsageIndicator: SVG donut chart showing context usage ratio with HoverCard displaying model, tokens, cache stats - ChatComposerActionBar: slot-based container for action bar items Session permission (permission_profile): - DB: new column on chat_sessions, default 'default' - API: PATCH sessions supports updating permission_profile - Chat API: bypassPermissions = global setting OR session full_access - PermissionPrompt: auto-approves and hides when full_access - Claude client: combines global and per-session bypass flags Bridge integration: - permission-broker: auto-approves permissions for full_access sessions without sending IM notifications - conversation-engine: passes bypassPermissions to SDK for full_access - PATCH route: auto-approves pending bridge permissions on switch to full_access Other: - ImageGenToggle relabeled to "Design Agent" / "设计 Agent" - New model-context.ts with MODEL_CONTEXT_WINDOWS lookup - Provider models API returns optional contextWindow field - New useContextUsage hook for token usage calculation - i18n: added composer.*, permission.*, context.* keys (en + zh) Co-Authored-By: Claude Opus 4.6 --- docs/exec-plans/active/composer-refactor.md | 51 ++++++++ src/app/api/chat/route.ts | 1 + src/app/api/chat/sessions/[id]/route.ts | 18 ++- src/app/api/providers/models/route.ts | 11 +- src/app/chat/[id]/page.tsx | 4 +- src/app/chat/page.tsx | 5 + src/components/chat/ChatComposerActionBar.tsx | 25 ++++ .../chat/ChatPermissionSelector.tsx | 118 +++++++++++++++++ src/components/chat/ChatView.tsx | 30 ++++- src/components/chat/ContextUsageIndicator.tsx | 120 ++++++++++++++++++ src/components/chat/ImageGenToggle.tsx | 4 +- src/components/chat/MessageInput.tsx | 44 +++---- src/components/chat/PermissionPrompt.tsx | 21 ++- src/components/chat/SlashCommandButton.tsx | 33 +++++ src/hooks/useContextUsage.ts | 63 +++++++++ src/i18n/en.ts | 25 ++++ src/i18n/zh.ts | 25 ++++ src/lib/bridge/conversation-engine.ts | 4 + src/lib/bridge/permission-broker.ts | 43 ++++++- src/lib/claude-client.ts | 8 +- src/lib/db.ts | 8 ++ src/lib/model-context.ts | 14 ++ src/types/index.ts | 5 +- 23 files changed, 643 insertions(+), 37 deletions(-) create mode 100644 docs/exec-plans/active/composer-refactor.md create mode 100644 src/components/chat/ChatComposerActionBar.tsx create mode 100644 src/components/chat/ChatPermissionSelector.tsx create mode 100644 src/components/chat/ContextUsageIndicator.tsx create mode 100644 src/components/chat/SlashCommandButton.tsx create mode 100644 src/hooks/useContextUsage.ts create mode 100644 src/lib/model-context.ts diff --git a/docs/exec-plans/active/composer-refactor.md b/docs/exec-plans/active/composer-refactor.md new file mode 100644 index 0000000..0992076 --- /dev/null +++ b/docs/exec-plans/active/composer-refactor.md @@ -0,0 +1,51 @@ +# Composer 重构 + 单聊天权限 + 远程桥接联动 + +## 状态: 🟡 进行中 + +## 概述 +重构聊天输入区为两层结构,新增 session 级权限档位,桥接联动,上下文占用指示器。 + +## 轨道拆分 + +### Track 1: Backend & Data(数据层) +- [ ] db.ts: chat_sessions 新增 permission_profile TEXT NOT NULL DEFAULT 'default' +- [ ] types/index.ts: Session 类型新增 permission_profile 字段 +- [ ] api/chat/sessions/route.ts: 创建 session 时写入 default +- [ ] api/chat/sessions/[id]/route.ts: GET 返回 permission_profile, PATCH 支持更新 +- [ ] api/chat/route.ts: bypassPermissions = global || session.permission_profile === 'full_access' +- [ ] lib/model-context.ts: 新建模型 contextWindow 常量表 +- [ ] api/providers/models/route.ts: 返回可选 contextWindow 字段 +- [ ] i18n/en.ts + zh.ts: 新增所有翻译 key + +### Track 2: Composer UI 重构 +- [ ] MessageInput.tsx: 内部 footer 只保留文件、模型、slash、发送 +- [ ] 移除 mode toggle(保留后端逻辑) +- [ ] 新建 ChatComposerActionBar.tsx: 输入框外部操作栏容器 +- [ ] 新建 SlashCommandButton.tsx: 复用现有 slash 逻辑 +- [ ] ImageGenToggle 改文案"设计 Agent",移到 action bar +- [ ] ChatView.tsx + page.tsx: 集成新布局 + +### Track 3: 权限选择器 + 上下文指示器 +- [ ] 新建 ChatPermissionSelector.tsx: default/full_access 切换 + 危险确认 +- [ ] 新建 ContextUsageIndicator.tsx: 圆圈 + HoverCard +- [ ] 新建 hooks/useContextUsage.ts: 解析 usage、计算 ratio +- [ ] 集成到 ChatComposerActionBar + +### Track 4: Bridge 联动 +- [ ] permission-broker.ts: 检查 session permission_profile +- [ ] bridge-manager.ts: full_access 时不发权限通知 +- [ ] conversation-engine.ts: full_access 时跳过权限卡片 +- [ ] 处理切换时已存在的 pending permission 失效 + +## 依赖关系 +- Track 2/3/4 都依赖 Track 1 的类型和 DB 字段 +- Track 2 和 Track 3 可并行,最后在 ChatView 集成 +- Track 4 独立于 UI 轨道 + +## 测试清单 +- [ ] session 级 permission_profile 持久化 +- [ ] slash 按钮触发现有命令流程 +- [ ] full_access 下本地不显示权限确认 +- [ ] full_access 下 bridge 不发权限通知 +- [ ] context hover 展示明细 +- [ ] composer 布局回归 diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index b5dcc5a..4e66bd3 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -314,6 +314,7 @@ Start by greeting the user and asking the first question. toolTimeoutSeconds: toolTimeout || 300, provider: resolvedProvider, conversationHistory: historyMsgs, + bypassPermissions: session.permission_profile === 'full_access', onRuntimeStatusChange: (status: string) => { try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ } }, diff --git a/src/app/api/chat/sessions/[id]/route.ts b/src/app/api/chat/sessions/[id]/route.ts index b44ee0d..3229598 100644 --- a/src/app/api/chat/sessions/[id]/route.ts +++ b/src/app/api/chat/sessions/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from 'next/server'; -import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode, updateSessionModel, updateSessionProviderId, clearSessionMessages, updateSdkSessionId } from '@/lib/db'; +import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode, updateSessionModel, updateSessionProviderId, clearSessionMessages, updateSdkSessionId, updateSessionPermissionProfile } from '@/lib/db'; +import { autoApprovePendingForSession } from '@/lib/bridge/permission-broker'; export async function GET( _request: NextRequest, @@ -49,6 +50,21 @@ export async function PATCH( if (body.sdk_session_id !== undefined) { updateSdkSessionId(id, body.sdk_session_id); } + if (body.permission_profile !== undefined) { + if (body.permission_profile !== 'default' && body.permission_profile !== 'full_access') { + return Response.json({ error: 'permission_profile must be "default" or "full_access"' }, { status: 400 }); + } + // When switching to full_access, auto-approve any pending bridge permissions + const previousProfile = session.permission_profile || 'default'; + updateSessionPermissionProfile(id, body.permission_profile); + if (previousProfile !== 'full_access' && body.permission_profile === 'full_access') { + try { + autoApprovePendingForSession(id); + } catch (err) { + console.warn('[session-api] Failed to auto-approve pending permissions:', err); + } + } + } if (body.clear_messages) { clearSessionMessages(id); } diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index c77aced..b411918 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { getAllProviders, getDefaultProviderId } from '@/lib/db'; +import { getContextWindow } from '@/lib/model-context'; import type { ErrorResponse, ProviderModelGroup } from '@/types'; // Default Claude model options @@ -89,7 +90,10 @@ export async function GET() { provider_id: 'env', provider_name: 'Claude Code', provider_type: 'anthropic', - models: DEFAULT_MODELS, + models: DEFAULT_MODELS.map(m => { + const cw = getContextWindow(m.value); + return cw != null ? { ...m, contextWindow: cw } : m; + }), }); // Provider types that are not LLMs (e.g. image generation) — skip in chat model selector @@ -112,7 +116,10 @@ export async function GET() { } catch { /* use default */ } } - const models = deduplicateModels(rawModels); + const models = deduplicateModels(rawModels).map(m => { + const cw = getContextWindow(m.value); + return cw != null ? { ...m, contextWindow: cw } : m; + }); groups.push({ provider_id: provider.id, diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index ea808b4..c2b0185 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -25,6 +25,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [sessionModel, setSessionModel] = useState(''); const [sessionProviderId, setSessionProviderId] = useState(''); const [sessionMode, setSessionMode] = useState(''); + const [sessionPermissionProfile, setSessionPermissionProfile] = useState<'default' | 'full_access'>('default'); const [projectName, setProjectName] = useState(''); const [sessionWorkingDir, setSessionWorkingDir] = useState(''); const [isEditingTitle, setIsEditingTitle] = useState(false); @@ -101,6 +102,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { setSessionModel(data.session.model || ''); setSessionProviderId(data.session.provider_id || ''); setSessionMode(data.session.mode || 'code'); + setSessionPermissionProfile(data.session.permission_profile || 'default'); setProjectName(data.session.project_name || ''); } } catch { @@ -239,7 +241,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { )} )} - + ); } diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index f78e46b..715cb8a 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation'; import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent } from '@/types'; import { MessageList } from '@/components/chat/MessageList'; import { MessageInput } from '@/components/chat/MessageInput'; +import { ChatComposerActionBar } from '@/components/chat/ChatComposerActionBar'; +import { ImageGenToggle } from '@/components/chat/ImageGenToggle'; import { PermissionPrompt } from '@/components/chat/PermissionPrompt'; import { usePanel } from '@/hooks/usePanel'; @@ -384,6 +386,9 @@ export default function NewChatPage() { mode={mode} onModeChange={setMode} /> + } + /> ); } diff --git a/src/components/chat/ChatComposerActionBar.tsx b/src/components/chat/ChatComposerActionBar.tsx new file mode 100644 index 0000000..73ac2d8 --- /dev/null +++ b/src/components/chat/ChatComposerActionBar.tsx @@ -0,0 +1,25 @@ +'use client'; + +import type { ReactNode } from 'react'; + +interface ChatComposerActionBarProps { + left?: ReactNode; + center?: ReactNode; + right?: ReactNode; +} + +export function ChatComposerActionBar({ left, center, right }: ChatComposerActionBarProps) { + return ( +
+
+ {left} +
+
+ {center} +
+
+ {right} +
+
+ ); +} diff --git a/src/components/chat/ChatPermissionSelector.tsx b/src/components/chat/ChatPermissionSelector.tsx new file mode 100644 index 0000000..2f12e4a --- /dev/null +++ b/src/components/chat/ChatPermissionSelector.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslation } from '@/hooks/useTranslation'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { LockIcon, SquareUnlock02Icon } from '@hugeicons/core-free-icons'; + +interface ChatPermissionSelectorProps { + sessionId: string; + permissionProfile: 'default' | 'full_access'; + onPermissionChange: (profile: 'default' | 'full_access') => void; +} + +export function ChatPermissionSelector({ + sessionId, + permissionProfile, + onPermissionChange, +}: ChatPermissionSelectorProps) { + const { t } = useTranslation(); + const [showWarning, setShowWarning] = useState(false); + + const handleSelect = (profile: 'default' | 'full_access') => { + if (profile === 'full_access' && permissionProfile !== 'full_access') { + setShowWarning(true); + return; + } + applyChange(profile); + }; + + const applyChange = async (profile: 'default' | 'full_access') => { + try { + await fetch(`/api/chat/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ permission_profile: profile }), + }); + onPermissionChange(profile); + } catch { + // silent + } + }; + + const isFullAccess = permissionProfile === 'full_access'; + + return ( + <> + + + + + + handleSelect('default')}> + + {t('permission.default')} + + handleSelect('full_access')}> + + {t('permission.fullAccess')} + + + + + + + + {t('permission.fullAccess')} + + {t('permission.fullAccessWarning')} + + + + {t('common.cancel')} + { + setShowWarning(false); + applyChange('full_access'); + }} + > + {t('permission.fullAccess')} + + + + + + ); +} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 6b4d0d7..652ad8c 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -5,6 +5,10 @@ import { useRouter } from 'next/navigation'; import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types'; import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; +import { ChatComposerActionBar } from './ChatComposerActionBar'; +import { ChatPermissionSelector } from './ChatPermissionSelector'; +import { ContextUsageIndicator } from './ContextUsageIndicator'; +import { ImageGenToggle } from './ImageGenToggle'; import { usePanel } from '@/hooks/usePanel'; import { useTranslation } from '@/hooks/useTranslation'; import { PermissionPrompt } from './PermissionPrompt'; @@ -26,13 +30,15 @@ interface ChatViewProps { modelName?: string; initialMode?: string; providerId?: string; + initialPermissionProfile?: 'default' | 'full_access'; } -export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId }: ChatViewProps) { +export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId, initialPermissionProfile }: ChatViewProps) { const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel(); const { t } = useTranslation(); const router = useRouter(); const [messages, setMessages] = useState(initialMessages); + const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>(initialPermissionProfile || 'default'); // Workspace mismatch banner state const [workspaceMismatchPath, setWorkspaceMismatchPath] = useState(null); @@ -52,6 +58,11 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal useEffect(() => { setCurrentProviderId(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || ''); }, [providerId]); + useEffect(() => { + if (initialPermissionProfile) { + setPermissionProfile(initialPermissionProfile); + } + }, [initialPermissionProfile]); // Stream snapshot from the manager — drives all streaming UI const [streamSnapshot, setStreamSnapshot] = useState( @@ -659,6 +670,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal permissionResolved={permissionResolved} onPermissionResponse={handlePermissionResponse} toolUses={toolUses} + permissionProfile={permissionProfile} /> {/* Batch image generation panels — shown above the input area */} @@ -681,6 +693,22 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal onModeChange={handleModeChange} onAssistantTrigger={checkAssistantTrigger} /> + } + center={ + + } + right={ + + } + /> ); } diff --git a/src/components/chat/ContextUsageIndicator.tsx b/src/components/chat/ContextUsageIndicator.tsx new file mode 100644 index 0000000..eb56d87 --- /dev/null +++ b/src/components/chat/ContextUsageIndicator.tsx @@ -0,0 +1,120 @@ +'use client'; + +import type { Message } from '@/types'; +import { useContextUsage } from '@/hooks/useContextUsage'; +import { useTranslation } from '@/hooks/useTranslation'; +import { + HoverCard, + HoverCardTrigger, + HoverCardContent, +} from '@/components/ui/hover-card'; + +interface ContextUsageIndicatorProps { + messages: Message[]; + modelName: string; +} + +function formatTokens(n: number): string { + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; + return String(n); +} + +export function ContextUsageIndicator({ messages, modelName }: ContextUsageIndicatorProps) { + const { t } = useTranslation(); + const usage = useContextUsage(messages, modelName); + + const size = 22; + const strokeWidth = 3; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - usage.ratio * circumference; + + // Color based on usage ratio + let strokeColor = 'text-muted-foreground'; + if (usage.hasData) { + if (usage.ratio > 0.8) strokeColor = 'text-red-500'; + else if (usage.ratio > 0.6) strokeColor = 'text-yellow-500'; + else strokeColor = 'text-primary'; + } + + return ( + + + + + + {!usage.hasData ? ( +

{t('context.noData')}

+ ) : ( +
+
+ {t('context.model')} + {usage.modelName} +
+
+ {t('context.used')} + {formatTokens(usage.used)} +
+
+ {t('context.total')} + + {usage.contextWindow ? formatTokens(usage.contextWindow) : t('context.unknown')} + +
+ {usage.contextWindow && ( +
+ {t('context.percentage')} + {(usage.ratio * 100).toFixed(1)}% +
+ )} +
+
+ {t('context.cacheRead')} + {formatTokens(usage.cacheReadTokens)} +
+
+ {t('context.cacheCreation')} + {formatTokens(usage.cacheCreationTokens)} +
+
+ {t('context.outputTokens')} + {formatTokens(usage.outputTokens)} +
+
+

+ {t('context.estimate')} +

+
+ )} +
+
+ ); +} diff --git a/src/components/chat/ImageGenToggle.tsx b/src/components/chat/ImageGenToggle.tsx index 9b29695..024cda9 100644 --- a/src/components/chat/ImageGenToggle.tsx +++ b/src/components/chat/ImageGenToggle.tsx @@ -31,11 +31,11 @@ export function ImageGenToggle() { : 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50' )} > - {t('imageGen.toggleLabel' as TranslationKey)} + {t('composer.designAgent' as TranslationKey)} - {t('imageGen.toggleTooltip' as TranslationKey)} + {t('composer.designAgentTooltip' as TranslationKey)} ); diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 4a763ab..0ab9cf7 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -35,7 +35,7 @@ import { import type { ChatStatus } from 'ai'; import type { FileAttachment, ProviderModelGroup } from '@/types'; import { nanoid } from 'nanoid'; -import { ImageGenToggle } from './ImageGenToggle'; +import { SlashCommandButton } from './SlashCommandButton'; import { useImageGen } from '@/hooks/useImageGen'; import { PENDING_KEY, setRefImages, deleteRefImages } from '@/lib/image-ref-store'; @@ -612,6 +612,23 @@ export function MessageInput({ } }, [fetchFiles, fetchSkills, popoverMode, closePopover]); + // Insert `/` into textarea to trigger slash command popover + const handleInsertSlash = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + const cursorPos = textarea.selectionStart; + const before = inputValue.slice(0, cursorPos); + const after = inputValue.slice(cursorPos); + const newValue = before + '/' + after; + setInputValue(newValue); + handleInputChange(newValue); + setTimeout(() => { + textarea.focus(); + textarea.selectionStart = cursorPos + 1; + textarea.selectionEnd = cursorPos + 1; + }, 0); + }, [inputValue, handleInputChange]); + const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent) => { e.preventDefault(); const content = inputValue.trim(); @@ -1119,27 +1136,6 @@ export function MessageInput({ {/* Attach file button */} - {/* Mode capsule toggle */} -
- {MODE_OPTIONS.map((opt) => { - const isActive = opt.value === mode; - return ( - - ); - })} -
- {/* Model selector */}
- {/* Image Agent toggle */} - + {/* Slash command button */} + , denyMessage?: string) => void; toolUses?: ToolUseInfo[]; + permissionProfile?: 'default' | 'full_access'; } function AskUserQuestionUI({ @@ -303,9 +304,27 @@ export function PermissionPrompt({ permissionResolved, onPermissionResponse, toolUses = [], + permissionProfile, }: PermissionPromptProps) { const { t } = useTranslation(); + // Auto-approve when full_access is active + const autoApprovedRef = useRef(null); + useEffect(() => { + if ( + permissionProfile === 'full_access' && + pendingPermission && + !permissionResolved && + autoApprovedRef.current !== pendingPermission.permissionRequestId + ) { + autoApprovedRef.current = pendingPermission.permissionRequestId; + onPermissionResponse('allow'); + } + }, [permissionProfile, pendingPermission, permissionResolved, onPermissionResponse]); + + // Don't render permission UI when full_access + if (permissionProfile === 'full_access') return null; + // Nothing to show if (!pendingPermission && !permissionResolved) return null; diff --git a/src/components/chat/SlashCommandButton.tsx b/src/components/chat/SlashCommandButton.tsx new file mode 100644 index 0000000..f9a4902 --- /dev/null +++ b/src/components/chat/SlashCommandButton.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { HugeiconsIcon } from "@hugeicons/react"; +import { CommandLineIcon } from "@hugeicons/core-free-icons"; +import { useTranslation } from '@/hooks/useTranslation'; +import type { TranslationKey } from '@/i18n'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { PromptInputButton } from '@/components/ai-elements/prompt-input'; + +interface SlashCommandButtonProps { + onInsertSlash: () => void; +} + +export function SlashCommandButton({ onInsertSlash }: SlashCommandButtonProps) { + const { t } = useTranslation(); + + return ( + + + + + + + + {t('composer.slashCommandTooltip' as TranslationKey)} + + + ); +} diff --git a/src/hooks/useContextUsage.ts b/src/hooks/useContextUsage.ts new file mode 100644 index 0000000..5b941f7 --- /dev/null +++ b/src/hooks/useContextUsage.ts @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; +import type { Message } from '@/types'; +import { getContextWindow } from '@/lib/model-context'; + +export interface ContextUsageData { + modelName: string; + contextWindow: number | null; + used: number; + ratio: number; + cacheReadTokens: number; + cacheCreationTokens: number; + outputTokens: number; + hasData: boolean; +} + +export function useContextUsage(messages: Message[], modelName: string): ContextUsageData { + return useMemo(() => { + const contextWindow = getContextWindow(modelName); + const noData: ContextUsageData = { + modelName, + contextWindow, + used: 0, + ratio: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + hasData: false, + }; + + // Find the last assistant message with token_usage + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== 'assistant' || !msg.token_usage) continue; + + try { + const usage = typeof msg.token_usage === 'string' + ? JSON.parse(msg.token_usage) + : msg.token_usage; + + const inputTokens = usage.input_tokens || 0; + const cacheRead = usage.cache_read_input_tokens || 0; + const cacheCreation = usage.cache_creation_input_tokens || 0; + const outputTokens = usage.output_tokens || 0; + const used = inputTokens + cacheRead + cacheCreation; + + return { + modelName, + contextWindow, + used, + ratio: contextWindow ? used / contextWindow : 0, + cacheReadTokens: cacheRead, + cacheCreationTokens: cacheCreation, + outputTokens, + hasData: true, + }; + } catch { + continue; + } + } + + return noData; + }, [messages, modelName]); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0413ffc..e3f856f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -741,6 +741,31 @@ const en = { 'assistant.confirmNotFoundDesc': 'This path does not exist yet. Create the directory and initialize it as a new assistant workspace?', 'assistant.confirmCreate': 'Create & Initialize', 'assistant.workspaceInvalid': 'Configured workspace path is invalid', + + // ── Composer ────────────────────────────────────────────── + 'composer.slashCommand': 'Commands', + 'composer.slashCommandTooltip': 'Insert slash command', + 'composer.designAgent': 'Design Agent', + 'composer.designAgentTooltip': 'Enable AI design & image generation', + + // ── Permission ──────────────────────────────────────────── + 'permission.default': 'Default', + 'permission.fullAccess': 'Full Access', + 'permission.fullAccessWarning': 'Full Access mode will automatically approve all permission requests without confirmation. This includes file writes, shell commands, and network requests. Only enable this if you fully trust the current task.', + 'permission.sessionPermission': 'Chat Permission', + + // ── Context Usage ───────────────────────────────────────── + 'context.usage': 'Context Usage', + 'context.model': 'Model', + 'context.used': 'Used', + 'context.total': 'Total', + 'context.percentage': 'Usage', + 'context.cacheRead': 'Cache Read', + 'context.cacheCreation': 'Cache Creation', + 'context.outputTokens': 'Output Tokens', + 'context.estimate': 'Estimated from the most recent response', + 'context.noData': 'No context usage data yet', + 'context.unknown': 'Unknown', } as const; export type TranslationKey = keyof typeof en; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4683381..c2d17d0 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -738,6 +738,31 @@ const zh: Record = { 'assistant.confirmNotFoundDesc': '此路径尚不存在,是否创建目录并初始化为新的助理工作区?', 'assistant.confirmCreate': '创建并初始化', 'assistant.workspaceInvalid': '已配置的工作区路径无效', + + // ── Composer ────────────────────────────────────────────── + 'composer.slashCommand': '命令', + 'composer.slashCommandTooltip': '插入斜杠命令', + 'composer.designAgent': '设计 Agent', + 'composer.designAgentTooltip': '启用 AI 设计与图片生成', + + // ── Permission ──────────────────────────────────────────── + 'permission.default': '默认权限', + 'permission.fullAccess': '完全访问', + 'permission.fullAccessWarning': '完全访问模式将自动批准此对话的所有权限请求,包括文件写入、命令执行等潜在危险操作。确定要开启吗?', + 'permission.sessionPermission': '对话权限', + + // ── Context Usage ───────────────────────────────────────── + 'context.usage': '上下文占用', + 'context.model': '模型', + 'context.used': '已用', + 'context.total': '总计', + 'context.percentage': '占用', + 'context.cacheRead': '缓存读取', + 'context.cacheCreation': '缓存创建', + 'context.outputTokens': '输出 Token', + 'context.estimate': '基于最近一轮响应估算', + 'context.noData': '暂无上下文统计', + 'context.unknown': '未知上下文窗口', }; export default zh; diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index e097988..10eb6ab 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -150,6 +150,9 @@ export async function processMessage( default: permissionMode = 'acceptEdits'; break; } + // Bypass permissions entirely when session has full_access profile + const bypassPermissions = session?.permission_profile === 'full_access'; + // Load conversation history for context const { messages: recentMsgs } = getMessages(sessionId, { limit: 50 }); const historyMsgs = recentMsgs.slice(0, -1).map(m => ({ @@ -178,6 +181,7 @@ export async function processMessage( provider: resolvedProvider, conversationHistory: historyMsgs, files, + bypassPermissions, onRuntimeStatusChange: (status: string) => { try { setSessionRuntimeStatus(sessionId, status); } catch { /* best effort */ } }, diff --git a/src/lib/bridge/permission-broker.ts b/src/lib/bridge/permission-broker.ts index e60be72..71a5d9b 100644 --- a/src/lib/bridge/permission-broker.ts +++ b/src/lib/bridge/permission-broker.ts @@ -13,7 +13,7 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { ChannelAddress, OutboundMessage } from './types'; import type { BaseChannelAdapter } from './channel-adapter'; import { deliver } from './delivery-layer'; -import { insertPermissionLink, getPermissionLink, markPermissionLinkResolved } from '../db'; +import { insertPermissionLink, getPermissionLink, markPermissionLinkResolved, getSession, getDb } from '../db'; import { resolvePendingPermission } from '../permission-registry'; import { escapeHtml } from './adapters/telegram-utils'; @@ -35,6 +35,16 @@ export async function forwardPermissionRequest( sessionId?: string, suggestions?: unknown[], ): Promise { + // Check if this session uses full_access permission profile — auto-approve without IM notification + if (sessionId) { + const session = getSession(sessionId); + if (session?.permission_profile === 'full_access') { + console.log(`[bridge] Auto-approved permission ${permissionRequestId} (tool=${toolName}) due to full_access profile`); + resolvePendingPermission(permissionRequestId, { behavior: 'allow' }); + return; + } + } + // Dedup: prevent duplicate forwarding of the same permission request const now = Date.now(); if (recentPermissionForwards.has(permissionRequestId)) { @@ -193,3 +203,34 @@ export function handlePermissionCallback( return resolved; } + +/** + * Auto-approve all pending permission requests for a session. + * Called when a session switches from 'default' to 'full_access' profile. + * Resolves in-memory pending permissions and marks DB links as resolved. + */ +export function autoApprovePendingForSession(sessionId: string): number { + // The permission_requests DB table tracks pending permissions by session_id. + // Find all pending ones and resolve them via the in-memory registry. + const db = getDb(); + + const pendingRows = db.prepare( + "SELECT id FROM permission_requests WHERE session_id = ? AND status = 'pending'" + ).all(sessionId) as { id: string }[]; + + let resolved = 0; + for (const row of pendingRows) { + const ok = resolvePendingPermission(row.id, { behavior: 'allow' }); + if (ok) { + resolved++; + console.log(`[bridge] Auto-approved pending permission ${row.id} for session ${sessionId} (profile switched to full_access)`); + } + // Also mark the IM link as resolved so the button becomes inoperative + try { markPermissionLinkResolved(row.id); } catch { /* best effort */ } + } + + if (resolved > 0) { + console.log(`[bridge] Auto-approved ${resolved} pending permission(s) for session ${sessionId}`); + } + return resolved; +} diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index afde9aa..c81ca69 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -31,7 +31,7 @@ import path from 'path'; * Removes null bytes and control characters that cause spawn EINVAL. */ function sanitizeEnvValue(value: string): string { - // eslint-disable-next-line no-control-regex + return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } @@ -280,6 +280,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream({ @@ -363,8 +364,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream = { + 'sonnet': 200000, + 'opus': 200000, + 'haiku': 200000, + 'claude-sonnet-4-20250514': 200000, + 'claude-opus-4-20250514': 200000, + 'claude-haiku-4-5-20251001': 200000, +}; + +export function getContextWindow(model: string): number | null { + return MODEL_CONTEXT_WINDOWS[model] + ?? MODEL_CONTEXT_WINDOWS[Object.keys(MODEL_CONTEXT_WINDOWS).find(k => model.includes(k)) ?? ''] + ?? null; +} diff --git a/src/types/index.ts b/src/types/index.ts index 51e371b..54f1f4e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,6 +21,7 @@ export interface ChatSession { runtime_status: string; runtime_updated_at: string; runtime_error: string; + permission_profile?: 'default' | 'full_access'; } // ========================================== @@ -122,7 +123,7 @@ export interface ProviderModelGroup { provider_id: string; // provider DB id, or 'env' for environment variables provider_name: string; provider_type: string; - models: Array<{ value: string; label: string }>; + models: Array<{ value: string; label: string; contextWindow?: number }>; } export interface CreateProviderRequest { @@ -813,4 +814,6 @@ export interface ClaudeStreamOptions { /** Recent conversation history from DB — used as fallback context when SDK resume is unavailable or fails */ conversationHistory?: Array<{ role: 'user' | 'assistant'; content: string }>; onRuntimeStatusChange?: (status: string) => void; + /** Per-session bypass: when true, skip all permission checks for this session */ + bypassPermissions?: boolean; }