main
mula.liu 2026-03-02 04:27:58 +08:00
parent a2ac5c4fb5
commit 301a6a4a2d
3 changed files with 116 additions and 47 deletions

View File

@ -29,6 +29,20 @@ function normalizeChannelName(raw: unknown): string {
return channel; return channel;
} }
function isLikelyEchoOfUserInput(progressText: string, userText: string): boolean {
const progress = normalizeAssistantMessageText(progressText).replace(/\s+/g, ' ').trim().toLowerCase();
const user = normalizeUserMessageText(userText).replace(/\s+/g, ' ').trim().toLowerCase();
if (!progress || !user) return false;
if (progress === user) return true;
if (user.length < 8) return false;
const hasProcessingPrefix =
/processing message|message from|received message|收到消息|处理消息|用户输入|command/i.test(progress);
if (progress.includes(user) && (hasProcessingPrefix || progress.length <= user.length + 40)) {
return true;
}
return false;
}
export function useBotsSync() { export function useBotsSync() {
const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore(); const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore();
const socketsRef = useRef<Record<string, WebSocket>>({}); const socketsRef = useRef<Record<string, WebSocket>>({});
@ -157,8 +171,8 @@ export function useBotsSync() {
ts: Date.now(), ts: Date.now(),
channel: sourceChannel || undefined, channel: sourceChannel || undefined,
}); });
if (isDashboardChannel && fullMessage && (normalizedState === 'THINKING' || normalizedState === 'TOOL_CALL')) { if (isDashboardChannel && fullMessage && normalizedState === 'TOOL_CALL') {
const chatText = normalizedState === 'TOOL_CALL' ? `${isZh ? '工具调用' : 'Tool Call'}\n${fullMessage}` : fullMessage; const chatText = `${isZh ? '工具调用' : 'Tool Call'}\n${fullMessage}`;
const now = Date.now(); const now = Date.now();
const prev = lastProgressRef.current[bot.id]; const prev = lastProgressRef.current[bot.id];
if (!prev || prev.text !== chatText || now - prev.ts > 1200) { if (!prev || prev.text !== chatText || now - prev.ts > 1200) {
@ -198,6 +212,10 @@ export function useBotsSync() {
updateBotState(bot.id, state, fullProgress); updateBotState(bot.id, state, fullProgress);
addBotEvent(bot.id, { state, text: fullProgress || t.progress, ts: Date.now(), channel: sourceChannel || undefined }); addBotEvent(bot.id, { state, text: fullProgress || t.progress, ts: Date.now(), channel: sourceChannel || undefined });
if (isDashboardChannel) { if (isDashboardChannel) {
const lastUserText = lastUserEchoRef.current[bot.id]?.text || '';
if (!isTool && isLikelyEchoOfUserInput(fullProgress, lastUserText)) {
return;
}
const chatText = isTool ? `${isZh ? '工具调用' : 'Tool Call'}\n${fullProgress}` : fullProgress; const chatText = isTool ? `${isZh ? '工具调用' : 'Tool Call'}\n${fullProgress}` : fullProgress;
const now = Date.now(); const now = Date.now();
const prev = lastProgressRef.current[bot.id]; const prev = lastProgressRef.current[bot.id];

View File

@ -366,6 +366,29 @@
color: var(--muted); color: var(--muted);
} }
.ops-chat-meta-right {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-chat-expand-icon-btn {
border: 1px solid color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
background: color-mix(in oklab, var(--panel) 76%, var(--brand-soft) 24%);
color: var(--text);
border-radius: 999px;
font-size: 12px;
font-weight: 700;
line-height: 1;
width: 20px;
height: 20px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ops-chat-text { .ops-chat-text {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@ -392,20 +415,6 @@
background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--panel-soft) 88%, var(--panel) 12%)); background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--panel-soft) 88%, var(--panel) 12%));
} }
.ops-chat-more-btn {
position: relative;
z-index: 1;
margin-top: 8px;
border: 1px solid color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
background: color-mix(in oklab, var(--panel) 72%, var(--brand-soft) 28%);
color: var(--text);
border-radius: 999px;
font-size: 12px;
font-weight: 700;
padding: 4px 10px;
cursor: pointer;
}
.ops-chat-text > *:first-child { .ops-chat-text > *:first-child {
margin-top: 0; margin-top: 0;
} }
@ -1074,24 +1083,42 @@
font-weight: 700; font-weight: 700;
} }
.ops-runtime-action { .ops-runtime-action-inline {
min-width: 0; min-width: 0;
display: grid; display: flex;
align-items: flex-start;
gap: 6px; gap: 6px;
} }
.ops-runtime-action-text { .ops-runtime-action-text {
display: block; display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
overflow-wrap: anywhere; overflow-wrap: anywhere;
line-height: 1.54; line-height: 1.54;
max-height: 82px; overflow: hidden;
overflow: auto; flex: 1;
min-width: 0;
} }
.ops-runtime-action-text.expanded { .ops-runtime-expand-btn {
max-height: 180px; border: 1px solid color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
background: color-mix(in oklab, var(--panel) 76%, var(--brand-soft) 24%);
color: var(--text);
border-radius: 999px;
font-size: 14px;
font-weight: 700;
line-height: 1;
width: 22px;
height: 22px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
} }
.ops-preview { .ops-preview {

View File

@ -249,7 +249,8 @@ function decorateWorkspacePathsForMarkdown(text: string) {
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\s]+)\)/g, /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\s]+)\)/g,
'[$1]($2)', '[$1]($2)',
); );
const workspacePathPattern = /\/root\/\.nanobot\/workspace\/[^\s<>"'`)\]},。!?;:、]+/g; const workspacePathPattern =
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp)\b/gi;
return normalizedExistingLinks.replace(workspacePathPattern, (fullPath) => { return normalizedExistingLinks.replace(workspacePathPattern, (fullPath) => {
const normalized = normalizeDashboardAttachmentPath(fullPath); const normalized = normalizeDashboardAttachmentPath(fullPath);
if (!normalized) return fullPath; if (!normalized) return fullPath;
@ -391,7 +392,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 [runtimeActionExpanded, setRuntimeActionExpanded] = useState(false); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null); const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const openWorkspacePathFromChat = (path: string) => { const openWorkspacePathFromChat = (path: string) => {
const normalized = String(path || '').trim(); const normalized = String(path || '').trim();
@ -406,7 +407,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\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\]},。!?;:、]+|https:\/\/workspace\.local\/open\/[^\s)]+/g; /\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp))\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp)\b|https:\/\/workspace\.local\/open\/[^\s)]+/gi;
const nodes: ReactNode[] = []; const nodes: ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
let matchIndex = 0; let matchIndex = 0;
@ -624,7 +625,7 @@ export function BotDashboardModule({
const summary = String(runtimeActionSummary || '').trim(); const summary = String(runtimeActionSummary || '').trim();
return Boolean(full && full !== '-' && summary && full !== summary); return Boolean(full && full !== '-' && summary && full !== summary);
}, [runtimeAction, runtimeActionSummary]); }, [runtimeAction, runtimeActionSummary]);
const runtimeActionDisplay = runtimeActionExpanded || !runtimeActionHasMore ? runtimeAction : runtimeActionSummary; const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction;
const shouldCollapseProgress = (text: string) => { const shouldCollapseProgress = (text: string) => {
const normalized = String(text || '').trim(); const normalized = String(text || '').trim();
@ -656,7 +657,24 @@ export function BotDashboardModule({
<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">
<span>{item.role === 'user' ? t.you : 'Nanobot'}</span> <span>{item.role === 'user' ? t.you : 'Nanobot'}</span>
<span className="mono">{formatClock(item.ts)}</span> <div className="ops-chat-meta-right">
<span className="mono">{formatClock(item.ts)}</span>
{collapsible ? (
<button
className="ops-chat-expand-icon-btn"
onClick={() =>
setExpandedProgressByKey((prev) => ({
...prev,
[itemKey]: !prev[itemKey],
}))
}
title={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
>
{expanded ? '×' : '…'}
</button>
) : null}
</div>
</div> </div>
<div className={`ops-chat-text ${collapsible && !expanded ? 'is-collapsed' : ''}`}> <div className={`ops-chat-text ${collapsible && !expanded ? 'is-collapsed' : ''}`}>
{item.text ? ( {item.text ? (
@ -668,19 +686,6 @@ export function BotDashboardModule({
</ReactMarkdown> </ReactMarkdown>
) )
) : null} ) : null}
{collapsible ? (
<button
className="ops-chat-more-btn"
onClick={() =>
setExpandedProgressByKey((prev) => ({
...prev,
[itemKey]: !prev[itemKey],
}))
}
>
{expanded ? (isZh ? '收起' : 'Less') : (isZh ? '更多' : 'More')}
</button>
) : null}
{(item.attachments || []).length > 0 ? ( {(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments"> <div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => { {(item.attachments || []).map((rawPath) => {
@ -752,7 +757,7 @@ export function BotDashboardModule({
useEffect(() => { useEffect(() => {
setExpandedProgressByKey({}); setExpandedProgressByKey({});
setRuntimeActionExpanded(false); setShowRuntimeActionModal(false);
}, [selectedBotId]); }, [selectedBotId]);
useEffect(() => { useEffect(() => {
@ -1951,14 +1956,16 @@ export function BotDashboardModule({
<div className="ops-runtime-row"><span>{t.current}</span><strong className="mono">{displayState}</strong></div> <div className="ops-runtime-row"><span>{t.current}</span><strong className="mono">{displayState}</strong></div>
<div className="ops-runtime-row"> <div className="ops-runtime-row">
<span>{t.lastAction}</span> <span>{t.lastAction}</span>
<div className="ops-runtime-action"> <div className="ops-runtime-action-inline">
<strong className={`ops-runtime-action-text ${runtimeActionExpanded ? 'expanded' : ''}`}>{runtimeActionDisplay}</strong> <strong className="ops-runtime-action-text">{runtimeActionDisplay}</strong>
{runtimeActionHasMore ? ( {runtimeActionHasMore ? (
<button <button
className="ops-chat-more-btn" className="ops-runtime-expand-btn"
onClick={() => setRuntimeActionExpanded((prev) => !prev)} onClick={() => setShowRuntimeActionModal(true)}
title={isZh ? '查看完整内容' : 'Show full content'}
aria-label={isZh ? '查看完整内容' : 'Show full content'}
> >
{runtimeActionExpanded ? (isZh ? '收起' : 'Less') : (isZh ? '更多' : 'More')}
</button> </button>
) : null} ) : null}
</div> </div>
@ -2468,6 +2475,23 @@ export function BotDashboardModule({
</div> </div>
)} )}
{showRuntimeActionModal && (
<div className="modal-mask" onClick={() => setShowRuntimeActionModal(false)}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row">
<h3>{t.lastAction}</h3>
</div>
<div className="workspace-preview-body">
<pre>{runtimeAction}</pre>
</div>
<div className="row-between">
<span />
<button className="btn btn-primary" onClick={() => setShowRuntimeActionModal(false)}>{t.close}</button>
</div>
</div>
</div>
)}
{workspacePreview && ( {workspacePreview && (
<div className="modal-mask" onClick={() => setWorkspacePreview(null)}> <div className="modal-mask" onClick={() => setWorkspacePreview(null)}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}> <div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>