v 0.1.3
parent
9ddeedf0b6
commit
92db4da304
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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}` : '连接成功'),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 || [];
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue