Compare commits

...

3 Commits

Author SHA1 Message Date
seb
12db70e1ae u 2026-10-06 01:53:19 +02:00
seb
858dfe1280 u 2026-10-06 00:33:41 +02:00
seb
fdb43ecfd8 u 2026-10-06 00:19:28 +02:00
4 changed files with 370 additions and 76 deletions

View File

@@ -630,15 +630,31 @@ const actions = {
* Adds brain icon with text as chunks stream in. * Adds brain icon with text as chunks stream in.
*/ */
applyStreamReasoning(data) { 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; if (!text || !text.trim()) return;
const trimmed = text.trim();
if (trimmed.startsWith('{') && (trimmed.includes('"encrypted"') || trimmed.includes('"plain"'))) {
return;
}
chatAutomat.setState((prev) => { chatAutomat.setState((prev) => {
const turns = prev.activeTurns ? [...prev.activeTurns] : []; const turns = prev.activeTurns ? [...prev.activeTurns] : [];
if (turns.length === 0) { if (turns.length === 0) {
turns.push({ turns.push({
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
handshake: 'green', 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: [], tools: [],
usage: null, usage: null,
content: '', content: '',
@@ -648,6 +664,7 @@ const actions = {
} }
const lastIdx = turns.length - 1; const lastIdx = turns.length - 1;
const lastTurn = { ...turns[lastIdx] }; const lastTurn = { ...turns[lastIdx] };
lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name));
const rList = (lastTurn.reasonings || []).map((r) => ({ ...r })); const rList = (lastTurn.reasonings || []).map((r) => ({ ...r }));
let unsealedIdx = -1; let unsealedIdx = -1;
for (let i = rList.length - 1; i >= 0; i--) { for (let i = rList.length - 1; i >= 0; i--) {
@@ -657,9 +674,9 @@ const actions = {
} }
} }
if (unsealedIdx >= 0) { if (unsealedIdx >= 0) {
rList[unsealedIdx].text = text; rList[unsealedIdx].text = trimmed;
} else { } 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; lastTurn.reasonings = rList;
turns[lastIdx] = lastTurn; turns[lastIdx] = lastTurn;
@@ -759,8 +776,9 @@ const actions = {
const hasAskUser = data.name === 'ask_user'; const hasAskUser = data.name === 'ask_user';
return { return {
activeTurns: turns, activeTurns: turns,
streamPhase: 'working', streamPhase: hasAskUser ? 'idle' : 'working',
conversationState: hasAskUser ? 'idle' : prev.conversationState, conversationState: hasAskUser ? 'idle' : prev.conversationState,
isSending: hasAskUser ? false : prev.isSending,
}; };
}); });
}, },
@@ -789,6 +807,7 @@ const actions = {
lastTurn.reasonings = (lastTurn.reasonings || []) lastTurn.reasonings = (lastTurn.reasonings || [])
.filter((r) => r.text && r.text.trim()) .filter((r) => r.text && r.text.trim())
.map((r) => ({ ...r, sealed: true })); .map((r) => ({ ...r, sealed: true }));
lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name));
turns[lastIdx] = lastTurn; turns[lastIdx] = lastTurn;
return { activeTurns: turns, streamPhase: 'working' }; return { activeTurns: turns, streamPhase: 'working' };
}); });
@@ -814,6 +833,7 @@ const actions = {
} }
const lastIdx = turns.length - 1; const lastIdx = turns.length - 1;
const lastTurn = { ...turns[lastIdx] }; const lastTurn = { ...turns[lastIdx] };
lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name));
const currentText = typeof data.text === 'string' const currentText = typeof data.text === 'string'
? data.text ? data.text
: ((lastTurn.content || '') + (data.delta || '')); : ((lastTurn.content || '') + (data.delta || ''));
@@ -873,7 +893,9 @@ const actions = {
reasonings: (t.reasonings || []) reasonings: (t.reasonings || [])
.filter((r) => r.text && r.text.trim()) .filter((r) => r.text && r.text.trim())
.map((r) => ({ ...r, sealed: true })), .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 // Gather full cumulative content from all turns
@@ -1038,6 +1060,7 @@ const actions = {
const payload = { const payload = {
role: 'tool', role: 'tool',
tool_call_id, tool_call_id,
name: 'ask_user',
content: answer, content: answer,
answer, answer,
}; };
@@ -1061,8 +1084,51 @@ const actions = {
sealed: false, sealed: false,
}; };
chatAutomat.setState((prev) => ({ chatAutomat.setState((prev) => {
messages: [...prev.messages, optimisticMsg], 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], activeTurns: [initialTurn],
conversationState: 'busy', conversationState: 'busy',
streamPhase: 'thinking', streamPhase: 'thinking',
@@ -1070,7 +1136,8 @@ const actions = {
lastUsage: null, lastUsage: null,
isSending: true, isSending: true,
sendError: null, sendError: null,
})); };
});
try { try {
const res = await modelconversationApi.addMessage(selectedConversationId, payload); const res = await modelconversationApi.addMessage(selectedConversationId, payload);

View File

@@ -508,7 +508,7 @@ export default class ChatDrawer extends PureComponent {
placeholder={ placeholder={
pendingAsk pendingAsk
? 'Bitte Frage oben beantworten…' ? 'Bitte Frage oben beantworten…'
: 'Nachricht eingeben…' : undefined
} }
/> />
@@ -544,10 +544,6 @@ export default class ChatDrawer extends PureComponent {
)} )}
</IconButton> </IconButton>
</Box> </Box>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.68rem', textAlign: 'center' }}>
Enter zum Senden • Shift+Enter für Zeilenumbruch
</Typography>
</Box> </Box>
); );
} }

