diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js
index a839393..87ccdd1 100644
--- a/src/automats/chatAutomat.js
+++ b/src/automats/chatAutomat.js
@@ -630,15 +630,31 @@ const actions = {
* Adds brain icon with text as chunks stream in.
*/
applyStreamReasoning(data) {
- const text = typeof data?.text === 'string' ? data.text : (typeof data === 'string' ? data : '');
+ let text = '';
+ if (typeof data === 'string') {
+ text = data;
+ } else if (data && typeof data === 'object') {
+ if (typeof data.plain === 'string') {
+ text = data.plain;
+ } else if (data.encrypted) {
+ text = '';
+ } else if (typeof data.text === 'string') {
+ text = data.text;
+ }
+ }
if (!text || !text.trim()) return;
+ const trimmed = text.trim();
+ if (trimmed.startsWith('{') && (trimmed.includes('"encrypted"') || trimmed.includes('"plain"'))) {
+ return;
+ }
+
chatAutomat.setState((prev) => {
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
if (turns.length === 0) {
turns.push({
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
handshake: 'green',
- reasonings: [{ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text, sealed: false }],
+ reasonings: [{ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text: trimmed, sealed: false }],
tools: [],
usage: null,
content: '',
@@ -648,6 +664,7 @@ const actions = {
}
const lastIdx = turns.length - 1;
const lastTurn = { ...turns[lastIdx] };
+ lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name));
const rList = (lastTurn.reasonings || []).map((r) => ({ ...r }));
let unsealedIdx = -1;
for (let i = rList.length - 1; i >= 0; i--) {
@@ -657,9 +674,9 @@ const actions = {
}
}
if (unsealedIdx >= 0) {
- rList[unsealedIdx].text = text;
+ rList[unsealedIdx].text = trimmed;
} else {
- rList.push({ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text, sealed: false });
+ rList.push({ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text: trimmed, sealed: false });
}
lastTurn.reasonings = rList;
turns[lastIdx] = lastTurn;
@@ -790,6 +807,7 @@ const actions = {
lastTurn.reasonings = (lastTurn.reasonings || [])
.filter((r) => r.text && r.text.trim())
.map((r) => ({ ...r, sealed: true }));
+ lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name));
turns[lastIdx] = lastTurn;
return { activeTurns: turns, streamPhase: 'working' };
});
@@ -815,6 +833,7 @@ const actions = {
}
const lastIdx = turns.length - 1;
const lastTurn = { ...turns[lastIdx] };
+ lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name));
const currentText = typeof data.text === 'string'
? data.text
: ((lastTurn.content || '') + (data.delta || ''));
@@ -874,7 +893,9 @@ const actions = {
reasonings: (t.reasonings || [])
.filter((r) => r.text && r.text.trim())
.map((r) => ({ ...r, sealed: true })),
- tools: (t.tools || []).map((tool) => (tool.state === 'black' ? { ...tool, state: 'green' } : tool)),
+ tools: (t.tools || [])
+ .filter((tool) => Boolean(tool.name))
+ .map((tool) => (tool.state === 'black' ? { ...tool, state: 'green' } : tool)),
}));
// Gather full cumulative content from all turns
@@ -1039,6 +1060,7 @@ const actions = {
const payload = {
role: 'tool',
tool_call_id,
+ name: 'ask_user',
content: answer,
answer,
};
@@ -1062,16 +1084,60 @@ const actions = {
sealed: false,
};
- chatAutomat.setState((prev) => ({
- messages: [...prev.messages, optimisticMsg],
- activeTurns: [initialTurn],
- conversationState: 'busy',
- streamPhase: 'thinking',
- streamingText: '',
- lastUsage: null,
- isSending: true,
- sendError: null,
- }));
+ chatAutomat.setState((prev) => {
+ const turns = prev.activeTurns ? [...prev.activeTurns] : [];
+ const sealedTurns = turns.map((t) => ({
+ ...t,
+ sealed: true,
+ reasonings: (t.reasonings || [])
+ .filter((r) => r.text && r.text.trim())
+ .map((r) => ({ ...r, sealed: true })),
+ tools: (t.tools || [])
+ .filter((tool) => Boolean(tool.name))
+ .map((tool) => (tool.state === 'black' ? { ...tool, state: 'green' } : tool)),
+ }));
+
+ const hasActiveTurns = sealedTurns.some(
+ (t) => (t.reasonings && t.reasonings.length > 0) || (t.tools && t.tools.length > 0) || t.content || t.usage
+ );
+
+ let foundExisting = false;
+ const nextMessages = prev.messages.map((m) => {
+ if (m.role === 'assistant' && !m.turns) {
+ try {
+ const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
+ if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === 'ask_user')) {
+ foundExisting = true;
+ return { ...m, turns: sealedTurns };
+ }
+ } catch {}
+ }
+ return m;
+ });
+
+ if (!foundExisting && hasActiveTurns) {
+ nextMessages.push({
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
+ conversation_id: selectedConversationId,
+ role: 'assistant',
+ message: '',
+ turns: sealedTurns,
+ created_at: new Date().toISOString(),
+ });
+ }
+ nextMessages.push(optimisticMsg);
+
+ return {
+ messages: nextMessages,
+ activeTurns: [initialTurn],
+ conversationState: 'busy',
+ streamPhase: 'thinking',
+ streamingText: '',
+ lastUsage: null,
+ isSending: true,
+ sendError: null,
+ };
+ });
try {
const res = await modelconversationApi.addMessage(selectedConversationId, payload);
diff --git a/src/components/chat/ChatFlowList.jsx b/src/components/chat/ChatFlowList.jsx
index f71d82c..c80cc67 100644
--- a/src/components/chat/ChatFlowList.jsx
+++ b/src/components/chat/ChatFlowList.jsx
@@ -10,6 +10,17 @@ import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import ComputerRoundedIcon from '@mui/icons-material/ComputerRounded';
import ImageSearchRoundedIcon from '@mui/icons-material/ImageSearchRounded';
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
+import SupportIcon from '@mui/icons-material/Support';
+import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
+import PhonelinkIcon from '@mui/icons-material/Phonelink';
+import SaveAsIcon from '@mui/icons-material/SaveAs';
+import DescriptionIcon from '@mui/icons-material/Description';
+import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
+import DeleteIcon from '@mui/icons-material/Delete';
+import GoogleIcon from '@mui/icons-material/Google';
+import WebIcon from '@mui/icons-material/Web';
+import EscalatorWarningIcon from '@mui/icons-material/EscalatorWarning';
+import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import HealingMarkdown from './HealingMarkdown';
import {
CHROMA,
@@ -23,6 +34,17 @@ const TOOL_ICON_MAP = {
javascript_execute: ComputerRoundedIcon,
search_images: ImageSearchRoundedIcon,
analyze_image: CompareRoundedIcon,
+ apisuggestionsorissues: SupportIcon,
+ ask_user: RecordVoiceOverIcon,
+ dashback_request: PhonelinkIcon,
+ patch: SaveAsIcon,
+ read_file: DescriptionIcon,
+ reset_vfs: DeleteForeverIcon,
+ rm: DeleteIcon,
+ web_search: GoogleIcon,
+ url_get: WebIcon,
+ sub_agent: EscalatorWarningIcon,
+ pdf_read: PictureAsPdfIcon,
};
function formatMessageTime(isoString) {
@@ -173,27 +195,108 @@ function HandshakeIconView({ state }) {
);
}
+function extractReasoningText(msg, payload) {
+ if (payload && typeof payload === 'object') {
+ if (payload.plain !== undefined) {
+ if (typeof payload.plain === 'string' && payload.plain.trim()) {
+ return payload.plain.trim();
+ }
+ return '';
+ }
+ if (payload.encrypted) {
+ return '';
+ }
+ const candidate = payload.text || payload.content || payload.thinking || payload.reasoning;
+ if (candidate && typeof candidate === 'string') {
+ const trimmed = candidate.trim();
+ if (trimmed.startsWith('{') && (trimmed.includes('"encrypted"') || trimmed.includes('"plain"'))) {
+ try {
+ const inner = JSON.parse(trimmed);
+ if (inner.plain && typeof inner.plain === 'string' && inner.plain.trim()) {
+ return inner.plain.trim();
+ }
+ } catch {}
+ return '';
+ }
+ return trimmed;
+ }
+ }
+
+ if (typeof msg?.message === 'string') {
+ const raw = msg.message.trim();
+ if (raw.startsWith('{') && (raw.includes('"encrypted"') || raw.includes('"plain"'))) {
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed.plain && typeof parsed.plain === 'string' && parsed.plain.trim()) {
+ return parsed.plain.trim();
+ }
+ } catch {}
+ return '';
+ }
+ if (!raw.startsWith('{')) {
+ return raw;
+ }
+ }
+
+ return '';
+}
+
+class ReasoningTooltipContent extends PureComponent {
+ boxRef = createRef();
+
+ scrollToBottom = () => {
+ const el = this.boxRef.current;
+ if (el) {
+ el.scrollTop = el.scrollHeight;
+ requestAnimationFrame(() => {
+ if (this.boxRef.current) {
+ this.boxRef.current.scrollTop = this.boxRef.current.scrollHeight;
+ }
+ });
+ }
+ };
+
+ componentDidMount() {
+ this.scrollToBottom();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.text !== prevProps.text) {
+ this.scrollToBottom();
+ }
+ }
+
+ render() {
+ const { text } = this.props;
+ return (
+
+ {text}
+
+ );
+ }
+}
+
function BrainIconView({ reasoning }) {
const tooltipContent = reasoning?.text?.trim() || '';
if (!tooltipContent) return null;
+ if (tooltipContent.startsWith('{') && (tooltipContent.includes('"encrypted"') || tooltipContent.includes('"plain"'))) {
+ return null;
+ }
return (
- {tooltipContent}
-
- }
+ title={}
>
{
+ if (!toolCallId) return null;
+ if (currentTurn?.tools) {
+ const t = currentTurn.tools.find((tool) => tool.id === toolCallId);
+ if (t) return t;
+ }
+ for (let i = currentAssistantTurns.length - 1; i >= 0; i--) {
+ const t = currentAssistantTurns[i].tools?.find((tool) => tool.id === toolCallId);
+ if (t) return t;
+ }
+ return null;
+ };
+
const getOrCreateTurn = (msg) => {
const shouldStartNew =
!currentTurn ||
@@ -481,29 +598,39 @@ export function normalizeChatMessages(rawMessages = []) {
}
if (msg.role === 'reasoning') {
- const turn = getOrCreateTurn(msg);
- const text =
- payload.plain ||
- payload.text ||
- payload.content ||
- payload.thinking ||
- payload.reasoning ||
- (typeof msg.message === 'string' ? msg.message : '');
- turn.reasonings.push({
- id: msg.id || `r-${turn.reasonings.length}`,
- text,
- sealed: true,
- });
+ const text = extractReasoningText(msg, payload);
+ if (text) {
+ const turn = getOrCreateTurn(msg);
+ turn.reasonings.push({
+ id: msg.id || `r-${turn.reasonings.length}`,
+ text,
+ sealed: true,
+ });
+ }
continue;
}
if (msg.role === 'tool') {
+ const existingTool = payload.tool_call_id ? findExistingTool(payload.tool_call_id) : null;
+ const isAskUser = payload.name === 'ask_user' || existingTool?.name === 'ask_user';
+
+ if (!isAskUser && existingTool) {
+ existingTool.result = payload.result !== undefined ? payload.result : (payload.content !== undefined ? payload.content : '');
+ if (payload.parameters) {
+ existingTool.parameters = payload.parameters;
+ }
+ if (payload.name && !existingTool.name) {
+ existingTool.name = payload.name;
+ }
+ continue;
+ }
+
const turn = getOrCreateTurn(msg);
turn.tools.push({
id: payload.tool_call_id || msg.id,
state: 'green',
- name: payload.name || '',
- content: payload.content !== undefined ? payload.content : (payload.parameters || ''),
+ name: isAskUser ? 'ask_user' : (payload.name || ''),
+ content: payload.content !== undefined ? payload.content : (payload.answer || payload.parameters || ''),
parameters: payload.parameters,
result: payload.result,
});
@@ -526,13 +653,22 @@ export function normalizeChatMessages(rawMessages = []) {
try {
args = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {});
} catch {}
- turn.tools.push({
- id: tc.id || `tc-${Date.now()}`,
- state: 'green',
- name: tc.function?.name || 'tool',
- content: JSON.stringify(args, null, 2),
- result: null,
- });
+
+ const isAskUser = tc.function?.name === 'ask_user';
+ const existing = !isAskUser && tc.id ? findExistingTool(tc.id) : null;
+ if (existing) {
+ existing.parameters = tc.function?.arguments || args;
+ if (tc.function?.name && !existing.name) existing.name = tc.function.name;
+ } else {
+ turn.tools.push({
+ id: tc.id || `tc-${Date.now()}`,
+ state: 'green',
+ name: tc.function?.name || 'tool',
+ parameters: tc.function?.arguments || args,
+ content: null,
+ result: null,
+ });
+ }
}
}
diff --git a/vite.config.js b/vite.config.js
index d27e03f..7522c50 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,9 +1,11 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { defineConfig, searchForWorkspaceRoot } from 'vite';
+import { execSync } from 'node:child_process';
+import { defineConfig, searchForWorkspaceRoot, createLogger } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
+import pc from 'picocolors';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const useHttps = process.env.VITE_HTTPS === '1';
@@ -209,7 +211,76 @@ function nginxHeadersPreviewPlugin() {
};
}
+function getListeningProcess(port) {
+ try {
+ const stdout = execSync(`lsof -nP -ti :${port} -sTCP:LISTEN`, {
+ encoding: 'utf8',
+ stdio: ['pipe', 'pipe', 'ignore'],
+ }).trim();
+ const pid = stdout.split('\n')[0];
+ if (!pid) return null;
+ let cmd = '';
+ try {
+ cmd = execSync(`ps -p ${pid} -o comm=`, {
+ encoding: 'utf8',
+ stdio: ['pipe', 'pipe', 'ignore'],
+ }).trim();
+ } catch {
+ // ignore
+ }
+ return { pid, cmd };
+ } catch {
+ return null;
+ }
+}
+
+function createPidLogger() {
+ const logger = createLogger();
+ const originalInfo = logger.info.bind(logger);
+ logger.info = (msg, options) => {
+ if (typeof msg === 'string') {
+ const match = msg.match(/Port (\d+) is in use/);
+ if (match) {
+ const port = match[1];
+ const owner = getListeningProcess(port);
+ if (owner) {
+ const detail = owner.cmd ? ` (PID ${owner.pid}: ${owner.cmd})` : ` (PID ${owner.pid})`;
+ msg = msg.replace(`Port ${port} is in use`, `Port ${port} is in use${pc.yellow(detail)}`);
+ }
+ }
+ }
+ originalInfo(msg, options);
+ };
+ return logger;
+}
+
+function pidReporterPlugin() {
+ const patchPrintUrls = (server) => {
+ if (!server || server._pidReporterPatched) return;
+ server._pidReporterPatched = true;
+ const origPrintUrls = server.printUrls.bind(server);
+ server.printUrls = () => {
+ origPrintUrls();
+ const pmId = process.env.pm_id ? pc.dim(` (PM2 #${process.env.pm_id})`) : '';
+ server.config.logger.info(
+ ` ${pc.green('➜')} ${pc.bold('PID')}: ${pc.cyan(process.pid)}${pmId}`,
+ );
+ };
+ };
+
+ return {
+ name: 'pid-reporter',
+ configureServer(server) {
+ patchPrintUrls(server);
+ },
+ configurePreviewServer(server) {
+ patchPrintUrls(server);
+ },
+ };
+}
+
export default defineConfig(({ command }) => ({
+ customLogger: createPidLogger(),
resolve: sharedResolve,
build: {
emptyOutDir: false,
@@ -298,6 +369,7 @@ export default defineConfig(({ command }) => ({
},
},
plugins: [
+ pidReporterPlugin(),
react(),
nginxHeadersPreviewPlugin(),
...(command === 'build'