feat: refactor composer to two-layer layout, add session permission & context indicator
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9695825007
commit
111fa8c92e
23 files changed
+643
-37
No files matched your search
@@ -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 */ }
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
|
||||
const [sessionModel, setSessionModel] = useState<string>('');
|
||||
const [sessionProviderId, setSessionProviderId] = useState<string>('');
|
||||
const [sessionMode, setSessionMode] = useState<string>('');
|
||||
const [sessionPermissionProfile, setSessionPermissionProfile] = useState<'default' | 'full_access'>('default');
|
||||
const [projectName, setProjectName] = useState<string>('');
|
||||
const [sessionWorkingDir, setSessionWorkingDir] = useState<string>('');
|
||||
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) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ChatView key={id} sessionId={id} initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} initialMode={sessionMode} providerId={sessionProviderId} />
|
||||
<ChatView key={id} sessionId={id} initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} initialMode={sessionMode} providerId={sessionProviderId} initialPermissionProfile={sessionPermissionProfile} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
<ChatComposerActionBar
|
||||
left={<ImageGenToggle />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{center}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{right}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
isFullAccess
|
||||
? 'bg-orange-500/15 text-orange-600 dark:text-orange-400 hover:bg-orange-500/25'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={isFullAccess ? SquareUnlock02Icon : LockIcon}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>
|
||||
{isFullAccess ? t('permission.fullAccess') : t('permission.default')}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[140px]">
|
||||
<DropdownMenuItem onClick={() => handleSelect('default')}>
|
||||
<HugeiconsIcon icon={LockIcon} className="h-3.5 w-3.5" />
|
||||
<span>{t('permission.default')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleSelect('full_access')}>
|
||||
<HugeiconsIcon icon={SquareUnlock02Icon} className="h-3.5 w-3.5 text-orange-500" />
|
||||
<span>{t('permission.fullAccess')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={showWarning} onOpenChange={setShowWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('permission.fullAccess')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('permission.fullAccessWarning')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setShowWarning(false);
|
||||
applyChange('full_access');
|
||||
}}
|
||||
>
|
||||
{t('permission.fullAccess')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<Message[]>(initialMessages);
|
||||
const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>(initialPermissionProfile || 'default');
|
||||
|
||||
// Workspace mismatch banner state
|
||||
const [workspaceMismatchPath, setWorkspaceMismatchPath] = useState<string | null>(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<SessionStreamSnapshot | null>(
|
||||
@@ -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 */}
|
||||
<BatchExecutionDashboard />
|
||||
@@ -681,6 +693,22 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
onModeChange={handleModeChange}
|
||||
onAssistantTrigger={checkAssistantTrigger}
|
||||
/>
|
||||
<ChatComposerActionBar
|
||||
left={<ImageGenToggle />}
|
||||
center={
|
||||
<ChatPermissionSelector
|
||||
sessionId={sessionId}
|
||||
permissionProfile={permissionProfile}
|
||||
onPermissionChange={setPermissionProfile}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<ContextUsageIndicator
|
||||
messages={messages}
|
||||
modelName={currentModel}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button className="flex items-center justify-center p-1 rounded hover:bg-muted transition-colors" type="button">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
className="stroke-muted"
|
||||
/>
|
||||
{/* Usage arc */}
|
||||
{usage.hasData && usage.ratio > 0 && (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className={`${strokeColor} transition-all`}
|
||||
style={{ stroke: 'currentColor' }}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="center" className="w-56 p-3 text-xs">
|
||||
{!usage.hasData ? (
|
||||
<p className="text-muted-foreground">{t('context.noData')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.model')}</span>
|
||||
<span className="font-medium">{usage.modelName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.used')}</span>
|
||||
<span className="font-medium">{formatTokens(usage.used)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.total')}</span>
|
||||
<span className="font-medium">
|
||||
{usage.contextWindow ? formatTokens(usage.contextWindow) : t('context.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
{usage.contextWindow && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.percentage')}</span>
|
||||
<span className="font-medium">{(usage.ratio * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border pt-1.5 mt-1.5 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.cacheRead')}</span>
|
||||
<span>{formatTokens(usage.cacheReadTokens)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.cacheCreation')}</span>
|
||||
<span>{formatTokens(usage.cacheCreationTokens)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.outputTokens')}</span>
|
||||
<span>{formatTokens(usage.outputTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground pt-1 border-t border-border">
|
||||
{t('context.estimate')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('imageGen.toggleTooltip' as TranslationKey)}
|
||||
{t('composer.designAgentTooltip' as TranslationKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -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<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const content = inputValue.trim();
|
||||
@@ -1119,27 +1136,6 @@ export function MessageInput({
|
||||
{/* Attach file button */}
|
||||
<AttachFileButton />
|
||||
|
||||
{/* Mode capsule toggle */}
|
||||
<div className="flex items-center rounded-full border border-border/60 overflow-hidden h-7">
|
||||
{MODE_OPTIONS.map((opt) => {
|
||||
const isActive = opt.value === mode;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => onModeChange?.(opt.value)}
|
||||
>
|
||||
{opt.value === 'code' ? t('messageInput.modeCode') : opt.value === 'plan' ? t('messageInput.modePlan') : opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Model selector */}
|
||||
<div className="relative" ref={modelMenuRef}>
|
||||
<PromptInputButton
|
||||
@@ -1191,8 +1187,8 @@ export function MessageInput({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Agent toggle */}
|
||||
<ImageGenToggle />
|
||||
{/* Slash command button */}
|
||||
<SlashCommandButton onInsertSlash={handleInsertSlash} />
|
||||
</PromptInputTools>
|
||||
|
||||
<FileAwareSubmitButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import {
|
||||
MessageResponse,
|
||||
@@ -35,6 +35,7 @@ interface PermissionPromptProps {
|
||||
permissionResolved: 'allow' | 'deny' | null;
|
||||
onPermissionResponse: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>, 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<string | null>(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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PromptInputButton onClick={onInsertSlash}>
|
||||
<HugeiconsIcon icon={CommandLineIcon} className="h-3.5 w-3.5" />
|
||||
</PromptInputButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('composer.slashCommandTooltip' as TranslationKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -738,6 +738,31 @@ const zh: Record<TranslationKey, string> = {
|
||||
'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;
|
||||
@@ -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 */ }
|
||||
},
|
||||
|
||||
@@ -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<void> {
|
||||
// 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;
|
||||
}
|
||||
@@ -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<strin
|
||||
conversationHistory,
|
||||
onRuntimeStatusChange,
|
||||
imageAgentMode,
|
||||
bypassPermissions: sessionBypassPermissions,
|
||||
} = options;
|
||||
|
||||
return new ReadableStream<string>({
|
||||
@@ -363,8 +364,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
}
|
||||
}
|
||||
|
||||
// Check if dangerously_skip_permissions is enabled in app settings
|
||||
const skipPermissions = getSetting('dangerously_skip_permissions') === 'true';
|
||||
// Check if dangerously_skip_permissions is enabled globally or per-session
|
||||
const globalSkip = getSetting('dangerously_skip_permissions') === 'true';
|
||||
const skipPermissions = globalSkip || !!sessionBypassPermissions;
|
||||
|
||||
const queryOptions: Options = {
|
||||
cwd: workingDirectory || os.homedir(),
|
||||
|
||||
@@ -331,6 +331,9 @@ function migrateDb(db: Database.Database): void {
|
||||
if (!colNames.includes('runtime_error')) {
|
||||
db.exec("ALTER TABLE chat_sessions ADD COLUMN runtime_error TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!colNames.includes('permission_profile')) {
|
||||
db.exec("ALTER TABLE chat_sessions ADD COLUMN permission_profile TEXT NOT NULL DEFAULT 'default'");
|
||||
}
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_runtime_status ON chat_sessions(runtime_status)");
|
||||
|
||||
// Migrate is_active provider to default_provider_id setting
|
||||
@@ -764,6 +767,11 @@ export function updateSessionMode(id: string, mode: string): void {
|
||||
db.prepare('UPDATE chat_sessions SET mode = ? WHERE id = ?').run(mode, id);
|
||||
}
|
||||
|
||||
export function updateSessionPermissionProfile(id: string, profile: string): void {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE chat_sessions SET permission_profile = ? WHERE id = ?').run(profile, id);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Message Operations
|
||||
// ==========================================
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
||||
'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;
|
||||
}
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user