From 92db4da304a6b5db229fce920be985205b059830 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 4 Mar 2026 00:45:51 +0800 Subject: [PATCH] v 0.1.3 --- backend/core/database.py | 2 + backend/main.py | 123 ++++++++---- backend/models/bot.py | 2 + frontend/src/App.tsx | 12 +- frontend/src/hooks/useBotsSync.ts | 24 ++- frontend/src/i18n/dashboard.en.ts | 12 ++ frontend/src/i18n/dashboard.zh-cn.ts | 12 ++ .../modules/dashboard/BotDashboardModule.css | 63 ++++++ .../modules/dashboard/BotDashboardModule.tsx | 179 +++++++++++++++++- frontend/src/store/appStore.ts | 19 ++ frontend/src/types/bot.ts | 2 + 11 files changed, 398 insertions(+), 52 deletions(-) diff --git a/backend/core/database.py b/backend/core/database.py index 88b9f70..0248867 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -68,6 +68,8 @@ def _ensure_botmessage_columns() -> None: return required_columns = { "media_json": "TEXT", + "feedback": "TEXT", + "feedback_at": "DATETIME", } with engine.connect() as conn: existing_rows = conn.execute(text("PRAGMA table_info(botmessage)")).fetchall() diff --git a/backend/main.py b/backend/main.py index f4a5160..ce3a20e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -134,6 +134,10 @@ class CommandRequest(BaseModel): attachments: Optional[List[str]] = None +class MessageFeedbackRequest(BaseModel): + feedback: Optional[str] = None # up | down | null + + def _normalize_packet_channel(packet: Dict[str, Any]) -> str: raw = str(packet.get("channel") or packet.get("source") or "").strip().lower() if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}: @@ -168,17 +172,18 @@ def _normalize_media_list(raw: Any, bot_id: str) -> List[str]: return rows -def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]): +def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int]: packet_type = str(packet.get("type", "")).upper() if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: - return + return None source_channel = _normalize_packet_channel(packet) if source_channel != "dashboard": - return + return None + persisted_message_id: Optional[int] = None with Session(engine) as session: bot = session.get(BotInstance, bot_id) if not bot: - return + return None if packet_type == "AGENT_STATE": payload = packet.get("payload") or {} state = str(payload.get("state") or "").strip() @@ -194,26 +199,28 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]): if text_msg or media_list: if text_msg: bot.last_action = " ".join(text_msg.split())[:4000] - session.add( - BotMessage( - bot_id=bot_id, - role="assistant", - text=text_msg, - media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, - ) + message_row = BotMessage( + bot_id=bot_id, + role="assistant", + text=text_msg, + media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, ) + session.add(message_row) + session.flush() + persisted_message_id = message_row.id elif packet_type == "USER_COMMAND": text_msg = str(packet.get("text") or "").strip() media_list = _normalize_media_list(packet.get("media"), bot_id) if text_msg or media_list: - session.add( - BotMessage( - bot_id=bot_id, - role="user", - text=text_msg, - media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, - ) + message_row = BotMessage( + bot_id=bot_id, + role="user", + text=text_msg, + media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, ) + session.add(message_row) + session.flush() + persisted_message_id = message_row.id elif packet_type == "BUS_EVENT": # Dashboard channel emits BUS_EVENT for both progress and final replies. # Persist only non-progress events to keep durable chat history clean. @@ -225,18 +232,22 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]): bot.current_state = "IDLE" if text_msg: bot.last_action = " ".join(text_msg.split())[:4000] - session.add( - BotMessage( - bot_id=bot_id, - role="assistant", - text=text_msg, - media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, - ) + message_row = BotMessage( + bot_id=bot_id, + role="assistant", + text=text_msg, + media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, ) + session.add(message_row) + session.flush() + persisted_message_id = message_row.id bot.updated_at = datetime.utcnow() session.add(bot) session.commit() + if persisted_message_id: + packet["message_id"] = persisted_message_id + return persisted_message_id class WSConnectionManager: @@ -1999,24 +2010,20 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend "Reply language must follow USER.md. If not specified, use the same language as the user input." ) + outbound_user_packet: Optional[Dict[str, Any]] = None if display_command or checked_attachments: - _persist_runtime_packet( - bot_id, - {"type": "USER_COMMAND", "channel": "dashboard", "text": display_command, "media": checked_attachments}, - ) + outbound_user_packet = { + "type": "USER_COMMAND", + "channel": "dashboard", + "text": display_command, + "media": checked_attachments, + } + _persist_runtime_packet(bot_id, outbound_user_packet) loop = getattr(app.state, "main_loop", None) - if loop and loop.is_running(): + if loop and loop.is_running() and outbound_user_packet: asyncio.run_coroutine_threadsafe( - manager.broadcast( - bot_id, - { - "type": "USER_COMMAND", - "channel": "dashboard", - "text": display_command, - "media": checked_attachments, - }, - ), + manager.broadcast(bot_id, outbound_user_packet), loop, ) @@ -2066,12 +2073,50 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends( "role": row.role, "text": row.text, "media": _parse_message_media(bot_id, getattr(row, "media_json", None)), + "feedback": str(getattr(row, "feedback", "") or "").strip() or None, "ts": int(row.created_at.timestamp() * 1000), } for row in ordered ] +@app.put("/api/bots/{bot_id}/messages/{message_id}/feedback") +def update_bot_message_feedback( + bot_id: str, + message_id: int, + payload: MessageFeedbackRequest, + session: Session = Depends(get_session), +): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + row = session.get(BotMessage, message_id) + if not row or row.bot_id != bot_id: + raise HTTPException(status_code=404, detail="Message not found") + if row.role != "assistant": + raise HTTPException(status_code=400, detail="Only assistant messages support feedback") + + raw = str(payload.feedback or "").strip().lower() + if raw in {"", "none", "null"}: + row.feedback = None + row.feedback_at = None + elif raw in {"up", "down"}: + row.feedback = raw + row.feedback_at = datetime.utcnow() + else: + raise HTTPException(status_code=400, detail="feedback must be 'up' or 'down'") + + session.add(row) + session.commit() + return { + "status": "updated", + "bot_id": bot_id, + "message_id": row.id, + "feedback": row.feedback, + "feedback_at": row.feedback_at.isoformat() if row.feedback_at else None, + } + + @app.delete("/api/bots/{bot_id}/messages") def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) diff --git a/backend/models/bot.py b/backend/models/bot.py index 0e3b58e..ff27a7d 100644 --- a/backend/models/bot.py +++ b/backend/models/bot.py @@ -19,6 +19,8 @@ class BotMessage(SQLModel, table=True): role: str = Field(index=True) # user | assistant | system text: str media_json: Optional[str] = Field(default=None) # JSON string list of workspace-relative file paths + feedback: Optional[str] = Field(default=None, index=True) # up | down + feedback_at: Optional[datetime] = Field(default=None) created_at: datetime = Field(default_factory=datetime.utcnow, index=True) class NanobotImage(SQLModel, table=True): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6a84b6f..f7437e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,8 +22,18 @@ function App() { const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const urlView = useMemo(() => { const params = new URLSearchParams(window.location.search); - const forcedBotId = + const pathMatch = window.location.pathname.match(/^\/bot\/([^/?#]+)/i); + let forcedBotIdFromPath = ''; + if (pathMatch?.[1]) { + try { + forcedBotIdFromPath = decodeURIComponent(pathMatch[1]).trim(); + } catch { + forcedBotIdFromPath = String(pathMatch[1]).trim(); + } + } + const forcedBotIdFromQuery = (params.get('botId') || params.get('bot_id') || params.get('id') || '').trim(); + const forcedBotId = forcedBotIdFromPath || forcedBotIdFromQuery; const compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase(); const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw); const compactMode = compactByFlag || forcedBotId.length > 0; diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index 98c01c0..c59a00e 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -23,6 +23,19 @@ function normalizeMedia(raw: unknown): string[] { return raw.map((v) => String(v || '').trim()).filter((v) => v.length > 0); } +function normalizeFeedback(raw: unknown): 'up' | 'down' | null { + const v = String(raw || '').trim().toLowerCase(); + if (v === 'up' || v === 'down') return v; + return null; +} + +function normalizeMessageId(raw: unknown): number | undefined { + const n = Number(raw); + if (!Number.isFinite(n)) return undefined; + const i = Math.trunc(n); + return i > 0 ? i : undefined; +} + function normalizeChannelName(raw: unknown): string { const channel = String(raw || '').trim().toLowerCase(); if (channel === 'dashboard_channel' || channel === 'dashboard-channel') return 'dashboard'; @@ -96,10 +109,12 @@ export function useBotsSync() { const roleRaw = String(row?.role || '').toLowerCase(); const role: ChatMessage['role'] = roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; return { + id: normalizeMessageId(row?.id), role, text: String(row?.text || ''), attachments: normalizeMedia(row?.media), ts: Number(row?.ts || Date.now()), + feedback: normalizeFeedback(row?.feedback), }; }) .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) @@ -191,12 +206,13 @@ export function useBotsSync() { if (!isDashboardChannel) return; const text = normalizeAssistantMessageText(String(data.text || payload.text || payload.content || '')); const attachments = normalizeMedia(data.media || payload.media); + const messageId = normalizeMessageId(data.message_id || payload.message_id); if (!text && attachments.length === 0) return; const now = Date.now(); const prev = lastAssistantRef.current[bot.id]; if (prev && prev.text === text && now - prev.ts < 5000 && attachments.length === 0) return; lastAssistantRef.current[bot.id] = { text, ts: now }; - addBotMessage(bot.id, { role: 'assistant', text, attachments, ts: now, kind: 'final' }); + addBotMessage(bot.id, { id: messageId, role: 'assistant', text, attachments, ts: now, kind: 'final', feedback: null }); updateBotState(bot.id, 'IDLE', ''); addBotEvent(bot.id, { state: 'SUCCESS', text: t.replied, ts: Date.now(), channel: sourceChannel || undefined }); return; @@ -233,10 +249,11 @@ export function useBotsSync() { } if (!isDashboardChannel) return; if (content) { + const messageId = normalizeMessageId(data.message_id || payload.message_id); const now = Date.now(); const prev = lastAssistantRef.current[bot.id]; if (!prev || prev.text !== content || now - prev.ts >= 5000) { - addBotMessage(bot.id, { role: 'assistant', text: content, ts: now, kind: 'final' }); + addBotMessage(bot.id, { id: messageId, role: 'assistant', text: content, ts: now, kind: 'final', feedback: null }); lastAssistantRef.current[bot.id] = { text: content, ts: now }; } updateBotState(bot.id, 'IDLE', summarizeProgressText(content, isZh)); @@ -248,12 +265,13 @@ export function useBotsSync() { if (!isDashboardChannel) return; const text = normalizeUserMessageText(String(data.text || payload.text || payload.command || '')); const attachments = normalizeMedia(data.media || payload.media); + const messageId = normalizeMessageId(data.message_id || payload.message_id); if (!text && attachments.length === 0) return; const now = Date.now(); const prev = lastUserEchoRef.current[bot.id]; if (prev && prev.text === text && now - prev.ts < 10000 && attachments.length === 0) return; lastUserEchoRef.current[bot.id] = { text, ts: now }; - addBotMessage(bot.id, { role: 'user', text, attachments, ts: now, kind: 'final' }); + addBotMessage(bot.id, { id: messageId, role: 'user', text, attachments, ts: now, kind: 'final' }); return; } if (data.type === 'RAW_LOG') { diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index fbc2116..171d6b7 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -21,6 +21,18 @@ export const dashboardEn = { uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`, attachmentMessage: '[attachment message]', removeAttachment: 'Remove attachment', + copyPrompt: 'Copy prompt', + copyPromptDone: 'Prompt copied.', + copyPromptFail: 'Failed to copy prompt.', + copyReply: 'Copy reply', + copyReplyDone: 'Reply copied.', + copyReplyFail: 'Failed to copy reply.', + goodReply: 'Good reply', + badReply: 'Bad reply', + feedbackUpSaved: 'Marked as good reply.', + feedbackDownSaved: 'Marked as bad reply.', + feedbackSaveFail: 'Failed to save feedback.', + feedbackMessagePending: 'Message is not synced yet. Please retry in a moment.', sendFailMsg: (msg: string) => `Command delivery failed: ${msg}`, providerRequired: 'Set provider/model/new API key before testing.', connOk: (preview: string) => (preview ? `Connection passed, models: ${preview}` : 'Connection passed'), diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 0eec9d2..067a87e 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -21,6 +21,18 @@ export const dashboardZhCn = { uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB:${files}`, attachmentMessage: '[附件消息]', removeAttachment: '移除附件', + copyPrompt: '复制指令', + copyPromptDone: '指令已复制。', + copyPromptFail: '复制指令失败。', + copyReply: '复制回复', + copyReplyDone: '回复已复制。', + copyReplyFail: '复制回复失败。', + goodReply: '好回复', + badReply: '坏回复', + feedbackUpSaved: '已标记为好回复。', + feedbackDownSaved: '已标记为坏回复。', + feedbackSaveFail: '反馈保存失败。', + feedbackMessagePending: '消息尚未同步,暂不可反馈。', sendFailMsg: (msg: string) => `指令发送失败:${msg}`, providerRequired: '请填写 Provider、模型和新 API Key 后再测试。', connOk: (preview: string) => (preview ? `连接成功,模型: ${preview}` : '连接成功'), diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 72360c9..690f66d 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -360,6 +360,32 @@ justify-content: flex-start; } +.ops-chat-hover-actions { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; +} + +.ops-chat-hover-actions-user { + width: 0; + margin-right: 0; + overflow: visible; + opacity: 0; + pointer-events: none; + transform: translateX(6px) scale(0.95); + transition: opacity 0.18s ease, transform 0.18s ease, width 0.18s ease, margin-right 0.18s ease; +} + +.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; + margin-right: 6px; + opacity: 1; + pointer-events: auto; + transform: translateX(0) scale(1); +} + .ops-chat-row.is-user { justify-content: flex-end; } @@ -428,6 +454,43 @@ justify-content: center; } +.ops-chat-inline-action { + width: 24px; + height: 24px; + padding: 0; + border-radius: 999px; + border: 1px solid var(--line); + background: color-mix(in oklab, var(--panel) 80%, var(--panel-soft) 20%); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ops-chat-inline-action:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.ops-chat-reply-actions { + margin-top: 8px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ops-chat-inline-action.active-up { + border-color: color-mix(in oklab, #2ca476 58%, var(--line) 42%); + background: color-mix(in oklab, #2ca476 20%, var(--panel-soft) 80%); + color: #1f7e5e; +} + +.ops-chat-inline-action.active-down { + border-color: color-mix(in oklab, #d14b4b 58%, var(--line) 42%); + background: color-mix(in oklab, #d14b4b 20%, var(--panel-soft) 80%); + color: #9c2f2f; +} + .ops-chat-text { white-space: normal; word-break: break-word; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index efd3acf..629b7b2 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; -import { Activity, Boxes, Check, Clock3, Download, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, Square, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; +import { Activity, Boxes, Check, Clock3, Copy, Download, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, 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'; @@ -341,8 +341,12 @@ function parseWorkspaceLink(href: string): string | null { function decorateWorkspacePathsForMarkdown(text: string) { const source = String(text || ''); const normalizedExistingLinks = source.replace( - /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\s]+)\)/g, - '[$1]($2)', + /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)/gi, + (_full, markdownPath: string) => { + const normalized = normalizeDashboardAttachmentPath(markdownPath); + if (!normalized) return String(_full || ''); + return `[${markdownPath}](${buildWorkspaceLink(normalized)})`; + }, ); const workspacePathPattern = /\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|html|htm|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b/gi; @@ -390,6 +394,10 @@ function mergeConversation(messages: ChatMessage[]) { JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments); if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && Math.abs(msg.ts - last.ts) < 15000) { last.ts = msg.ts; + last.id = msg.id || last.id; + if (typeof msg.feedback !== 'undefined') { + last.feedback = msg.feedback; + } return; } } @@ -470,6 +478,7 @@ export function BotDashboardModule({ locale, addBotMessage, setBotMessages, + setBotMessageFeedback, } = useAppStore(); const { notify, confirm } = useLucentPrompt(); const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable'; @@ -534,6 +543,7 @@ export function BotDashboardModule({ const [compactPanelTab, setCompactPanelTab] = useState('chat'); const [isCompactMobile, setIsCompactMobile] = useState(false); const [expandedProgressByKey, setExpandedProgressByKey] = useState>({}); + const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState>({}); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); const runtimeMenuRef = useRef(null); const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => @@ -582,6 +592,25 @@ export function BotDashboardModule({ notify(t.urlCopyFail, { tone: 'error' }); } }; + const copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => { + const text = String(textRaw || ''); + if (!text.trim()) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } + notify(successMsg, { tone: 'success' }); + } catch { + notify(failMsg, { tone: 'error' }); + } + }; const openWorkspacePathFromChat = (path: string) => { const normalized = String(path || '').trim(); if (!normalized) return; @@ -603,7 +632,7 @@ export function BotDashboardModule({ const source = String(text || ''); if (!source) return [source]; const pattern = - /\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|html|htm|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|html|htm|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b|https:\/\/workspace\.local\/open\/[^\s)]+/gi; + /\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|html|htm|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|html|htm|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi; const nodes: ReactNode[] = []; let lastIndex = 0; let matchIndex = 0; @@ -882,7 +911,7 @@ export function BotDashboardModule({ const conversationNodes = useMemo( () => conversation.map((item, idx) => { - const itemKey = `${item.ts}-${idx}`; + const itemKey = `${item.id || item.ts}-${idx}`; const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress'; const fullText = String(item.text || ''); const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText; @@ -898,6 +927,18 @@ export function BotDashboardModule({ Nanobot )} + {item.role === 'user' ? ( +
+ void copyUserPrompt(item.text)} + tooltip={t.copyPrompt} + aria-label={t.copyPrompt} + > + + +
+ ) : null}
@@ -965,6 +1006,36 @@ export function BotDashboardModule({ })}
) : null} + {item.role === 'assistant' && !isProgressBubble ? ( +
+ void submitAssistantFeedback(item, 'up')} + disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} + tooltip={t.goodReply} + aria-label={t.goodReply} + > + + + void submitAssistantFeedback(item, 'down')} + disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} + tooltip={t.badReply} + aria-label={t.badReply} + > + + + void copyAssistantReply(item.text)} + tooltip={t.copyReply} + aria-label={t.copyReply} + > + + +
+ ) : null}
@@ -976,7 +1047,19 @@ export function BotDashboardModule({ )}), - [conversation, expandedProgressByKey, isZh, selectedBotId, t.user, t.you], + [ + conversation, + expandedProgressByKey, + feedbackSavingByMessageId, + isZh, + selectedBotId, + t.badReply, + t.copyPrompt, + t.copyReply, + t.goodReply, + t.user, + t.you, + ], ); useEffect(() => { @@ -1650,6 +1733,85 @@ export function BotDashboardModule({ } }; + const copyUserPrompt = async (text: string) => { + await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail); + }; + + const copyAssistantReply = async (text: string) => { + await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail); + }; + + const fetchBotMessages = async (botId: string): Promise => { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, { + params: { limit: 300 }, + }); + const rows = Array.isArray(res.data) ? res.data : []; + return rows + .map((row) => { + const roleRaw = String(row?.role || '').toLowerCase(); + const role: ChatMessage['role'] = + roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; + const feedbackRaw = String(row?.feedback || '').trim().toLowerCase(); + const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null; + return { + id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined, + role, + text: String(row?.text || ''), + attachments: normalizeAttachmentPaths(row?.media), + ts: Number(row?.ts || Date.now()), + feedback, + kind: 'final', + } as ChatMessage; + }) + .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) + .slice(-300); + }; + + const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => { + if (!selectedBotId) { + notify(t.feedbackMessagePending, { tone: 'warning' }); + return; + } + let targetMessageId = message.id; + if (!targetMessageId) { + try { + const latest = await fetchBotMessages(selectedBotId); + setBotMessages(selectedBotId, latest); + const normalizedTarget = normalizeAssistantMessageText(message.text); + const matched = latest + .filter((m) => m.role === 'assistant' && m.id) + .map((m) => ({ m, diff: Math.abs((m.ts || 0) - (message.ts || 0)) })) + .filter(({ m, diff }) => normalizeAssistantMessageText(m.text) === normalizedTarget && diff <= 10 * 60 * 1000) + .sort((a, b) => a.diff - b.diff)[0]?.m; + if (matched?.id) { + targetMessageId = matched.id; + } + } catch { + // ignore and fallback to warning below + } + } + if (!targetMessageId) { + notify(t.feedbackMessagePending, { tone: 'warning' }); + return; + } + if (feedbackSavingByMessageId[targetMessageId]) return; + setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true })); + try { + await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback }); + setBotMessageFeedback(selectedBotId, targetMessageId, feedback); + notify(feedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' }); + } catch (error: any) { + const msg = error?.response?.data?.detail || t.feedbackSaveFail; + notify(msg, { tone: 'error' }); + } finally { + setFeedbackSavingByMessageId((prev) => { + const next = { ...prev }; + delete next[targetMessageId]; + return next; + }); + } + }; + const onComposerKeyDown = (e: KeyboardEvent) => { const native = e.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number }; if (native.isComposing || native.keyCode === 229) return; @@ -1900,10 +2062,12 @@ export function BotDashboardModule({ exported_at: new Date().toISOString(), message_count: conversation.length, messages: conversation.map((m) => ({ + id: m.id || null, role: m.role, text: m.text, attachments: m.attachments || [], kind: m.kind || 'final', + feedback: m.feedback || null, ts: m.ts, datetime: new Date(m.ts).toISOString(), })), @@ -2543,9 +2707,6 @@ export function BotDashboardModule({
{isZh ? 'Docker 实际限制' : 'Docker Runtime Limits'}
-
{isZh ? 'CPU限制生效' : 'CPU limit active'}{Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : (resourceSnapshot.enforcement.cpu_limited ? 'YES' : 'NO')}
-
{isZh ? '内存限制生效' : 'Memory limit active'}{Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : (resourceSnapshot.enforcement.memory_limited ? 'YES' : 'NO')}
-
{isZh ? '存储限制生效' : 'Storage limit active'}{Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : (resourceSnapshot.enforcement.storage_limited ? 'YES' : 'NO')}
CPU{resourceSnapshot.runtime.limits.cpu_cores ? resourceSnapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}
{isZh ? '内存' : 'Memory'}{resourceSnapshot.runtime.limits.memory_bytes ? formatBytes(resourceSnapshot.runtime.limits.memory_bytes) : (Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}
{isZh ? '存储' : 'Storage'}{resourceSnapshot.runtime.limits.storage_bytes ? formatBytes(resourceSnapshot.runtime.limits.storage_bytes) : (resourceSnapshot.runtime.limits.storage_opt_raw || (Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-'))}
diff --git a/frontend/src/store/appStore.ts b/frontend/src/store/appStore.ts index 901bd04..c922fba 100644 --- a/frontend/src/store/appStore.ts +++ b/frontend/src/store/appStore.ts @@ -31,6 +31,7 @@ interface AppStore { setBotLogs: (botId: string, logs: string[]) => void; addBotMessage: (botId: string, msg: ChatMessage) => void; setBotMessages: (botId: string, msgs: ChatMessage[]) => void; + setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void; addBotEvent: (botId: string, event: BotEvent) => void; setBotEvents: (botId: string, events: BotEvent[]) => void; } @@ -122,6 +123,7 @@ export const useAppStore = create((set) => ({ const dedupeWindowMs = msg.role === 'user' ? 15000 : 5000; if ( last && + (last.id || 0) === (msg.id || 0) && last.role === msg.role && last.text === msg.text && (last.kind || 'final') === (msg.kind || 'final') && @@ -150,6 +152,23 @@ export const useAppStore = create((set) => ({ }, }, })), + setBotMessageFeedback: (botId, messageId, feedback) => + set((state) => { + const prev = state.activeBots[botId]; + if (!prev) return state; + const rows = (prev.messages || []).map((msg) => + (msg.id || 0) === messageId ? { ...msg, feedback } : msg, + ); + return { + activeBots: { + ...state.activeBots, + [botId]: { + ...prev, + messages: rows, + }, + }, + }; + }), addBotEvent: (botId, event) => set((state) => { const prev = state.activeBots[botId]?.events || []; diff --git a/frontend/src/types/bot.ts b/frontend/src/types/bot.ts index edc72fe..e3f267b 100644 --- a/frontend/src/types/bot.ts +++ b/frontend/src/types/bot.ts @@ -1,9 +1,11 @@ export interface ChatMessage { + id?: number; role: 'user' | 'assistant' | 'system'; text: string; ts: number; attachments?: string[]; kind?: 'progress' | 'final'; + feedback?: 'up' | 'down' | null; } export interface BotEvent {