main
mula.liu 2026-03-10 15:04:33 +08:00
parent d99ab859ca
commit 415d0078fb
7 changed files with 244 additions and 93 deletions

View File

@ -340,19 +340,29 @@ def _validate_panel_access_password(supplied: str) -> Optional[str]:
return None return None
def _is_panel_protected_api_path(path: str) -> bool:
raw = str(path or "").strip()
if not raw.startswith("/api/"):
return False
if raw in {
"/api/panel/auth/status",
"/api/panel/auth/login",
"/api/health",
"/api/health/cache",
}:
return False
# Bot-scoped APIs are protected by the bot's own access password only.
if _extract_bot_id_from_api_path(raw):
return False
return True
@app.middleware("http") @app.middleware("http")
async def bot_access_password_guard(request: Request, call_next): async def bot_access_password_guard(request: Request, call_next):
if request.method.upper() == "OPTIONS": if request.method.upper() == "OPTIONS":
return await call_next(request) return await call_next(request)
if request.url.path.startswith("/api/"): if _is_panel_protected_api_path(request.url.path):
if request.url.path in {
"/api/panel/auth/status",
"/api/panel/auth/login",
"/api/health",
"/api/health/cache",
}:
return await call_next(request)
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request)) panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
if panel_error: if panel_error:
return JSONResponse(status_code=401, content={"detail": panel_error}) return JSONResponse(status_code=401, content={"detail": panel_error})
@ -2517,6 +2527,25 @@ def read_workspace_file(
} }
def _serve_workspace_file(bot_id: str, path: str, download: bool, session: Session):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
_root, target = _resolve_workspace_path(bot_id, path)
if not os.path.isfile(target):
raise HTTPException(status_code=404, detail="File not found")
media_type, _ = mimetypes.guess_type(target)
if download:
return FileResponse(
target,
media_type=media_type or "application/octet-stream",
filename=os.path.basename(target),
)
return FileResponse(target, media_type=media_type or "application/octet-stream")
@app.get("/api/bots/{bot_id}/cron/jobs") @app.get("/api/bots/{bot_id}/cron/jobs")
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)): def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
@ -2580,17 +2609,17 @@ def download_workspace_file(
download: bool = False, download: bool = False,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
bot = session.get(BotInstance, bot_id) return _serve_workspace_file(bot_id=bot_id, path=path, download=download, session=session)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
_, target = _resolve_workspace_path(bot_id, path)
if not os.path.isfile(target): @app.get("/public/bots/{bot_id}/workspace/download")
raise HTTPException(status_code=404, detail="workspace file not found") def public_download_workspace_file(
media_type, _ = mimetypes.guess_type(target) bot_id: str,
if download: path: str,
return FileResponse(target, filename=os.path.basename(target), media_type=media_type) download: bool = False,
return FileResponse(target, media_type=media_type) session: Session = Depends(get_session),
):
return _serve_workspace_file(bot_id=bot_id, path=path, download=download, session=session)
@app.post("/api/bots/{bot_id}/workspace/upload") @app.post("/api/bots/{bot_id}/workspace/upload")
@ -2667,17 +2696,6 @@ async def upload_workspace_files(
@app.websocket("/ws/monitor/{bot_id}") @app.websocket("/ws/monitor/{bot_id}")
async def websocket_endpoint(websocket: WebSocket, bot_id: str): async def websocket_endpoint(websocket: WebSocket, bot_id: str):
panel_error = _validate_panel_access_password(
str(
websocket.headers.get(PANEL_ACCESS_PASSWORD_HEADER)
or websocket.query_params.get("panel_access_password")
or ""
).strip()
)
if panel_error:
await websocket.close(code=4401, reason=panel_error)
return
with Session(engine) as session: with Session(engine) as session:
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:

View File

@ -25,7 +25,7 @@ function AuthenticatedApp({
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
const [showImageFactory, setShowImageFactory] = useState(false); const [showImageFactory, setShowImageFactory] = useState(false);
const [showCreateWizard, setShowCreateWizard] = useState(false); const [showCreateWizard, setShowCreateWizard] = useState(false);
useBotsSync(); useBotsSync(forcedBotId);
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim()); const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim());
const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView); const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView);

View File

@ -75,7 +75,7 @@ function extractToolCallProgressHint(raw: string, isZh: boolean): string | null
return `${isZh ? '工具调用' : 'Tool Call'}\n${callLabel}`; return `${isZh ? '工具调用' : 'Tool Call'}\n${callLabel}`;
} }
export function useBotsSync() { export function useBotsSync(forcedBotId?: string) {
const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore(); const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore();
const socketsRef = useRef<Record<string, WebSocket>>({}); const socketsRef = useRef<Record<string, WebSocket>>({});
const heartbeatsRef = useRef<Record<string, number>>({}); const heartbeatsRef = useRef<Record<string, number>>({});
@ -86,14 +86,20 @@ export function useBotsSync() {
const isZh = useAppStore((s) => s.locale === 'zh'); const isZh = useAppStore((s) => s.locale === 'zh');
const locale = useAppStore((s) => s.locale); const locale = useAppStore((s) => s.locale);
const t = pickLocale(locale, { 'zh-cn': botsSyncZhCn, en: botsSyncEn }); const t = pickLocale(locale, { 'zh-cn': botsSyncZhCn, en: botsSyncEn });
const forced = String(forcedBotId || '').trim();
useEffect(() => { useEffect(() => {
const fetchBots = async () => { const fetchBots = async () => {
try { try {
if (forced) {
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}`);
setBots(res.data ? [res.data] : []);
return;
}
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`); const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(res.data); setBots(res.data);
} catch (error) { } catch (error) {
console.error('Failed to fetch bots', error); console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error);
} }
}; };
@ -103,7 +109,7 @@ export function useBotsSync() {
return () => { return () => {
window.clearInterval(interval); window.clearInterval(interval);
}; };
}, [setBots]); }, [forced, setBots]);
useEffect(() => { useEffect(() => {
const botIds = Object.keys(activeBots); const botIds = Object.keys(activeBots);

View File

@ -23,8 +23,12 @@ export const dashboardEn = {
attachmentMessage: '[attachment message]', attachmentMessage: '[attachment message]',
removeAttachment: 'Remove attachment', removeAttachment: 'Remove attachment',
copyPrompt: 'Copy prompt', copyPrompt: 'Copy prompt',
editPrompt: 'Edit prompt',
copyPromptDone: 'Prompt copied.', copyPromptDone: 'Prompt copied.',
copyPromptFail: 'Failed to copy prompt.', copyPromptFail: 'Failed to copy prompt.',
editPromptDone: 'Inserted into composer.',
voiceInput: 'Voice input',
voiceUnavailable: 'Voice input is not available yet.',
copyReply: 'Copy reply', copyReply: 'Copy reply',
copyReplyDone: 'Reply copied.', copyReplyDone: 'Reply copied.',
copyReplyFail: 'Failed to copy reply.', copyReplyFail: 'Failed to copy reply.',

View File

@ -23,8 +23,12 @@ export const dashboardZhCn = {
attachmentMessage: '[附件消息]', attachmentMessage: '[附件消息]',
removeAttachment: '移除附件', removeAttachment: '移除附件',
copyPrompt: '复制指令', copyPrompt: '复制指令',
editPrompt: '编辑指令',
copyPromptDone: '指令已复制。', copyPromptDone: '指令已复制。',
copyPromptFail: '复制指令失败。', copyPromptFail: '复制指令失败。',
editPromptDone: '已填入输入框。',
voiceInput: '语音输入',
voiceUnavailable: '语音输入暂未接入。',
copyReply: '复制回复', copyReply: '复制回复',
copyReplyDone: '回复已复制。', copyReplyDone: '回复已复制。',
copyReplyFail: '复制回复失败。', copyReplyFail: '复制回复失败。',

View File

@ -321,9 +321,14 @@
} }
.ops-bot-actions .ops-bot-action-stop { .ops-bot-actions .ops-bot-action-stop {
background: color-mix(in oklab, #f5af48 30%, var(--panel-soft) 70%); background: #0b1220;
border-color: color-mix(in oklab, #f5af48 58%, var(--line) 42%); border-color: color-mix(in oklab, #0b1220 72%, var(--line) 28%);
color: #5e3b00; color: #fff;
}
.ops-bot-actions .ops-bot-action-stop:hover {
background: color-mix(in oklab, #0b1220 84%, #1f2937 16%);
border-color: color-mix(in oklab, #0b1220 82%, white 18%);
} }
.ops-bot-actions .ops-bot-action-delete { .ops-bot-actions .ops-bot-action-delete {
@ -600,7 +605,7 @@
.ops-chat-row.is-user:hover .ops-chat-hover-actions-user, .ops-chat-row.is-user:hover .ops-chat-hover-actions-user,
.ops-chat-row.is-user:focus-within .ops-chat-hover-actions-user { .ops-chat-row.is-user:focus-within .ops-chat-hover-actions-user {
width: 24px; width: 54px;
margin-right: 6px; margin-right: 6px;
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
@ -967,10 +972,13 @@
border-radius: 12px; border-radius: 12px;
background: var(--panel-soft); background: var(--panel-soft);
padding: 10px; padding: 10px;
display: grid; display: block;
grid-template-columns: 1fr auto auto auto; }
gap: 10px;
align-items: flex-end; .ops-composer-shell {
position: relative;
min-height: 108px;
overflow: hidden;
} }
.ops-chat-top-context { .ops-chat-top-context {
@ -1024,10 +1032,75 @@
} }
.ops-composer-input { .ops-composer-input {
min-height: 78px; min-height: 96px;
resize: none; resize: none;
font-size: 14px; font-size: 14px;
line-height: 1.45; line-height: 1.45;
padding: 14px 120px 42px 14px;
}
.ops-composer-tools-right {
position: absolute;
bottom: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-composer-tools-right {
right: 12px;
max-width: calc(100% - 24px);
}
.ops-composer-inline-btn {
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--icon-muted);
display: inline-flex;
align-items: center;
justify-content: center;
}
.ops-composer-inline-btn:hover:not(:disabled) {
background: color-mix(in oklab, var(--panel) 66%, var(--brand-soft) 34%);
color: var(--icon);
}
.ops-composer-submit-btn {
width: 34px;
height: 34px;
border: 0;
border-radius: 999px;
background: var(--text);
color: var(--panel);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 8px 18px rgba(9, 15, 28, 0.22);
}
.ops-composer-submit-btn.is-interrupt {
width: 34px;
min-width: 34px;
padding: 0;
background: color-mix(in oklab, #d14b4b 20%, var(--panel) 80%);
color: color-mix(in oklab, var(--text) 86%, white 14%);
border: 1px solid color-mix(in oklab, #d14b4b 50%, var(--line) 50%);
}
.ops-composer-submit-btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.ops-composer-submit-btn:disabled,
.ops-composer-inline-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
.ops-pending-files { .ops-pending-files {
@ -1037,16 +1110,6 @@
gap: 8px; gap: 8px;
} }
.ops-send-toggle-interrupt {
border-color: color-mix(in oklab, #d14b4b 58%, var(--line) 42%);
background: color-mix(in oklab, #d14b4b 12%, var(--panel-soft) 88%);
color: #9b3030;
}
.ops-send-toggle-interrupt:disabled {
opacity: 0.55;
}
.ops-user-quoted-reply { .ops-user-quoted-reply {
margin-bottom: 8px; margin-bottom: 8px;
border-left: 3px solid color-mix(in oklab, var(--brand) 55%, var(--line) 45%); border-left: 3px solid color-mix(in oklab, var(--brand) 55%, var(--line) 45%);
@ -2273,7 +2336,7 @@
} }
.ops-composer { .ops-composer {
grid-template-columns: 1fr; padding: 8px;
} }
.workspace-panel { .workspace-panel {

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios'; import axios from 'axios';
import { Activity, Boxes, Check, ChevronLeft, ChevronRight, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Reply, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; import { Activity, ArrowUp, Boxes, Check, ChevronLeft, ChevronRight, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Repeat2, Reply, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
@ -19,7 +19,6 @@ import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess'; import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess';
import { getPanelAccessPassword } from '../../utils/panelAccess';
interface BotDashboardModuleProps { interface BotDashboardModuleProps {
onOpenCreateWizard?: () => void; onOpenCreateWizard?: () => void;
@ -609,6 +608,7 @@ export function BotDashboardModule({
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null); const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
const filePickerRef = useRef<HTMLInputElement | null>(null); const filePickerRef = useRef<HTMLInputElement | null>(null);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [cronJobs, setCronJobs] = useState<CronJob[]>([]); const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
const [cronLoading, setCronLoading] = useState(false); const [cronLoading, setCronLoading] = useState(false);
const [cronActionJobId, setCronActionJobId] = useState<string>(''); const [cronActionJobId, setCronActionJobId] = useState<string>('');
@ -674,13 +674,9 @@ export function BotDashboardModule({
setPendingAttachments([]); setPendingAttachments([]);
}, []); }, []);
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => { const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
const accessPassword = selectedBotId ? getBotAccessPassword(selectedBotId) : '';
const panelPassword = getPanelAccessPassword();
const query = [`path=${encodeURIComponent(filePath)}`]; const query = [`path=${encodeURIComponent(filePath)}`];
if (forceDownload) query.push('download=1'); if (forceDownload) query.push('download=1');
if (accessPassword) query.push(`access_password=${encodeURIComponent(accessPassword)}`); return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`;
if (panelPassword) query.push(`panel_access_password=${encodeURIComponent(panelPassword)}`);
return `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?${query.join('&')}`;
}; };
const closeWorkspacePreview = () => { const closeWorkspacePreview = () => {
setWorkspacePreview(null); setWorkspacePreview(null);
@ -911,7 +907,16 @@ export function BotDashboardModule({
storage_gb: '10', storage_gb: '10',
}); });
const bots = useMemo(() => Object.values(activeBots), [activeBots]); const bots = useMemo(
() =>
Object.values(activeBots).sort((a, b) => {
const aCreated = parseBotTimestamp(a.created_at);
const bCreated = parseBotTimestamp(b.created_at);
if (aCreated !== bCreated) return aCreated - bCreated;
return String(a.id || '').localeCompare(String(b.id || ''));
}),
[activeBots],
);
const normalizedBotListQuery = botListQuery.trim().toLowerCase(); const normalizedBotListQuery = botListQuery.trim().toLowerCase();
const filteredBots = useMemo(() => { const filteredBots = useMemo(() => {
if (!normalizedBotListQuery) return bots; if (!normalizedBotListQuery) return bots;
@ -1225,6 +1230,14 @@ export function BotDashboardModule({
)} )}
{item.role === 'user' ? ( {item.role === 'user' ? (
<div className="ops-chat-hover-actions ops-chat-hover-actions-user"> <div className="ops-chat-hover-actions ops-chat-hover-actions-user">
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => editUserPrompt(item.text)}
tooltip={t.editPrompt}
aria-label={t.editPrompt}
>
<Pencil size={13} />
</LucentIconButton>
<LucentIconButton <LucentIconButton
className="ops-chat-inline-action" className="ops-chat-inline-action"
onClick={() => void copyUserPrompt(item.text)} onClick={() => void copyUserPrompt(item.text)}
@ -2170,6 +2183,20 @@ export function BotDashboardModule({
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail); await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
}; };
const editUserPrompt = (text: string) => {
const normalized = normalizeUserMessageText(text);
if (!normalized) return;
setCommand(normalized);
composerTextareaRef.current?.focus();
if (composerTextareaRef.current) {
const caret = normalized.length;
window.requestAnimationFrame(() => {
composerTextareaRef.current?.setSelectionRange(caret, caret);
});
}
notify(t.editPromptDone, { tone: 'success' });
};
const copyAssistantReply = async (text: string) => { const copyAssistantReply = async (text: string) => {
await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail); await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail);
}; };
@ -2275,6 +2302,10 @@ export function BotDashboardModule({
filePickerRef.current?.click(); filePickerRef.current?.click();
}; };
const onVoiceInput = () => {
notify(t.voiceUnavailable, { tone: 'warning' });
};
const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => { const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBot || !event.target.files || event.target.files.length === 0) return; if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
const files = Array.from(event.target.files); const files = Array.from(event.target.files);
@ -2750,6 +2781,11 @@ export function BotDashboardModule({
onChange={(e) => setBotListQuery(e.target.value)} onChange={(e) => setBotListQuery(e.target.value)}
placeholder={t.botSearchPlaceholder} placeholder={t.botSearchPlaceholder}
aria-label={t.botSearchPlaceholder} aria-label={t.botSearchPlaceholder}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
name="bot-search"
/> />
<button <button
type="button" type="button"
@ -3008,38 +3044,58 @@ export function BotDashboardModule({
onChange={onPickAttachments} onChange={onPickAttachments}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<textarea <div className="ops-composer-shell">
className="input ops-composer-input" <textarea
value={command} ref={composerTextareaRef}
onChange={(e) => setCommand(e.target.value)} className="input ops-composer-input"
onKeyDown={onComposerKeyDown} value={command}
disabled={!canChat} onChange={(e) => setCommand(e.target.value)}
placeholder={ onKeyDown={onComposerKeyDown}
canChat disabled={!canChat}
? t.inputPlaceholder placeholder={
: t.disabledPlaceholder canChat
} ? t.inputPlaceholder
/> : t.disabledPlaceholder
<LucentIconButton }
className="btn btn-secondary icon-btn" />
disabled={!canChat || isUploadingAttachments} <div className="ops-composer-tools-right">
onClick={triggerPickAttachments} <LucentIconButton
tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile} className="ops-composer-inline-btn"
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile} disabled={!canChat}
> onClick={onVoiceInput}
<Paperclip size={14} className={isUploadingAttachments ? 'animate-spin' : ''} /> tooltip={t.voiceInput}
</LucentIconButton> aria-label={t.voiceInput}
<button >
className={`btn ${isChatEnabled && (isThinking || isSending) ? 'ops-send-toggle-interrupt' : 'btn-primary'}`} <Mic size={16} />
disabled={ </LucentIconButton>
isChatEnabled && (isThinking || isSending) <LucentIconButton
? Boolean(interruptingByBot[selectedBot.id]) className="ops-composer-inline-btn"
: (!isChatEnabled || (!command.trim() && pendingAttachments.length === 0 && !quotedReply)) disabled={!canChat || isUploadingAttachments}
} onClick={triggerPickAttachments}
onClick={() => void (isChatEnabled && (isThinking || isSending) ? interruptExecution() : send())} tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
> aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
{isChatEnabled && (isThinking || isSending) ? t.interrupt : (isSending ? t.sending : t.send)} >
</button> <Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
</LucentIconButton>
<button
className={`ops-composer-submit-btn ${isChatEnabled && (isThinking || isSending) ? 'is-interrupt' : ''}`}
disabled={
isChatEnabled && (isThinking || isSending)
? Boolean(interruptingByBot[selectedBot.id])
: (!isChatEnabled || (!command.trim() && pendingAttachments.length === 0 && !quotedReply))
}
onClick={() => void (isChatEnabled && (isThinking || isSending) ? interruptExecution() : send())}
aria-label={isChatEnabled && (isThinking || isSending) ? t.interrupt : t.send}
title={isChatEnabled && (isThinking || isSending) ? t.interrupt : t.send}
>
{isChatEnabled && (isThinking || isSending) ? (
<Square size={15} />
) : (
<ArrowUp size={18} />
)}
</button>
</div>
</div>
</div> </div>
</div> </div>
{isUploadingAttachments ? ( {isUploadingAttachments ? (