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
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()

View File

@ -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(
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(
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(
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},
)
loop = getattr(app.state, "main_loop", None)
if loop and loop.is_running():
asyncio.run_coroutine_threadsafe(
manager.broadcast(
bot_id,
{
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() and outbound_user_packet:
asyncio.run_coroutine_threadsafe(
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)

View File

@ -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):

View File

@ -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;

View File

@ -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') {

View File

@ -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'),

View File

@ -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}` : '连接成功'),

View File

@ -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;

View File

@ -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<CompactPanelTab>('chat');
const [isCompactMobile, setIsCompactMobile] = useState(false);
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const runtimeMenuRef = useRef<HTMLDivElement | null>(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({
<img src={nanobotLogo} alt="Nanobot" />
</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-meta">
@ -965,6 +1006,36 @@ export function BotDashboardModule({
})}
</div>
) : 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>
@ -976,7 +1047,19 @@ export function BotDashboardModule({
</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(() => {
@ -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 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({
<div className="card stack">
<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>{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>

View File

@ -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<AppStore>((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<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) =>
set((state) => {
const prev = state.activeBots[botId]?.events || [];

View File

@ -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 {