View File

@@ -6,6 +6,21 @@ import ClickAwayListener from '@mui/material/ClickAwayListener';
import HandshakeRoundedIcon from '@mui/icons-material/HandshakeRounded'; import HandshakeRoundedIcon from '@mui/icons-material/HandshakeRounded';
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded'; import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
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 HealingMarkdown from './HealingMarkdown';
import { import {
CHROMA, CHROMA,
@@ -14,6 +29,24 @@ import {
SURFACE_RAISED, SURFACE_RAISED,
} from '../../colors'; } from '../../colors';
const TOOL_ICON_MAP = {
find_files: SearchRoundedIcon,
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) { function formatMessageTime(isoString) {
if (!isoString) return ''; if (!isoString) return '';
try { try {
@@ -45,9 +78,13 @@ function formatUsageText(usage) {
return `${fmt(input)} Prompt${cachedPart} · ${fmt(output)} Antwort${cost}`; return `${fmt(input)} Prompt${cachedPart} · ${fmt(output)} Antwort${cost}`;
} }
function formatToolTooltip(content, result, parameters) { function formatToolTooltip(content, result, parameters, prefix) {
const parts = []; const parts = [];
if (prefix) {
parts.push(prefix);
}
if (parameters !== undefined && parameters !== null && parameters !== '') { if (parameters !== undefined && parameters !== null && parameters !== '') {
try { try {
const parsed = typeof parameters === 'string' ? JSON.parse(parameters) : parameters; const parsed = typeof parameters === 'string' ? JSON.parse(parameters) : parameters;
@@ -158,14 +195,82 @@ function HandshakeIconView({ state }) {
); );
} }
function BrainIconView({ reasoning }) { function extractReasoningText(msg, payload) {
const tooltipContent = reasoning?.text?.trim() || ''; if (payload && typeof payload === 'object') {
if (!tooltipContent) return null; 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 ( return (
<ClickTooltip
title={
<Box <Box
ref={this.boxRef}
sx={{ sx={{
p: 0.5, p: 0.5,
maxWidth: 440, maxWidth: 440,
@@ -176,9 +281,22 @@ function BrainIconView({ reasoning }) {
lineHeight: 1.45, lineHeight: 1.45,
}} }}
> >
{tooltipContent} {text}
</Box> </Box>
);
} }
}
function BrainIconView({ reasoning }) {
const tooltipContent = reasoning?.text?.trim() || '';
if (!tooltipContent) return null;
if (tooltipContent.startsWith('{') && (tooltipContent.includes('"encrypted"') || tooltipContent.includes('"plain"'))) {
return null;
}
return (
<ClickTooltip
title={<ReasoningTooltipContent text={tooltipContent} />}
> >
<Box <Box
sx={{ sx={{
@@ -201,8 +319,11 @@ function BrainIconView({ reasoning }) {
} }
function WorkIconView({ tool }) { function WorkIconView({ tool }) {
if (!tool || !tool.name) return null;
const isGreen = tool?.state === 'green'; const isGreen = tool?.state === 'green';
const tooltipText = formatToolTooltip(tool?.content, tool?.result, tool?.parameters); const ToolNameIcon = tool?.name ? TOOL_ICON_MAP[tool.name] : null;
const prefix = ToolNameIcon ? tool?.name : null;
const tooltipText = formatToolTooltip(tool?.content, tool?.result, tool?.parameters || tool?.arguments, prefix);
return ( return (
<ClickTooltip <ClickTooltip
@@ -237,6 +358,10 @@ function WorkIconView({ tool }) {
}, },
}} }}
> >
{ToolNameIcon ? (
<ToolNameIcon sx={{ fontSize: 18, color: 'inherit' }} />
) : (
<>
<BuildRoundedIcon sx={{ fontSize: 18, color: 'inherit' }} /> <BuildRoundedIcon sx={{ fontSize: 18, color: 'inherit' }} />
{tool?.name ? ( {tool?.name ? (
<Typography <Typography
@@ -252,6 +377,8 @@ function WorkIconView({ tool }) {
{tool.name} {tool.name}
</Typography> </Typography>
) : null} ) : null}
</>
)}
</Box> </Box>
</ClickTooltip> </ClickTooltip>
); );
@@ -401,6 +528,19 @@ export function normalizeChatMessages(rawMessages = []) {
}; };
let turnSeq = 0; let turnSeq = 0;
const findExistingTool = (toolCallId) => {
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 getOrCreateTurn = (msg) => {
const shouldStartNew = const shouldStartNew =
!currentTurn || !currentTurn ||
@@ -458,29 +598,39 @@ export function normalizeChatMessages(rawMessages = []) {
} }
if (msg.role === 'reasoning') { if (msg.role === 'reasoning') {
const text = extractReasoningText(msg, payload);
if (text) {
const turn = getOrCreateTurn(msg); 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({ turn.reasonings.push({
id: msg.id || `r-${turn.reasonings.length}`, id: msg.id || `r-${turn.reasonings.length}`,
text, text,
sealed: true, sealed: true,
}); });
}
continue; continue;
} }
if (msg.role === 'tool') { 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); const turn = getOrCreateTurn(msg);
turn.tools.push({ turn.tools.push({
id: payload.tool_call_id || msg.id, id: payload.tool_call_id || msg.id,
state: 'green', state: 'green',
name: payload.name || '', name: isAskUser ? 'ask_user' : (payload.name || ''),
content: payload.content !== undefined ? payload.content : (payload.parameters || ''), content: payload.content !== undefined ? payload.content : (payload.answer || payload.parameters || ''),
parameters: payload.parameters, parameters: payload.parameters,
result: payload.result, result: payload.result,
}); });
@@ -503,15 +653,24 @@ export function normalizeChatMessages(rawMessages = []) {
try { try {
args = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {}); args = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {});
} catch {} } catch {}
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({ turn.tools.push({
id: tc.id || `tc-${Date.now()}`, id: tc.id || `tc-${Date.now()}`,
state: 'green', state: 'green',
name: tc.function?.name || 'tool', name: tc.function?.name || 'tool',
content: JSON.stringify(args, null, 2), parameters: tc.function?.arguments || args,
content: null,
result: null, result: null,
}); });
} }
} }
}
const content = payload.content !== undefined ? payload.content : msg.message; const content = payload.content !== undefined ? payload.content : msg.message;
if (content && typeof content === 'string') { if (content && typeof content === 'string') {

View File

@@ -1,9 +1,11 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import pc from 'picocolors';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const useHttps = process.env.VITE_HTTPS === '1'; 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 }) => ({ export default defineConfig(({ command }) => ({
customLogger: createPidLogger(),
resolve: sharedResolve, resolve: sharedResolve,
build: { build: {
emptyOutDir: false, emptyOutDir: false,
@@ -298,6 +369,7 @@ export default defineConfig(({ command }) => ({
}, },
}, },
plugins: [ plugins: [
pidReporterPlugin(),
react(), react(),
nginxHeadersPreviewPlugin(), nginxHeadersPreviewPlugin(),
...(command === 'build' ...(command === 'build'