system prompt client global and project

This commit is contained in:
Storme-bit
2026-04-19 02:57:11 -07:00
parent a0154e15e6
commit fa3b0859f0
4 changed files with 144 additions and 44 deletions

View File

@@ -6,6 +6,7 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
const [name, setName] = useState(project?.name ?? '');
const [description, setDescription] = useState(project?.description ?? '');
const [colour, setColour] = useState(project?.colour ?? COLOURS[0]);
const [systemPrompt, setSystemPrompt] = useState(project?.system_prompt ?? '');
const inputRef = useRef(null);
useEffect(() => {
@@ -15,13 +16,20 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
function handleSubmit() {
const trimmed = name.trim();
if (!trimmed) return;
onSave({ name: trimmed, description: description.trim() || null, colour, icon: null, isolated: 1 });
onSave({
name: trimmed,
description: description.trim() || null,
colour,
icon: null,
isolated: 1,
system_prompt: systemPrompt.trim() || null,
});
onClose();
}
function handleKeyDown(e) {
if (e.key === 'Enter' && mode !== 'confirm-delete') handleSubmit();
if (e.key === 'Escape') onClose();
// Don't submit on Enter — textarea fields make Enter ambiguous
}
return (
@@ -35,7 +43,8 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: '24px', width: '380px',
padding: '24px', width: '420px',
maxHeight: '90vh', overflowY: 'auto',
display: 'flex', flexDirection: 'column', gap: '16px',
}}>
{mode === 'confirm-delete' ? (
@@ -122,6 +131,32 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
</div>
</div>
{/* System Prompt */}
<div className="flex-col" style={{ gap: '6px' }}>
<label className="label-upper">
System Prompt <span style={{ opacity: 0.5 }}>(optional)</span>
</label>
<p className="text-xs text-muted" style={{ marginTop: '-2px' }}>
Overrides the global system prompt for conversations in this project.
Leave blank to use the global default.
</p>
<textarea
value={systemPrompt}
onChange={e => setSystemPrompt(e.target.value)}
placeholder="You are a helpful assistant specialised in..."
rows={4}
style={{
background: 'var(--bg-elevated)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px',
color: 'var(--text-primary)', fontSize: '13px', outline: 'none',
width: '100%', resize: 'vertical', fontFamily: 'inherit',
lineHeight: '1.6',
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border)'}
/>
</div>
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn-reset text-base text-muted"
onClick={onClose}

View File

@@ -36,9 +36,9 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
}
}
async function handleSave({ name, description, colour, icon, isolated }) {
async function handleSave({ name, description, colour, icon, isolated, system_prompt }) {
try {
await updateProject(project.id, { name, description, colour, icon, isolated });
await updateProject(project.id, { name, description, colour, icon, isolated, system_prompt });
onProjectsChange?.();
setModal(null);
} catch (err) {

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useSettings } from '../hooks/useSettings';
import { useModels } from '../hooks/useModels';
import { getServiceHealth } from '../api/orchestration'; // ← merged
import { getServiceHealth } from '../api/orchestration';
export default function SettingsView({ onNavigate, onBack, modelProps }) {
const { settings, saveSetting, saving } = useSettings(); // ← single source
const { settings, saveSetting, saving } = useSettings();
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--bg-base)' }}>
@@ -51,12 +51,16 @@ export default function SettingsView({ onNavigate, onBack, modelProps }) {
</SettingsSection>
<SettingsSection title="Models">
{/* ← Error boundary wraps ModelsSection only */}
<SettingsSectionErrorBoundary>
<ModelsSection settings={settings} saveSetting={saveSetting} saving={saving} modelProps={modelProps} />
</SettingsSectionErrorBoundary>
</SettingsSection>
{/* Global system prompt */}
<SettingsSection title="Behaviour">
<SystemPromptSetting settings={settings} saveSetting={saveSetting} saving={saving} />
</SettingsSection>
<SettingsSection title="About">
<SettingsRow
label="Service Health"
@@ -79,7 +83,8 @@ export default function SettingsView({ onNavigate, onBack, modelProps }) {
);
}
// Error boundary — class component required by React for this pattern
// ── Error boundary ───────────────────────────────────────────
class SettingsSectionErrorBoundary extends React.Component {
constructor(props) {
super(props);
@@ -107,6 +112,8 @@ class SettingsSectionErrorBoundary extends React.Component {
}
}
// ── Layout components ────────────────────────────────────────
function SettingsSection({ title, children }) {
return (
<div style={{ marginBottom: '32px' }}>
@@ -125,11 +132,28 @@ function SettingsSection({ title, children }) {
);
}
function SettingsRow({ label, description, action }) {
return (
<div style={{
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span className="text-sm" style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{label}</span>
{description && <span className="text-xs text-muted">{description}</span>}
</div>
<div style={{ flexShrink: 0, marginLeft: 16 }}>
{action}
</div>
</div>
);
}
function NumberSetting({ label, description, value, min, max, step = 1, onSave, saving }) {
const [local, setLocal] = useState(value ?? '');
const isDirty = local !== '' && Number(local) !== value;
// Sync when settings load from API
useEffect(() => {
if (value !== undefined) setLocal(value);
}, [value]);
@@ -168,30 +192,78 @@ function NumberSetting({ label, description, value, min, max, step = 1, onSave,
);
}
function SettingsRow({ label, description, action }) {
function ComingSoon() {
return <span className="text-xs text-muted" style={{ fontStyle: 'italic' }}>Coming soon</span>;
}
// ── System prompt setting ────────────────────────────────────
function SystemPromptSetting({ settings, saveSetting, saving }) {
const [local, setLocal] = useState(settings?.systemPrompt ?? '');
const [savedPrompt, setSavedPrompt] = useState(settings?.systemPrompt ?? '');
useEffect(() => {
if (settings?.systemPrompt !== undefined) {
setLocal(settings.systemPrompt ?? '');
setSavedPrompt(settings.systemPrompt ?? '');
}
}, [settings?.systemPrompt]);
const isDirty = local !== savedPrompt;
async function handleSave() {
await saveSetting('systemPrompt', local.trim() || null);
setSavedPrompt(local);
}
return (
<div style={{
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
}}
// Remove bottom border on last child via CSS would need a class;
// easiest to just let it render the section border-radius clips it cleanly
>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '8px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span className="text-sm" style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{label}</span>
{description && <span className="text-xs text-muted">{description}</span>}
<span className="text-sm" style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
System Prompt
</span>
<span className="text-xs text-muted">
Default instruction given to the model on every request. Projects can override this.
</span>
</div>
<div style={{ flexShrink: 0, marginLeft: 16 }}>
{action}
{isDirty && (
<button
className="btn-primary"
style={{ padding: '5px 12px', fontSize: '12px', flexShrink: 0, marginLeft: '16px' }}
disabled={saving}
onClick={handleSave}
>
{saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
<textarea
value={local}
onChange={e => setLocal(e.target.value)}
rows={5}
style={{
width: '100%',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
padding: '10px 12px',
color: 'var(--text-primary)',
fontSize: '13px', lineHeight: '1.6',
resize: 'vertical', fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box',
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border)'}
/>
{!isDirty && local && (
<p className="text-xs text-muted" style={{ marginTop: '6px' }}>Saved</p>
)}
</div>
);
}
function ComingSoon() {
return <span className="text-xs text-muted" style={{ fontStyle: 'italic' }}>Coming soon</span>;
}
// ── Service health ───────────────────────────────────────────
function ServiceHealth() {
const [services, setServices] = useState(null);
@@ -242,25 +314,18 @@ function ServiceHealth() {
borderBottom: i < services.length - 1 ? '1px solid var(--border)' : 'none',
background: 'var(--bg-elevated)',
}}>
{/* Status dot */}
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: svc.status === 'healthy' ? '#2ecc71' : '#e74c3c',
}} />
{/* Label */}
<span className="text-sm" style={{ minWidth: 90, color: 'var(--text-primary)' }}>
{svc.label}
</span>
{/* Detail — show model for inference, nothing extra for others */}
<span className="text-xs text-muted" style={{ flex: 1 }}>
{svc.key === 'inference' && svc.detail?.model
? svc.detail.model
: svc.status === 'unreachable' ? 'Unreachable' : ''}
</span>
{/* Latency */}
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>
{svc.latency}ms
</span>
@@ -272,11 +337,12 @@ function ServiceHealth() {
);
}
// ── Models section ───────────────────────────────────────────
function ModelsSection({ settings, saveSetting, saving, modelProps }) {
const { models, selectedModel, setSelectedModel } = useModels();
const [selectedInfo, setSelectedInfo] = useState(null);
// Sync info panel when selection changes
useEffect(() => {
const m = models.find(m => m.value === selectedModel);
setSelectedInfo(m ?? null);
@@ -297,7 +363,6 @@ function ModelsSection({ settings, saveSetting, saving, modelProps }) {
onSave={val => saveSetting('temperature', val)}
saving={saving}
/>
<NumberSetting
label="Repeat Penalty"
description="Penalises repeated tokens — higher reduces repetition (12)"
@@ -343,7 +408,6 @@ function ModelsSection({ settings, saveSetting, saving, modelProps }) {
}
/>
{/* Model info panel */}
{selectedInfo && (
<div style={{
margin: '0', padding: '14px 16px',
@@ -360,7 +424,9 @@ function ModelsSection({ settings, saveSetting, saving, modelProps }) {
)}
<InfoLine
label="Context"
value={modelProps ? `${modelProps.contextWindow.toLocaleString()} tokens` : '—'}
value={modelProps?.contextWindow
? `${modelProps.contextWindow.toLocaleString()} tokens`
: '—'}
/>
<InfoLine
label="Loaded"

View File

@@ -104,7 +104,6 @@ export default function Sidebar({
}
const sessionRowProps = (session) => ({
key: session.external_id,
session,
isActive: activeSession?.external_id === session.external_id,
isHovered: hoveredId === session.external_id,
@@ -236,7 +235,7 @@ export default function Sidebar({
</span>
</div>
{projectSessions.map(session => (
<SessionRow {...sessionRowProps(session)} />
<SessionRow key={session.external_id} {...sessionRowProps(session)} />
))}
</div>
);
@@ -251,7 +250,7 @@ export default function Sidebar({
</div>
)}
{unassigned.map(session => (
<SessionRow {...sessionRowProps(session)} />
<SessionRow key={session.external_id} {...sessionRowProps(session)} />
))}
</>
)}