v0.1.4
parent
d99ab859ca
commit
415d0078fb
|
|
@ -340,19 +340,29 @@ def _validate_panel_access_password(supplied: str) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
def _is_panel_protected_api_path(path: str) -> bool:
|
||||||
async def bot_access_password_guard(request: Request, call_next):
|
raw = str(path or "").strip()
|
||||||
if request.method.upper() == "OPTIONS":
|
if not raw.startswith("/api/"):
|
||||||
return await call_next(request)
|
return False
|
||||||
|
if raw in {
|
||||||
if request.url.path.startswith("/api/"):
|
|
||||||
if request.url.path in {
|
|
||||||
"/api/panel/auth/status",
|
"/api/panel/auth/status",
|
||||||
"/api/panel/auth/login",
|
"/api/panel/auth/login",
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/health/cache",
|
"/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)
|
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))
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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: '复制回复失败。',
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +3044,9 @@ export function BotDashboardModule({
|
||||||
onChange={onPickAttachments}
|
onChange={onPickAttachments}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
<div className="ops-composer-shell">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={composerTextareaRef}
|
||||||
className="input ops-composer-input"
|
className="input ops-composer-input"
|
||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
|
@ -3020,28 +3058,46 @@ export function BotDashboardModule({
|
||||||
: t.disabledPlaceholder
|
: t.disabledPlaceholder
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<div className="ops-composer-tools-right">
|
||||||
<LucentIconButton
|
<LucentIconButton
|
||||||
className="btn btn-secondary icon-btn"
|
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}
|
disabled={!canChat || isUploadingAttachments}
|
||||||
onClick={triggerPickAttachments}
|
onClick={triggerPickAttachments}
|
||||||
tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
||||||
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
|
||||||
>
|
>
|
||||||
<Paperclip size={14} className={isUploadingAttachments ? 'animate-spin' : ''} />
|
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
|
||||||
</LucentIconButton>
|
</LucentIconButton>
|
||||||
<button
|
<button
|
||||||
className={`btn ${isChatEnabled && (isThinking || isSending) ? 'ops-send-toggle-interrupt' : 'btn-primary'}`}
|
className={`ops-composer-submit-btn ${isChatEnabled && (isThinking || isSending) ? 'is-interrupt' : ''}`}
|
||||||
disabled={
|
disabled={
|
||||||
isChatEnabled && (isThinking || isSending)
|
isChatEnabled && (isThinking || isSending)
|
||||||
? Boolean(interruptingByBot[selectedBot.id])
|
? Boolean(interruptingByBot[selectedBot.id])
|
||||||
: (!isChatEnabled || (!command.trim() && pendingAttachments.length === 0 && !quotedReply))
|
: (!isChatEnabled || (!command.trim() && pendingAttachments.length === 0 && !quotedReply))
|
||||||
}
|
}
|
||||||
onClick={() => void (isChatEnabled && (isThinking || isSending) ? interruptExecution() : send())}
|
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) ? t.interrupt : (isSending ? t.sending : t.send)}
|
{isChatEnabled && (isThinking || isSending) ? (
|
||||||
|
<Square size={15} />
|
||||||
|
) : (
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{isUploadingAttachments ? (
|
{isUploadingAttachments ? (
|
||||||
<div className="ops-upload-progress" aria-live="polite">
|
<div className="ops-upload-progress" aria-live="polite">
|
||||||
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
|
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue