This commit is contained in:
seb
2026-10-07 11:40:55 +02:00
parent a2c18841c1
commit 22115cb583
6 changed files with 446 additions and 47 deletions

View File

@@ -60,7 +60,7 @@ import authAutomat from './authAutomat';
/** Selectable model presets; the first entry is the default for new conversations. */
export const PRESET_MODELS = [
{ value: 'stealth/space-bunny-alpha', label: 'Space Bunny Alpha' },
{ value: 'openai/gpt-6-luna', label: 'GPT-6 Luna (Standard)' },
];
const DEFAULT_MODEL = PRESET_MODELS[0].value;

View File

@@ -4,6 +4,7 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import InputBase from '@mui/material/InputBase';
import TextInput from '../TextInput';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
@@ -18,8 +19,15 @@ import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import Tooltip from '@mui/material/Tooltip';
import Badge from '@mui/material/Badge';
import Divider from '@mui/material/Divider';
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
import { setting as settingApi } from '../../api';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat';
import ChatFlowList from './ChatFlowList';
@@ -62,6 +70,10 @@ export default class ChatDrawer extends PureComponent {
attachmentError: null,
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
isResizing: false,
showSettingsBar: false,
modelslugflag: 'floor',
modelreasoning: 'medium',
modelslug: 'openai/gpt-6-luna',
};
this.inputRef = React.createRef();
this.fileInputRef = React.createRef();
@@ -79,6 +91,7 @@ export default class ChatDrawer extends PureComponent {
if (this.state.isOpen) {
this.focusInput(150);
}
void this.loadModelSettings();
this.unsub = chatAutomat.subscribe(this, (state) => {
const wasOpen = this.state.isOpen;
const willOpen = state.isOpen;
@@ -338,6 +351,71 @@ export default class ChatDrawer extends PureComponent {
}
};
loadModelSettings = async () => {
try {
const data = await settingApi.list();
const settings = data?.settings || [];
const flagSetting = settings.find((s) => s.key === 'modelslugflag');
const reasoningSetting = settings.find((s) => s.key === 'modelreasoning');
const slugSetting = settings.find((s) => s.key === 'modelslug');
this.setState({
modelslugflag: flagSetting?.value ?? 'floor',
modelreasoning: reasoningSetting?.value ?? 'medium',
modelslug: slugSetting?.value ?? 'openai/gpt-6-luna',
});
} catch (err) {
console.error('[ChatDrawer] Failed to load model settings:', err);
}
};
handleToggleSettingsBar = () => {
this.setState((prev) => ({ showSettingsBar: !prev.showSettingsBar }));
};
handleSetFlag = async (nextFlag) => {
if (this.state.modelslugflag === nextFlag) return;
const current = this.state.modelslugflag;
this.setState({ modelslugflag: nextFlag });
try {
await settingApi.upsert('modelslugflag', { value: nextFlag });
} catch (err) {
console.error('[ChatDrawer] Failed to save modelslugflag:', err);
this.setState({ modelslugflag: current });
}
};
handleSetReasoning = async (nextReasoning) => {
if (this.state.modelreasoning === nextReasoning) return;
const current = this.state.modelreasoning;
this.setState({ modelreasoning: nextReasoning });
try {
await settingApi.upsert('modelreasoning', { value: nextReasoning });
} catch (err) {
console.error('[ChatDrawer] Failed to save modelreasoning:', err);
this.setState({ modelreasoning: current });
}
};
handleModelslugChange = (e) => {
this.setState({ modelslug: e.target.value });
};
handleModelslugBlur = async () => {
const { modelslug } = this.state;
const trimmed = (modelslug || '').trim();
try {
await settingApi.upsert('modelslug', { value: trimmed });
} catch (err) {
console.error('[ChatDrawer] Failed to save modelslug:', err);
}
};
handleModelslugKeyDown = (e) => {
if (e.key === 'Enter') {
e.target.blur();
}
};
getPendingAskUser = () => selectPendingAskUser(this.state);
renderHeader() {
@@ -345,9 +423,12 @@ export default class ChatDrawer extends PureComponent {
conversations,
selectedConversationId,
loadingConversations,
showSettingsBar,
modelslugflag,
modelreasoning,
modelslug,
} = this.state;
return (
<Box
sx={{
@@ -360,15 +441,261 @@ export default class ChatDrawer extends PureComponent {
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AutoAwesomeRoundedIcon sx={{ fontSize: 22, color: 'primary.main' }} />
<Typography variant="subtitle1" fontWeight={700}>
KI-Assistent
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: 34 }}>
{showSettingsBar ? (
/* Settings Bar Mode: Title goes away and reveals toggles */
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 0, overflow: 'hidden' }}>
<IconButton
size="small"
onClick={this.handleToggleSettingsBar}
aria-label="Einstellungen schließen"
sx={{
p: 0.5,
color: 'primary.main',
bgcolor: 'action.selected',
flexShrink: 0,
}}
>
<TuneRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen">
{/* Group 1: Speed (floor, walk/standard "", run/nitro) */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.3,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
p: 0.3,
borderRadius: 2,
flexShrink: 0,
}}
>
<IconButton
size="small"
onClick={() => this.handleSetFlag('floor')}
aria-label="Floor"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelslugflag === 'floor' ? 'primary.main' : 'transparent',
color: modelslugflag === 'floor' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelslugflag === 'floor' ? 'primary.dark' : 'action.selected',
},
}}
>
<ElderlyRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
<IconButton
size="small"
onClick={() => this.handleSetFlag('')}
aria-label="Normal"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelslugflag === '' ? 'primary.main' : 'transparent',
color: modelslugflag === '' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelslugflag === '' ? 'primary.dark' : 'action.selected',
},
}}
>
<DirectionsWalkRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
<IconButton
size="small"
onClick={() => this.handleSetFlag('nitro')}
aria-label="Nitro"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelslugflag === 'nitro' ? 'primary.main' : 'transparent',
color: modelslugflag === 'nitro' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelslugflag === 'nitro' ? 'primary.dark' : 'action.selected',
},
}}
>
<DirectionsRunRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
{/* Group 2: Reasoning (none, low, medium, max) */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.3,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
p: 0.3,
borderRadius: 2,
flexShrink: 0,
}}
>
{/* none toggle (crossed out brain) */}
<IconButton
size="small"
onClick={() => this.handleSetReasoning('none')}
aria-label="Reasoning None"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelreasoning === 'none' ? 'primary.main' : 'transparent',
color: modelreasoning === 'none' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelreasoning === 'none' ? 'primary.dark' : 'action.selected',
},
}}
>
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<PsychologyRoundedIcon sx={{ fontSize: 16 }} />
<Box
sx={{
position: 'absolute',
width: '125%',
height: '1.5px',
bgcolor: modelreasoning === 'none' ? 'primary.contrastText' : 'error.main',
transform: 'rotate(-45deg)',
borderRadius: 1,
}}
/>
</Box>
</IconButton>
{/* low toggle (smallest brain) */}
<IconButton
size="small"
onClick={() => this.handleSetReasoning('low')}
aria-label="Reasoning Low"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelreasoning === 'low' ? 'primary.main' : 'transparent',
color: modelreasoning === 'low' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelreasoning === 'low' ? 'primary.dark' : 'action.selected',
},
}}
>
<PsychologyRoundedIcon sx={{ fontSize: 13 }} />
</IconButton>
{/* medium toggle (medium brain) */}
<IconButton
size="small"
onClick={() => this.handleSetReasoning('medium')}
aria-label="Reasoning Medium"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelreasoning === 'medium' ? 'primary.main' : 'transparent',
color: modelreasoning === 'medium' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelreasoning === 'medium' ? 'primary.dark' : 'action.selected',
},
}}
>
<PsychologyRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
{/* max toggle (big brain) */}
<IconButton
size="small"
onClick={() => this.handleSetReasoning('high')}
aria-label="Reasoning Max"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelreasoning === 'high' ? 'primary.main' : 'transparent',
color: modelreasoning === 'high' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelreasoning === 'high' ? 'primary.dark' : 'action.selected',
},
}}
>
<PsychologyRoundedIcon sx={{ fontSize: 22 }} />
</IconButton>
</Box>
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
{/* modelslug input */}
<InputBase
value={modelslug || ''}
onChange={this.handleModelslugChange}
onBlur={this.handleModelslugBlur}
onKeyDown={this.handleModelslugKeyDown}
placeholder="Modellslug"
inputProps={{
spellCheck: false,
autoCorrect: 'off',
autoCapitalize: 'off',
'aria-label': 'Modellslug',
}}
sx={{
flex: 1,
minWidth: 80,
fontSize: '0.78rem',
fontFamily: 'monospace',
px: 0.8,
py: 0.2,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
borderRadius: 1.5,
border: '1px solid transparent',
'&:focus-within': {
borderColor: 'primary.main',
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : '#fff'),
},
'& input': {
p: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}}
/>
</Box>
) : (
/* Normal Mode: settings icon on the left end, title visible */
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
size="small"
onClick={this.handleToggleSettingsBar}
aria-label="Modell-Einstellungen"
sx={{
p: 0.5,
color: 'text.secondary',
'&:hover': { color: 'primary.main', bgcolor: 'action.hover' },
}}
>
<TuneRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
<AutoAwesomeRoundedIcon sx={{ fontSize: 20, color: 'primary.main' }} />
<Typography variant="subtitle1" fontWeight={700}>
KI-Assistent
</Typography>
</Box>
)}
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ ml: 1, flexShrink: 0 }}>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Box>

