v0.1.4
parent
d99ab859ca
commit
415d0078fb
|
|
@ -340,19 +340,29 @@ def _validate_panel_access_password(supplied: str) -> Optional[str]:
|
|||
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")
|
||||
async def bot_access_password_guard(request: Request, call_next):
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
if request.url.path.startswith("/api/"):
|
||||
if request.url.path in {
|
||||
"/api/panel/auth/status",
|
||||
"/api/panel/auth/login",
|
||||
"/api/health",
|
||||
"/api/health/cache",
|
||||
}:
|
||||
return await call_next(request)
|
||||
if _is_panel_protected_api_path(request.url.path):
|
||||
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
|
||||
if 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")
|
||||
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
|
|
@ -2580,17 +2609,17 @@ def download_workspace_file(
|
|||
download: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
return _serve_workspace_file(bot_id=bot_id, path=path, download=download, session=session)
|
||||
|
||||
_, target = _resolve_workspace_path(bot_id, path)
|
||||
if not os.path.isfile(target):
|
||||
raise HTTPException(status_code=404, detail="workspace file not found")
|
||||
media_type, _ = mimetypes.guess_type(target)
|
||||
if download:
|
||||
return FileResponse(target, filename=os.path.basename(target), media_type=media_type)
|
||||
return FileResponse(target, media_type=media_type)
|
||||
|
||||
@app.get("/public/bots/{bot_id}/workspace/download")
|
||||
def public_download_workspace_file(
|
||||
bot_id: str,
|
||||
path: str,
|
||||
download: bool = False,
|
||||
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")
|
||||
|
|
@ -2667,17 +2696,6 @@ async def upload_workspace_files(
|
|||
|
||||
@app.websocket("/ws/monitor/{bot_id}")
|
||||
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:
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function AuthenticatedApp({
|
|||
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
||||
const [showImageFactory, setShowImageFactory] = useState(false);
|
||||
const [showCreateWizard, setShowCreateWizard] = useState(false);
|
||||
useBotsSync();
|
||||
useBotsSync(forcedBotId);
|
||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
||||
const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim());
|
||||
const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function extractToolCallProgressHint(raw: string, isZh: boolean): string | null
|
|||
return `${isZh ? '工具调用' : 'Tool Call'}\n${callLabel}`;
|
||||
}
|
||||
|
||||
export function useBotsSync() {
|
||||
export function useBotsSync(forcedBotId?: string) {
|
||||
const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore();
|
||||
const socketsRef = useRef<Record<string, WebSocket>>({});
|
||||
const heartbeatsRef = useRef<Record<string, number>>({});
|
||||
|
|
@ -86,14 +86,20 @@ export function useBotsSync() {
|
|||
const isZh = useAppStore((s) => s.locale === 'zh');
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
const t = pickLocale(locale, { 'zh-cn': botsSyncZhCn, en: botsSyncEn });
|
||||
const forced = String(forcedBotId || '').trim();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBots = async () => {
|
||||
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`);
|
||||
setBots(res.data);
|
||||
} 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 () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [setBots]);
|
||||
}, [forced, setBots]);
|
||||
|
||||
useEffect(() => {
|
||||
const botIds = Object.keys(activeBots);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,12 @@ export const dashboardEn = {
|
|||
attachmentMessage: '[attachment message]',
|
||||
removeAttachment: 'Remove attachment',
|
||||
copyPrompt: 'Copy prompt',
|
||||
editPrompt: 'Edit prompt',
|
||||
copyPromptDone: 'Prompt copied.',
|
||||
copyPromptFail: 'Failed to copy prompt.',
|
||||
editPromptDone: 'Inserted into composer.',
|
||||
voiceInput: 'Voice input',
|
||||
voiceUnavailable: 'Voice input is not available yet.',
|
||||
copyReply: 'Copy reply',
|
||||
copyReplyDone: 'Reply copied.',
|
||||
copyReplyFail: 'Failed to copy reply.',
|
||||
|
|
|
|||
|
|
@ -23,8 +23,12 @@ export const dashboardZhCn = {
|
|||
attachmentMessage: '[附件消息]',
|
||||
removeAttachment: '移除附件',
|
||||
copyPrompt: '复制指令',
|
||||
editPrompt: '编辑指令',
|
||||
copyPromptDone: '指令已复制。',
|
||||
copyPromptFail: '复制指令失败。',
|
||||
editPromptDone: '已填入输入框。',
|
||||
voiceInput: '语音输入',
|
||||
voiceUnavailable: '语音输入暂未接入。',
|
||||
copyReply: '复制回复',
|
||||
copyReplyDone: '回复已复制。',
|
||||
copyReplyFail: '复制回复失败。',
|
||||
|
|
|
|||
|
|
@ -321,9 +321,14 @@
|
|||
}
|
||||
|
||||
.ops-bot-actions .ops-bot-action-stop {
|
||||
background: color-mix(in oklab, #f5af48 30%, var(--panel-soft) 70%);
|
||||
border-color: color-mix(in oklab, #f5af48 58%, var(--line) 42%);
|
||||
color: #5e3b00;
|
||||
background: #0b1220;
|
||||
border-color: color-mix(in oklab, #0b1220 72%, var(--line) 28%);
|
||||
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 {
|
||||
|
|
@ -600,7 +605,7 @@
|
|||
|
||||
.ops-chat-row.is-user:hover .ops-chat-hover-actions-user,
|
||||
.ops-chat-row.is-user:focus-within .ops-chat-hover-actions-user {
|
||||
width: 24px;
|
||||
width: 54px;
|
||||
margin-right: 6px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
|
|
@ -967,10 +972,13 @@
|
|||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ops-composer-shell {
|
||||
position: relative;
|
||||
min-height: 108px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ops-chat-top-context {
|
||||
|
|
@ -1024,10 +1032,75 @@
|
|||
}
|
||||
|
||||
.ops-composer-input {
|
||||
min-height: 78px;
|
||||
min-height: 96px;
|
||||
resize: none;
|
||||
font-size: 14px;
|
||||
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 {
|
||||
|
|
@ -1037,16 +1110,6 @@
|
|||
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 {
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid color-mix(in oklab, var(--brand) 55%, var(--line) 45%);
|
||||
|
|
@ -2273,7 +2336,7 @@
|
|||
}
|
||||
|
||||
.ops-composer {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.workspace-panel {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
||||
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 remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
|
@ -19,7 +19,6 @@ import { dashboardEn } from '../../i18n/dashboard.en';
|
|||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||
import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess';
|
||||
import { getPanelAccessPassword } from '../../utils/panelAccess';
|
||||
|
||||
interface BotDashboardModuleProps {
|
||||
onOpenCreateWizard?: () => void;
|
||||
|
|
@ -609,6 +608,7 @@ export function BotDashboardModule({
|
|||
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
|
||||
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
|
||||
const filePickerRef = useRef<HTMLInputElement | null>(null);
|
||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
|
||||
const [cronLoading, setCronLoading] = useState(false);
|
||||
const [cronActionJobId, setCronActionJobId] = useState<string>('');
|
||||
|
|
@ -674,13 +674,9 @@ export function BotDashboardModule({
|
|||
setPendingAttachments([]);
|
||||
}, []);
|
||||
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
|
||||
const accessPassword = selectedBotId ? getBotAccessPassword(selectedBotId) : '';
|
||||
const panelPassword = getPanelAccessPassword();
|
||||
const query = [`path=${encodeURIComponent(filePath)}`];
|
||||
if (forceDownload) query.push('download=1');
|
||||
if (accessPassword) query.push(`access_password=${encodeURIComponent(accessPassword)}`);
|
||||
if (panelPassword) query.push(`panel_access_password=${encodeURIComponent(panelPassword)}`);
|
||||
return `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?${query.join('&')}`;
|
||||
return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`;
|
||||
};
|
||||
const closeWorkspacePreview = () => {
|
||||
setWorkspacePreview(null);
|
||||
|
|
@ -911,7 +907,16 @@ export function BotDashboardModule({
|
|||
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 filteredBots = useMemo(() => {
|
||||
if (!normalizedBotListQuery) return bots;
|
||||
|
|
@ -1225,6 +1230,14 @@ export function BotDashboardModule({
|
|||
)}
|
||||
{item.role === '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
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void copyUserPrompt(item.text)}
|
||||
|
|
@ -2170,6 +2183,20 @@ export function BotDashboardModule({
|
|||
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) => {
|
||||
await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail);
|
||||
};
|
||||
|
|
@ -2275,6 +2302,10 @@ export function BotDashboardModule({
|
|||
filePickerRef.current?.click();
|
||||
};
|
||||
|
||||
const onVoiceInput = () => {
|
||||
notify(t.voiceUnavailable, { tone: 'warning' });
|
||||
};
|
||||
|
||||
const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
|
||||
const files = Array.from(event.target.files);
|
||||
|
|
@ -2750,6 +2781,11 @@ export function BotDashboardModule({
|
|||
onChange={(e) => setBotListQuery(e.target.value)}
|
||||
placeholder={t.botSearchPlaceholder}
|
||||
aria-label={t.botSearchPlaceholder}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
name="bot-search"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -3008,38 +3044,58 @@ export function BotDashboardModule({
|
|||
onChange={onPickAttachments}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<textarea
|
||||
className="input ops-composer-input"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyDown={onComposerKeyDown}
|
||||
disabled={!canChat}
|
||||
placeholder={
|
||||
canChat
|
||||
? t.inputPlaceholder
|
||||
: t.disabledPlaceholder
|
||||
}
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary icon-btn"
|
||||
disabled={!canChat || isUploadingAttachments}
|
||||
onClick={triggerPickAttachments}
|
||||
tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
||||
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
||||
>
|
||||
<Paperclip size={14} className={isUploadingAttachments ? 'animate-spin' : ''} />
|
||||
</LucentIconButton>
|
||||
<button
|
||||
className={`btn ${isChatEnabled && (isThinking || isSending) ? 'ops-send-toggle-interrupt' : 'btn-primary'}`}
|
||||
disabled={
|
||||
isChatEnabled && (isThinking || isSending)
|
||||
? Boolean(interruptingByBot[selectedBot.id])
|
||||
: (!isChatEnabled || (!command.trim() && pendingAttachments.length === 0 && !quotedReply))
|
||||
}
|
||||
onClick={() => void (isChatEnabled && (isThinking || isSending) ? interruptExecution() : send())}
|
||||
>
|
||||
{isChatEnabled && (isThinking || isSending) ? t.interrupt : (isSending ? t.sending : t.send)}
|
||||
</button>
|
||||
<div className="ops-composer-shell">
|
||||
<textarea
|
||||
ref={composerTextareaRef}
|
||||
className="input ops-composer-input"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyDown={onComposerKeyDown}
|
||||
disabled={!canChat}
|
||||
placeholder={
|
||||
canChat
|
||||
? t.inputPlaceholder
|
||||
: t.disabledPlaceholder
|
||||
}
|
||||
/>
|
||||
<div className="ops-composer-tools-right">
|
||||
<LucentIconButton
|
||||
className="ops-composer-inline-btn"
|
||||
disabled={!canChat}
|
||||
onClick={onVoiceInput}
|
||||
tooltip={t.voiceInput}
|
||||
aria-label={t.voiceInput}
|
||||
>
|
||||
<Mic size={16} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-composer-inline-btn"
|
||||
disabled={!canChat || isUploadingAttachments}
|
||||
onClick={triggerPickAttachments}
|
||||
tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
||||
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
||||
>
|
||||
<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>
|
||||
{isUploadingAttachments ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue