From 415d0078fb497bea4e278fbe838b34deb1c590d5 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Tue, 10 Mar 2026 15:04:33 +0800 Subject: [PATCH] v0.1.4 --- backend/main.py | 76 ++++++---- frontend/src/App.tsx | 2 +- frontend/src/hooks/useBotsSync.ts | 12 +- frontend/src/i18n/dashboard.en.ts | 4 + frontend/src/i18n/dashboard.zh-cn.ts | 4 + .../modules/dashboard/BotDashboardModule.css | 103 ++++++++++--- .../modules/dashboard/BotDashboardModule.tsx | 136 ++++++++++++------ 7 files changed, 244 insertions(+), 93 deletions(-) diff --git a/backend/main.py b/backend/main.py index bfaa233..dad3d72 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f3f556d..03003a8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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); diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index 721e25a..026d1cd 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -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>({}); const heartbeatsRef = useRef>({}); @@ -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(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}`); + setBots(res.data ? [res.data] : []); + return; + } const res = await axios.get(`${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); diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index 0aecd26..cd3e02c 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -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.', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index db18f39..2566114 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -23,8 +23,12 @@ export const dashboardZhCn = { attachmentMessage: '[附件消息]', removeAttachment: '移除附件', copyPrompt: '复制指令', + editPrompt: '编辑指令', copyPromptDone: '指令已复制。', copyPromptFail: '复制指令失败。', + editPromptDone: '已填入输入框。', + voiceInput: '语音输入', + voiceUnavailable: '语音输入暂未接入。', copyReply: '复制回复', copyReplyDone: '回复已复制。', copyReplyFail: '复制回复失败。', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 38bfb65..89f4b13 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -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 { diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index cb252b3..f86d90f 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -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(null); const filePickerRef = useRef(null); + const composerTextareaRef = useRef(null); const [cronJobs, setCronJobs] = useState([]); const [cronLoading, setCronLoading] = useState(false); const [cronActionJobId, setCronActionJobId] = useState(''); @@ -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' ? (
+ editUserPrompt(item.text)} + tooltip={t.editPrompt} + aria-label={t.editPrompt} + > + + 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) => { 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" />