View File

@@ -3,6 +3,7 @@ import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
@@ -28,7 +29,7 @@ const SETTABLE_KEYS = [
'keys.tavily',
'keys.openrouter',
'keys.serper',
'default-model',
'modelslugimage',
'systemprompt-prefix',
'systemprompt-suffix',
];
@@ -38,11 +39,17 @@ const KEY_LABELS = {
'keys.tavily': 'Tavily API-Key',
'keys.openrouter': 'OpenRouter API-Key',
'keys.serper': 'Serper API-Key',
'default-model': 'Modellslug',
'modelslugimage': 'Modellslug Bild',
'systemprompt-prefix': 'Prä Prompt',
'systemprompt-suffix': 'Post Prompt',
};
export const DEFAULT_SETTINGS = {
modelslugimage: 'meta/muse-image',
};
const ENUM_OPTIONS = {};
const MULTILINE_KEYS = new Set([
'systemprompt-prefix',
'systemprompt-suffix',
@@ -80,12 +87,17 @@ export default class SettingsKeysPanel extends Component {
try {
const data = await settingApi.list();
requireObject(data, 'GET /api/company/setting');
const settings = requireArray(data.settings, 'settings');
const fetched = requireArray(data.settings, 'settings');
const byKey = new Map(fetched.map((s) => [s.key, s]));
const settings = SETTABLE_KEYS.map((key) => {
if (byKey.has(key)) return byKey.get(key);
return { key, value: DEFAULT_SETTINGS[key] ?? null };
});
const drafts = Object.fromEntries(
settings.map((s) => [
s.key,
{
value: s.value ?? '',
value: s.value ?? (DEFAULT_SETTINGS[s.key] ?? ''),
busy: false,
saveError: null,
},
@@ -117,14 +129,8 @@ export default class SettingsKeysPanel extends Component {
const row = this.state.settings.find((s) => s.key === key);
if (!row) return;
const body = {};
if (draft.value !== (row.value ?? '')) {
body.value = draft.value;
}
if (Object.keys(body).length === 0) {
return;
}
const valToSave = draft.value !== '' ? draft.value : (DEFAULT_SETTINGS[key] ?? '');
const body = { value: valToSave };
this.setState((prev) => ({
drafts: {
@@ -146,7 +152,7 @@ export default class SettingsKeysPanel extends Component {
drafts: {
...this.state.drafts,
[key]: {
value: updated.value ?? '',
value: updated.value ?? valToSave,
busy: false,
saveError: null,
},
@@ -198,6 +204,7 @@ export default class SettingsKeysPanel extends Component {
const draft = row ? drafts[row.key] : null;
if (!row || !draft) return null;
const isMultiline = MULTILINE_KEYS.has(row.key);
const enumOpts = ENUM_OPTIONS[row.key];
return (
<TableRow key={row.key} hover>
<TableCell
@@ -211,23 +218,49 @@ export default class SettingsKeysPanel extends Component {
<Typography variant="body2">{keyLabel(row.key)}</Typography>
</TableCell>
<TableCell sx={{ verticalAlign: 'top' }}>
<TextField
size="small"
fullWidth
multiline={isMultiline}
minRows={isMultiline ? 2 : undefined}
maxRows={isMultiline ? 6 : undefined}
value={draft.value}
disabled={draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
onKeyDown={(e) => {
if (!isMultiline && e.key === 'Enter') {
e.preventDefault();
void this.handleSave(row.key);
}
}}
autoComplete="off"
/>
{enumOpts ? (
<TextField
select
size="small"
fullWidth
value={draft.value !== undefined && draft.value !== null ? draft.value : (DEFAULT_SETTINGS[row.key] ?? '')}
disabled={draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
slotProps={{ htmlInput: { spellCheck: false } }}
inputProps={{ spellCheck: false }}
>
{enumOpts.map((opt) => {
const val = typeof opt === 'object' ? opt.value : opt;
const lbl = typeof opt === 'object' ? opt.label : opt;
return (
<MenuItem key={val} value={val}>
{lbl}
</MenuItem>
);
})}
</TextField>
) : (
<TextField
size="small"
fullWidth
multiline={isMultiline}
minRows={isMultiline ? 2 : undefined}
maxRows={isMultiline ? 6 : undefined}
placeholder={DEFAULT_SETTINGS[row.key] ?? undefined}
value={draft.value}
disabled={draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
onKeyDown={(e) => {
if (!isMultiline && e.key === 'Enter') {
e.preventDefault();
void this.handleSave(row.key);
}
}}
autoComplete="off"
slotProps={{ htmlInput: { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' } }}
inputProps={{ spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' }}
/>
)}
</TableCell>
<TableCell align="right" sx={{ verticalAlign: 'top', pt: 1, width: 60 }}>
<Stack

View File

@@ -4,7 +4,8 @@ import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
import { formatPrice } from '../../utils/price';
import { formatPrice, getArticleGross } from '../../utils/price';
import companyAutomat from '../../automats/companyAutomat';
import { articleSku } from '../article/articleRoute';
import {
getThumbHashDataUrl,
@@ -289,6 +290,7 @@ export class ArticleTile extends Tile {
if (!article) return null;
const sku = articleSku(article);
const brand = article.brand;
const grossPrice = getArticleGross(article, companyAutomat.state.current?.taxrate);
return (
<>
{brand?.name ? (
@@ -310,9 +312,9 @@ export class ArticleTile extends Tile {
{sku}
</Typography>
) : null}
{article.net != null && (
{grossPrice != null && (
<Typography variant="caption" fontWeight={600}>
{formatPrice(article.net)}
{formatPrice(grossPrice)}
</Typography>
)}
</>
@@ -353,6 +355,7 @@ export class ArticleTile extends Tile {
const hasPhoto = this.hasPhoto();
const { article, label, sx, disabled } = this.props;
const sku = articleSku(article);
const grossPrice = getArticleGross(article, companyAutomat.state.current?.taxrate);
return (
<ButtonBase
@@ -451,7 +454,7 @@ export class ArticleTile extends Tile {
{sku}
</Typography>
) : null}
{article.net != null && (
{grossPrice != null && (
<Typography
variant="caption"
fontWeight={600}
@@ -461,7 +464,7 @@ export class ArticleTile extends Tile {
textShadow: TEXT_GLOW_DARK,
}}
>
{formatPrice(article.net)}
{formatPrice(grossPrice)}
</Typography>
)}
</Box>

View File

@@ -20,7 +20,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import articleSearchAutomat from '../automats/articleSearchAutomat';
import BackendError from '../components/BackendError';
import { formatPrice } from '../utils/price';
import { formatPrice, getArticleGross } from '../utils/price';
import {
buildSearchUrl,
queryFromParams,
@@ -46,6 +46,7 @@ import {
setLastCategoryId,
navigationAutomat,
categoryAutomat,
companyAutomat,
} from '../automats';
import { BrandLabel } from '../components/brand';
import { SCRIM_LABEL } from '../colors';
@@ -586,7 +587,7 @@ class ArtikelSearchPage extends Component {
const isParent = Boolean(row.isParent || row.is_parent);
const childCount = Number(row.childCount) || 0;
const showChildBadge = isParent || childCount > 0;
const price = row.price ?? row.net ?? row.vknetto;
const price = getArticleGross(row, companyAutomat.state.current?.taxrate);
return (
<ListItemButton
key={row.id}

View File

@@ -52,3 +52,38 @@ export function netFromGross(gross, taxRate) {
if (factor <= 0) return null;
return Math.round((gross / factor) * 10000) / 10000;
}
/**
* Resolves or calculates the gross price (Bruttopreis) for an article.
* Supports:
* - Direct gross price (`article.gross`)
* - Net price with tax rate (`article.net`, `article.taxRate`)
* - Nested prices array (`article.prices`)
*
* @param {object|null} article
* @param {number} [defaultTaxRate=0] Fallback tax rate if not present on article/price
* @returns {number|null} Gross price rounded to 2 decimal places, or null
*/
export function getArticleGross(article, defaultTaxRate = 0) {
if (!article) return null;
if (article.gross != null && Number.isFinite(Number(article.gross))) {
return Number(article.gross);
}
let net = article.net ?? article.price ?? article.vknetto;
let taxRate = article.taxRate;
if (net == null && Array.isArray(article.prices) && article.prices.length > 0) {
const base = article.prices.find((p) => p.minmultiplier === 1) ?? article.prices[0];
if (base?.gross != null && Number.isFinite(Number(base.gross))) {
return Number(base.gross);
}
net = base?.net;
if (taxRate == null) taxRate = base?.taxRate;
}
if (net == null || !Number.isFinite(Number(net))) return null;
const rate = taxRate != null && Number.isFinite(Number(taxRate))
? Number(taxRate)
: Number(defaultTaxRate ?? 0);
const gross = Number(net) * (1 + rate / 100);
return Math.round((gross + Number.EPSILON) * 100) / 100;
}