main
mula.liu 2026-03-04 00:45:51 +08:00
parent 9ddeedf0b6
commit 92db4da304
11 changed files with 398 additions and 52 deletions

View File

@ -68,6 +68,8 @@ def _ensure_botmessage_columns() -> None:
return return
required_columns = { required_columns = {
"media_json": "TEXT", "media_json": "TEXT",
"feedback": "TEXT",
"feedback_at": "DATETIME",
} }
with engine.connect() as conn: with engine.connect() as conn:
existing_rows = conn.execute(text("PRAGMA table_info(botmessage)")).fetchall() existing_rows = conn.execute(text("PRAGMA table_info(botmessage)")).fetchall()

View File

@ -134,6 +134,10 @@ class CommandRequest(BaseModel):
attachments: Optional[List[str]] = None attachments: Optional[List[str]] = None
class MessageFeedbackRequest(BaseModel):
feedback: Optional[str] = None # up | down | null
def _normalize_packet_channel(packet: Dict[str, Any]) -> str: def _normalize_packet_channel(packet: Dict[str, Any]) -> str:
raw = str(packet.get("channel") or packet.get("source") or "").strip().lower() raw = str(packet.get("channel") or packet.get("source") or "").strip().lower()
if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}: 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 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() packet_type = str(packet.get("type", "")).upper()
if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
return return None
source_channel = _normalize_packet_channel(packet) source_channel = _normalize_packet_channel(packet)
if source_channel != "dashboard": if source_channel != "dashboard":
return return None
persisted_message_id: Optional[int] = None
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:
return return None
if packet_type == "AGENT_STATE": if packet_type == "AGENT_STATE":
payload = packet.get("payload") or {} payload = packet.get("payload") or {}
state = str(payload.get("state") or "").strip() 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 or media_list:
if text_msg: if text_msg:
bot.last_action = " ".join(text_msg.split())[:4000] bot.last_action = " ".join(text_msg.split())[:4000]
session.add( message_row = BotMessage(
BotMessage(
bot_id=bot_id, bot_id=bot_id,
role="assistant", role="assistant",
text=text_msg, text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, 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": elif packet_type == "USER_COMMAND":
text_msg = str(packet.get("text") or "").strip() text_msg = str(packet.get("text") or "").strip()
media_list = _normalize_media_list(packet.get("media"), bot_id) media_list = _normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list: if text_msg or media_list:
session.add( message_row = BotMessage(
BotMessage(
bot_id=bot_id, bot_id=bot_id,
role="user", role="user",
text=text_msg, text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, 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": elif packet_type == "BUS_EVENT":
# Dashboard channel emits BUS_EVENT for both progress and final replies. # Dashboard channel emits BUS_EVENT for both progress and final replies.
# Persist only non-progress events to keep durable chat history clean. # 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" bot.current_state = "IDLE"
if text_msg: if text_msg:
bot.last_action = " ".join(text_msg.split())[:4000] bot.last_action = " ".join(text_msg.split())[:4000]
session.add( message_row = BotMessage(
BotMessage(
bot_id=bot_id, bot_id=bot_id,
role="assistant", role="assistant",
text=text_msg, text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None, 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() bot.updated_at = datetime.utcnow()
session.add(bot) session.add(bot)
session.commit() session.commit()
if persisted_message_id:
packet["message_id"] = persisted_message_id
return persisted_message_id
class WSConnectionManager: 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." "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: if display_command or checked_attachments:
_persist_runtime_packet( outbound_user_packet = {
bot_id,
{"type": "USER_COMMAND", "channel": "dashboard", "text": display_command, "media": checked_attachments},
)
loop = getattr(app.state, "main_loop", None)
if loop and loop.is_running():
asyncio.run_coroutine_threadsafe(
manager.broadcast(
bot_id,
{
"type": "USER_COMMAND", "type": "USER_COMMAND",
"channel": "dashboard", "channel": "dashboard",
"text": display_command, "text": display_command,
"media": checked_attachments, "media": checked_attachments,
}, }
), _persist_runtime_packet(bot_id, outbound_user_packet)
loop = getattr(app.state, "main_loop", None)
if loop and loop.is_running() and outbound_user_packet:
asyncio.run_coroutine_threadsafe(
manager.broadcast(bot_id, outbound_user_packet),
loop, loop,
) )
@ -2066,12 +2073,50 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(
"role": row.role, "role": row.role,
"text": row.text, "text": row.text,
"media": _parse_message_media(bot_id, getattr(row, "media_json", None)), "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), "ts": int(row.created_at.timestamp() * 1000),
} }
for row in ordered 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") @app.delete("/api/bots/{bot_id}/messages")
def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)

View File

@ -19,6 +19,8 @@ class BotMessage(SQLModel, table=True):
role: str = Field(index=True) # user | assistant | system role: str = Field(index=True) # user | assistant | system
text: str text: str
media_json: Optional[str] = Field(default=None) # JSON string list of workspace-relative file paths 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) created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
class NanobotImage(SQLModel, table=True): class NanobotImage(SQLModel, table=True):

View File

@ -22,8 +22,18 @@ function App() {
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const urlView = useMemo(() => { const urlView = useMemo(() => {
const params = new URLSearchParams(window.location.search); 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(); (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 compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase();
const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw); const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw);
const compactMode = compactByFlag || forcedBotId.length > 0; const compactMode = compactByFlag || forcedBotId.length > 0;

View File

@ -23,6 +23,19 @@ function normalizeMedia(raw: unknown): string[] {
return raw.map((v) => String(v || '').trim()).filter((v) => v.length > 0); 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 { function normalizeChannelName(raw: unknown): string {
const channel = String(raw || '').trim().toLowerCase(); const channel = String(raw || '').trim().toLowerCase();
if (channel === 'dashboard_channel' || channel === 'dashboard-channel') return 'dashboard'; if (channel === 'dashboard_channel' || channel === 'dashboard-channel') return 'dashboard';
@ -96,10 +109,12 @@ export function useBotsSync() {
const roleRaw = String(row?.role || '').toLowerCase(); const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] = roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; const role: ChatMessage['role'] = roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
return { return {
id: normalizeMessageId(row?.id),
role, role,
text: String(row?.text || ''), text: String(row?.text || ''),
attachments: normalizeMedia(row?.media), attachments: normalizeMedia(row?.media),
ts: Number(row?.ts || Date.now()), ts: Number(row?.ts || Date.now()),
feedback: normalizeFeedback(row?.feedback),
}; };
}) })
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
@ -191,12 +206,13 @@ export function useBotsSync() {
if (!isDashboardChannel) return; if (!isDashboardChannel) return;
const text = normalizeAssistantMessageText(String(data.text || payload.text || payload.content || '')); const text = normalizeAssistantMessageText(String(data.text || payload.text || payload.content || ''));
const attachments = normalizeMedia(data.media || payload.media); const attachments = normalizeMedia(data.media || payload.media);
const messageId = normalizeMessageId(data.message_id || payload.message_id);
if (!text && attachments.length === 0) return; if (!text && attachments.length === 0) return;
const now = Date.now(); const now = Date.now();
const prev = lastAssistantRef.current[bot.id]; const prev = lastAssistantRef.current[bot.id];
if (prev && prev.text === text && now - prev.ts < 5000 && attachments.length === 0) return; if (prev && prev.text === text && now - prev.ts < 5000 && attachments.length === 0) return;
lastAssistantRef.current[bot.id] = { text, ts: now }; 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', ''); updateBotState(bot.id, 'IDLE', '');
addBotEvent(bot.id, { state: 'SUCCESS', text: t.replied, ts: Date.now(), channel: sourceChannel || undefined }); addBotEvent(bot.id, { state: 'SUCCESS', text: t.replied, ts: Date.now(), channel: sourceChannel || undefined });
return; return;
@ -233,10 +249,11 @@ export function useBotsSync() {
} }
if (!isDashboardChannel) return; if (!isDashboardChannel) return;
if (content) { if (content) {
const messageId = normalizeMessageId(data.message_id || payload.message_id);
const now = Date.now(); const now = Date.now();
const prev = lastAssistantRef.current[bot.id]; const prev = lastAssistantRef.current[bot.id];
if (!prev || prev.text !== content || now - prev.ts >= 5000) { 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 }; lastAssistantRef.current[bot.id] = { text: content, ts: now };
} }
updateBotState(bot.id, 'IDLE', summarizeProgressText(content, isZh)); updateBotState(bot.id, 'IDLE', summarizeProgressText(content, isZh));
@ -248,12 +265,13 @@ export function useBotsSync() {
if (!isDashboardChannel) return; if (!isDashboardChannel) return;
const text = normalizeUserMessageText(String(data.text || payload.text || payload.command || '')); const text = normalizeUserMessageText(String(data.text || payload.text || payload.command || ''));
const attachments = normalizeMedia(data.media || payload.media); const attachments = normalizeMedia(data.media || payload.media);
const messageId = normalizeMessageId(data.message_id || payload.message_id);
if (!text && attachments.length === 0) return; if (!text && attachments.length === 0) return;
const now = Date.now(); const now = Date.now();
const prev = lastUserEchoRef.current[bot.id]; const prev = lastUserEchoRef.current[bot.id];
if (prev && prev.text === text && now - prev.ts < 10000 && attachments.length === 0) return; if (prev && prev.text === text && now - prev.ts < 10000 && attachments.length === 0) return;
lastUserEchoRef.current[bot.id] = { text, ts: now }; 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; return;
} }
if (data.type === 'RAW_LOG') { if (data.type === 'RAW_LOG') {

View File

@ -21,6 +21,18 @@ export const dashboardEn = {
uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`, uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`,
attachmentMessage: '[attachment message]', attachmentMessage: '[attachment message]',
removeAttachment: 'Remove attachment', 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}`, sendFailMsg: (msg: string) => `Command delivery failed: ${msg}`,
providerRequired: 'Set provider/model/new API key before testing.', providerRequired: 'Set provider/model/new API key before testing.',
connOk: (preview: string) => (preview ? `Connection passed, models: ${preview}` : 'Connection passed'), connOk: (preview: string) => (preview ? `Connection passed, models: ${preview}` : 'Connection passed'),

View File

@ -21,6 +21,18 @@ export const dashboardZhCn = {
uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB${files}`, uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB${files}`,
attachmentMessage: '[附件消息]', attachmentMessage: '[附件消息]',
removeAttachment: '移除附件', removeAttachment: '移除附件',
copyPrompt: '复制指令',
copyPromptDone: '指令已复制。',
copyPromptFail: '复制指令失败。',
copyReply: '复制回复',
copyReplyDone: '回复已复制。',
copyReplyFail: '复制回复失败。',
goodReply: '好回复',
badReply: '坏回复',
feedbackUpSaved: '已标记为好回复。',
feedbackDownSaved: '已标记为坏回复。',
feedbackSaveFail: '反馈保存失败。',
feedbackMessagePending: '消息尚未同步,暂不可反馈。',
sendFailMsg: (msg: string) => `指令发送失败:${msg}`, sendFailMsg: (msg: string) => `指令发送失败:${msg}`,
providerRequired: '请填写 Provider、模型和新 API Key 后再测试。', providerRequired: '请填写 Provider、模型和新 API Key 后再测试。',
connOk: (preview: string) => (preview ? `连接成功,模型: ${preview}` : '连接成功'), connOk: (preview: string) => (preview ? `连接成功,模型: ${preview}` : '连接成功'),

View File

@ -360,6 +360,32 @@
justify-content: flex-start; 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 { .ops-chat-row.is-user {
justify-content: flex-end; justify-content: flex-end;
} }
@ -428,6 +454,43 @@
justify-content: center; 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 { .ops-chat-text {
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import { 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, 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 ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
@ -341,8 +341,12 @@ function parseWorkspaceLink(href: string): string | null {
function decorateWorkspacePathsForMarkdown(text: string) { function decorateWorkspacePathsForMarkdown(text: string) {
const source = String(text || ''); const source = String(text || '');
const normalizedExistingLinks = source.replace( const normalizedExistingLinks = source.replace(
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\s]+)\)/g, /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)/gi,
'[$1]($2)', (_full, markdownPath: string) => {
const normalized = normalizeDashboardAttachmentPath(markdownPath);
if (!normalized) return String(_full || '');
return `[${markdownPath}](${buildWorkspaceLink(normalized)})`;
},
); );
const workspacePathPattern = 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; /\/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); JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments);
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && Math.abs(msg.ts - last.ts) < 15000) { if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && Math.abs(msg.ts - last.ts) < 15000) {
last.ts = msg.ts; last.ts = msg.ts;
last.id = msg.id || last.id;
if (typeof msg.feedback !== 'undefined') {
last.feedback = msg.feedback;
}
return; return;
} }
} }
@ -470,6 +478,7 @@ export function BotDashboardModule({
locale, locale,
addBotMessage, addBotMessage,
setBotMessages, setBotMessages,
setBotMessageFeedback,
} = useAppStore(); } = useAppStore();
const { notify, confirm } = useLucentPrompt(); const { notify, confirm } = useLucentPrompt();
const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable'; const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable';
@ -534,6 +543,7 @@ export function BotDashboardModule({
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat'); const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
const [isCompactMobile, setIsCompactMobile] = useState(false); const [isCompactMobile, setIsCompactMobile] = useState(false);
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({}); const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null); const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) =>
@ -582,6 +592,25 @@ export function BotDashboardModule({
notify(t.urlCopyFail, { tone: 'error' }); 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 openWorkspacePathFromChat = (path: string) => {
const normalized = String(path || '').trim(); const normalized = String(path || '').trim();
if (!normalized) return; if (!normalized) return;
@ -603,7 +632,7 @@ export function BotDashboardModule({
const source = String(text || ''); const source = String(text || '');
if (!source) return [source]; if (!source) return [source];
const pattern = 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[] = []; const nodes: ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
let matchIndex = 0; let matchIndex = 0;
@ -882,7 +911,7 @@ export function BotDashboardModule({
const conversationNodes = useMemo( const conversationNodes = useMemo(
() => () =>
conversation.map((item, idx) => { 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 isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const fullText = String(item.text || ''); const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText; const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
@ -898,6 +927,18 @@ export function BotDashboardModule({
<img src={nanobotLogo} alt="Nanobot" /> <img src={nanobotLogo} alt="Nanobot" />
</div> </div>
)} )}
{item.role === 'user' ? (
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void copyUserPrompt(item.text)}
tooltip={t.copyPrompt}
aria-label={t.copyPrompt}
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}> <div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
<div className="ops-chat-meta"> <div className="ops-chat-meta">
@ -965,6 +1006,36 @@ export function BotDashboardModule({
})} })}
</div> </div>
) : null} ) : null}
{item.role === 'assistant' && !isProgressBubble ? (
<div className="ops-chat-reply-actions">
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
onClick={() => void submitAssistantFeedback(item, 'up')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={t.goodReply}
aria-label={t.goodReply}
>
<ThumbsUp size={13} />
</LucentIconButton>
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
onClick={() => void submitAssistantFeedback(item, 'down')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={t.badReply}
aria-label={t.badReply}
>
<ThumbsDown size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void copyAssistantReply(item.text)}
tooltip={t.copyReply}
aria-label={t.copyReply}
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
</div> </div>
</div> </div>
@ -976,7 +1047,19 @@ export function BotDashboardModule({
</div> </div>
</div> </div>
)}), )}),
[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(() => { 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<ChatMessage[]> => {
const res = await axios.get<any[]>(`${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<HTMLTextAreaElement>) => { const onComposerKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
const native = e.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number }; const native = e.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number };
if (native.isComposing || native.keyCode === 229) return; if (native.isComposing || native.keyCode === 229) return;
@ -1900,10 +2062,12 @@ export function BotDashboardModule({
exported_at: new Date().toISOString(), exported_at: new Date().toISOString(),
message_count: conversation.length, message_count: conversation.length,
messages: conversation.map((m) => ({ messages: conversation.map((m) => ({
id: m.id || null,
role: m.role, role: m.role,
text: m.text, text: m.text,
attachments: m.attachments || [], attachments: m.attachments || [],
kind: m.kind || 'final', kind: m.kind || 'final',
feedback: m.feedback || null,
ts: m.ts, ts: m.ts,
datetime: new Date(m.ts).toISOString(), datetime: new Date(m.ts).toISOString(),
})), })),
@ -2543,9 +2707,6 @@ export function BotDashboardModule({
<div className="card stack"> <div className="card stack">
<div className="section-mini-title">{isZh ? 'Docker 实际限制' : 'Docker Runtime Limits'}</div> <div className="section-mini-title">{isZh ? 'Docker 实际限制' : 'Docker Runtime Limits'}</div>
<div className="ops-runtime-row"><span>{isZh ? 'CPU限制生效' : 'CPU limit active'}</span><strong>{Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : (resourceSnapshot.enforcement.cpu_limited ? 'YES' : 'NO')}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存限制生效' : 'Memory limit active'}</span><strong>{Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : (resourceSnapshot.enforcement.memory_limited ? 'YES' : 'NO')}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '存储限制生效' : 'Storage limit active'}</span><strong>{Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : (resourceSnapshot.enforcement.storage_limited ? 'YES' : 'NO')}</strong></div>
<div className="ops-runtime-row"><span>CPU</span><strong>{resourceSnapshot.runtime.limits.cpu_cores ? resourceSnapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div> <div className="ops-runtime-row"><span>CPU</span><strong>{resourceSnapshot.runtime.limits.cpu_cores ? resourceSnapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{resourceSnapshot.runtime.limits.memory_bytes ? formatBytes(resourceSnapshot.runtime.limits.memory_bytes) : (Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div> <div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{resourceSnapshot.runtime.limits.memory_bytes ? formatBytes(resourceSnapshot.runtime.limits.memory_bytes) : (Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '存储' : 'Storage'}</span><strong>{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') : '-'))}</strong></div> <div className="ops-runtime-row"><span>{isZh ? '存储' : 'Storage'}</span><strong>{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') : '-'))}</strong></div>

View File

@ -31,6 +31,7 @@ interface AppStore {
setBotLogs: (botId: string, logs: string[]) => void; setBotLogs: (botId: string, logs: string[]) => void;
addBotMessage: (botId: string, msg: ChatMessage) => void; addBotMessage: (botId: string, msg: ChatMessage) => void;
setBotMessages: (botId: string, msgs: ChatMessage[]) => void; setBotMessages: (botId: string, msgs: ChatMessage[]) => void;
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
addBotEvent: (botId: string, event: BotEvent) => void; addBotEvent: (botId: string, event: BotEvent) => void;
setBotEvents: (botId: string, events: BotEvent[]) => void; setBotEvents: (botId: string, events: BotEvent[]) => void;
} }
@ -122,6 +123,7 @@ export const useAppStore = create<AppStore>((set) => ({
const dedupeWindowMs = msg.role === 'user' ? 15000 : 5000; const dedupeWindowMs = msg.role === 'user' ? 15000 : 5000;
if ( if (
last && last &&
(last.id || 0) === (msg.id || 0) &&
last.role === msg.role && last.role === msg.role &&
last.text === msg.text && last.text === msg.text &&
(last.kind || 'final') === (msg.kind || 'final') && (last.kind || 'final') === (msg.kind || 'final') &&
@ -150,6 +152,23 @@ export const useAppStore = create<AppStore>((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) => addBotEvent: (botId, event) =>
set((state) => { set((state) => {
const prev = state.activeBots[botId]?.events || []; const prev = state.activeBots[botId]?.events || [];

View File

@ -1,9 +1,11 @@
export interface ChatMessage { export interface ChatMessage {
id?: number;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
text: string; text: string;
ts: number; ts: number;
attachments?: string[]; attachments?: string[];
kind?: 'progress' | 'final'; kind?: 'progress' | 'final';
feedback?: 'up' | 'down' | null;
} }
export interface BotEvent { export interface BotEvent {