system prompt client global and project
This commit is contained in:
@@ -6,6 +6,7 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
|
|||||||
const [name, setName] = useState(project?.name ?? '');
|
const [name, setName] = useState(project?.name ?? '');
|
||||||
const [description, setDescription] = useState(project?.description ?? '');
|
const [description, setDescription] = useState(project?.description ?? '');
|
||||||
const [colour, setColour] = useState(project?.colour ?? COLOURS[0]);
|
const [colour, setColour] = useState(project?.colour ?? COLOURS[0]);
|
||||||
|
const [systemPrompt, setSystemPrompt] = useState(project?.system_prompt ?? '');
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -15,13 +16,20 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
|
|||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return;
|
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();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e) {
|
function handleKeyDown(e) {
|
||||||
if (e.key === 'Enter' && mode !== 'confirm-delete') handleSubmit();
|
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onClose();
|
||||||
|
// Don't submit on Enter — textarea fields make Enter ambiguous
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +43,8 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
|
|||||||
background: 'var(--bg-surface)',
|
background: 'var(--bg-surface)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius-lg)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
padding: '24px', width: '380px',
|
padding: '24px', width: '420px',
|
||||||
|
maxHeight: '90vh', overflowY: 'auto',
|
||||||
display: 'flex', flexDirection: 'column', gap: '16px',
|
display: 'flex', flexDirection: 'column', gap: '16px',
|
||||||
}}>
|
}}>
|
||||||
{mode === 'confirm-delete' ? (
|
{mode === 'confirm-delete' ? (
|
||||||
@@ -122,6 +131,32 @@ export default function ProjectModal({ project, mode, onSave, onDelete, onClose
|
|||||||
</div>
|
</div>
|
||||||
</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' }}>
|
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
<button className="btn-reset text-base text-muted"
|
<button className="btn-reset text-base text-muted"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
await updateProject(project.id, { name, description, colour, icon, isolated });
|
await updateProject(project.id, { name, description, colour, icon, isolated, system_prompt });
|
||||||
onProjectsChange?.();
|
onProjectsChange?.();
|
||||||
setModal(null);
|
setModal(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from '../hooks/useSettings';
|
||||||
import { useModels } from '../hooks/useModels';
|
import { useModels } from '../hooks/useModels';
|
||||||
import { getServiceHealth } from '../api/orchestration'; // ← merged
|
import { getServiceHealth } from '../api/orchestration';
|
||||||
|
|
||||||
export default function SettingsView({ onNavigate, onBack, modelProps }) {
|
export default function SettingsView({ onNavigate, onBack, modelProps }) {
|
||||||
const { settings, saveSetting, saving } = useSettings(); // ← single source
|
const { settings, saveSetting, saving } = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--bg-base)' }}>
|
<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>
|
||||||
|
|
||||||
<SettingsSection title="Models">
|
<SettingsSection title="Models">
|
||||||
{/* ← Error boundary wraps ModelsSection only */}
|
|
||||||
<SettingsSectionErrorBoundary>
|
<SettingsSectionErrorBoundary>
|
||||||
<ModelsSection settings={settings} saveSetting={saveSetting} saving={saving} modelProps={modelProps}/>
|
<ModelsSection settings={settings} saveSetting={saveSetting} saving={saving} modelProps={modelProps} />
|
||||||
</SettingsSectionErrorBoundary>
|
</SettingsSectionErrorBoundary>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Global system prompt */}
|
||||||
|
<SettingsSection title="Behaviour">
|
||||||
|
<SystemPromptSetting settings={settings} saveSetting={saveSetting} saving={saving} />
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title="About">
|
<SettingsSection title="About">
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Service Health"
|
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 {
|
class SettingsSectionErrorBoundary extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -107,6 +112,8 @@ class SettingsSectionErrorBoundary extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Layout components ────────────────────────────────────────
|
||||||
|
|
||||||
function SettingsSection({ title, children }) {
|
function SettingsSection({ title, children }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: '32px' }}>
|
<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 }) {
|
function NumberSetting({ label, description, value, min, max, step = 1, onSave, saving }) {
|
||||||
const [local, setLocal] = useState(value ?? '');
|
const [local, setLocal] = useState(value ?? '');
|
||||||
const isDirty = local !== '' && Number(local) !== value;
|
const isDirty = local !== '' && Number(local) !== value;
|
||||||
|
|
||||||
// Sync when settings load from API
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== undefined) setLocal(value);
|
if (value !== undefined) setLocal(value);
|
||||||
}, [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 (
|
return (
|
||||||
<div style={{
|
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
padding: '14px 16px',
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
borderBottom: '1px solid var(--border)',
|
<span className="text-sm" style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||||
}}
|
System Prompt
|
||||||
// Remove bottom border on last child via CSS would need a class;
|
</span>
|
||||||
// easiest to just let it render — the section border-radius clips it cleanly
|
<span className="text-xs text-muted">
|
||||||
>
|
Default instruction given to the model on every request. Projects can override this.
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
</span>
|
||||||
<span className="text-sm" style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{label}</span>
|
</div>
|
||||||
{description && <span className="text-xs text-muted">{description}</span>}
|
{isDirty && (
|
||||||
</div>
|
<button
|
||||||
<div style={{ flexShrink: 0, marginLeft: 16 }}>
|
className="btn-primary"
|
||||||
{action}
|
style={{ padding: '5px 12px', fontSize: '12px', flexShrink: 0, marginLeft: '16px' }}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComingSoon() {
|
// ── Service health ───────────────────────────────────────────
|
||||||
return <span className="text-xs text-muted" style={{ fontStyle: 'italic' }}>Coming soon</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServiceHealth() {
|
function ServiceHealth() {
|
||||||
const [services, setServices] = useState(null);
|
const [services, setServices] = useState(null);
|
||||||
@@ -242,25 +314,18 @@ function ServiceHealth() {
|
|||||||
borderBottom: i < services.length - 1 ? '1px solid var(--border)' : 'none',
|
borderBottom: i < services.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
}}>
|
}}>
|
||||||
{/* Status dot */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||||
background: svc.status === 'healthy' ? '#2ecc71' : '#e74c3c',
|
background: svc.status === 'healthy' ? '#2ecc71' : '#e74c3c',
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<span className="text-sm" style={{ minWidth: 90, color: 'var(--text-primary)' }}>
|
<span className="text-sm" style={{ minWidth: 90, color: 'var(--text-primary)' }}>
|
||||||
{svc.label}
|
{svc.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Detail — show model for inference, nothing extra for others */}
|
|
||||||
<span className="text-xs text-muted" style={{ flex: 1 }}>
|
<span className="text-xs text-muted" style={{ flex: 1 }}>
|
||||||
{svc.key === 'inference' && svc.detail?.model
|
{svc.key === 'inference' && svc.detail?.model
|
||||||
? svc.detail.model
|
? svc.detail.model
|
||||||
: svc.status === 'unreachable' ? 'Unreachable' : ''}
|
: svc.status === 'unreachable' ? 'Unreachable' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Latency */}
|
|
||||||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>
|
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>
|
||||||
{svc.latency}ms
|
{svc.latency}ms
|
||||||
</span>
|
</span>
|
||||||
@@ -272,11 +337,12 @@ function ServiceHealth() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Models section ───────────────────────────────────────────
|
||||||
|
|
||||||
function ModelsSection({ settings, saveSetting, saving, modelProps }) {
|
function ModelsSection({ settings, saveSetting, saving, modelProps }) {
|
||||||
const { models, selectedModel, setSelectedModel } = useModels();
|
const { models, selectedModel, setSelectedModel } = useModels();
|
||||||
const [selectedInfo, setSelectedInfo] = useState(null);
|
const [selectedInfo, setSelectedInfo] = useState(null);
|
||||||
|
|
||||||
// Sync info panel when selection changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = models.find(m => m.value === selectedModel);
|
const m = models.find(m => m.value === selectedModel);
|
||||||
setSelectedInfo(m ?? null);
|
setSelectedInfo(m ?? null);
|
||||||
@@ -287,7 +353,7 @@ function ModelsSection({ settings, saveSetting, saving, modelProps }) {
|
|||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Models Folder"
|
label="Models Folder"
|
||||||
description="Path to folder containing .gguf files"
|
description="Path to folder containing .gguf files"
|
||||||
action={<ModelsFolderSetting settings={settings} saveSetting={saveSetting} saving={saving}/>}
|
action={<ModelsFolderSetting settings={settings} saveSetting={saveSetting} saving={saving} />}
|
||||||
/>
|
/>
|
||||||
<NumberSetting
|
<NumberSetting
|
||||||
label="Temperature"
|
label="Temperature"
|
||||||
@@ -297,7 +363,6 @@ function ModelsSection({ settings, saveSetting, saving, modelProps }) {
|
|||||||
onSave={val => saveSetting('temperature', val)}
|
onSave={val => saveSetting('temperature', val)}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberSetting
|
<NumberSetting
|
||||||
label="Repeat Penalty"
|
label="Repeat Penalty"
|
||||||
description="Penalises repeated tokens — higher reduces repetition (1–2)"
|
description="Penalises repeated tokens — higher reduces repetition (1–2)"
|
||||||
@@ -343,7 +408,6 @@ function ModelsSection({ settings, saveSetting, saving, modelProps }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Model info panel */}
|
|
||||||
{selectedInfo && (
|
{selectedInfo && (
|
||||||
<div style={{
|
<div style={{
|
||||||
margin: '0', padding: '14px 16px',
|
margin: '0', padding: '14px 16px',
|
||||||
@@ -360,7 +424,9 @@ function ModelsSection({ settings, saveSetting, saving, modelProps }) {
|
|||||||
)}
|
)}
|
||||||
<InfoLine
|
<InfoLine
|
||||||
label="Context"
|
label="Context"
|
||||||
value={modelProps ? `${modelProps.contextWindow.toLocaleString()} tokens` : '—'}
|
value={modelProps?.contextWindow
|
||||||
|
? `${modelProps.contextWindow.toLocaleString()} tokens`
|
||||||
|
: '—'}
|
||||||
/>
|
/>
|
||||||
<InfoLine
|
<InfoLine
|
||||||
label="Loaded"
|
label="Loaded"
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ export default function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionRowProps = (session) => ({
|
const sessionRowProps = (session) => ({
|
||||||
key: session.external_id,
|
|
||||||
session,
|
session,
|
||||||
isActive: activeSession?.external_id === session.external_id,
|
isActive: activeSession?.external_id === session.external_id,
|
||||||
isHovered: hoveredId === session.external_id,
|
isHovered: hoveredId === session.external_id,
|
||||||
@@ -236,7 +235,7 @@ export default function Sidebar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{projectSessions.map(session => (
|
{projectSessions.map(session => (
|
||||||
<SessionRow {...sessionRowProps(session)} />
|
<SessionRow key={session.external_id} {...sessionRowProps(session)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -251,7 +250,7 @@ export default function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{unassigned.map(session => (
|
{unassigned.map(session => (
|
||||||
<SessionRow {...sessionRowProps(session)} />
|
<SessionRow key={session.external_id} {...sessionRowProps(session)} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user