Add user defined system message

This commit is contained in:
dwrz
2026-05-28 00:53:08 +00:00
parent a06833bb8b
commit 9ea4005e96
26 changed files with 652 additions and 222 deletions

View File

@@ -311,6 +311,8 @@ type Request struct {
Messages []openai.ChatCompletionMessage `json:"messages"`
// Model is the LLM model ID. If empty, the default model is used.
Model string `json:"model,omitempty"`
// SystemMessage overrides the configured system message for this request.
SystemMessage string `json:"system_message,omitempty"`
// Voice is the voice ID for TTS.
Voice string `json:"voice,omitempty"`
}
@@ -455,7 +457,7 @@ func (svc *Service) voice(w http.ResponseWriter, r *http.Request) {
if model == "" {
model = svc.llm.DefaultModel()
}
msgs, err := svc.llm.Query(ctx, messages, model)
msgs, err := svc.llm.Query(ctx, messages, model, req.SystemMessage)
if err != nil {
log.ErrorContext(
ctx,
@@ -731,7 +733,7 @@ func (svc *Service) voiceStream(w http.ResponseWriter, r *http.Request) {
llmErr error
)
go func() {
llmErr = svc.llm.QueryStream(ctx, messages, model, events)
llmErr = svc.llm.QueryStream(ctx, messages, model, req.SystemMessage, events)
}()
// Consume events and send as SSE.

View File

@@ -90,4 +90,9 @@
<path d="m15 9-6 6"/>
<path d="m9 9 6 6"/>
</symbol>
<symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -593,6 +593,201 @@ body {
color: var(--color-text-muted);
}
/* ==================== */
/* Settings Modal */
/* ==================== */
.settings-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: var(--s1);
}
.settings-overlay.open {
display: flex;
}
.settings-panel {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
padding: var(--s1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
.settings-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--s1);
}
.settings-panel__title {
font-size: var(--s1);
font-weight: 600;
color: var(--color-text);
margin: 0;
}
/* Close button: remove toolbar border, darken the X. */
#settings-close {
border: none;
color: var(--color-text);
}
@media (hover: hover) {
#settings-close:hover {
border-color: transparent;
color: var(--color-text);
}
}
/* Tabs */
.settings-tabs {
display: flex;
border-bottom: 2px solid var(--color-border-light);
margin-bottom: var(--s1);
}
.settings-tab {
padding: var(--s-2) var(--s-1);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
font-family: inherit;
font-size: var(--s-1);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
@media (hover: hover) {
.settings-tab:hover {
color: var(--color-text);
}
}
.settings-tab:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.settings-tab.active {
color: var(--color-text);
border-bottom-color: var(--color-primary);
font-weight: 500;
}
/* Tab panels */
.settings-tab-panel {
display: none;
}
.settings-tab-panel.active {
display: block;
}
.settings-label {
display: block;
font-size: var(--s-1);
font-weight: 500;
color: var(--color-text);
margin-bottom: var(--s-2);
}
.settings-select {
width: 100%;
height: var(--s2);
padding: 0 var(--s-1);
font-family: inherit;
font-size: var(--s-1);
line-height: var(--s2);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
cursor: pointer;
margin-bottom: var(--s1);
}
.settings-select:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.settings-textarea {
display: block;
width: 100%;
min-height: calc(var(--s2) * 6);
padding: var(--s-1);
font-family: var(--font-mono);
font-size: var(--s-1);
line-height: 1.5;
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
resize: vertical;
margin-bottom: var(--s-1);
}
.settings-textarea:focus {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.settings-save-row {
display: flex;
justify-content: flex-end;
}
.settings-save-btn {
display: flex;
align-items: center;
justify-content: center;
padding: var(--s-2) var(--s-1);
font-family: inherit;
font-size: var(--s-1);
font-weight: 500;
color: white;
background: var(--color-primary);
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: background-color 0.15s ease;
min-width: var(--s2);
}
@media (hover: hover) {
.settings-save-btn:hover {
background: var(--color-primary-hover);
}
}
.settings-save-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.settings-save-btn--success {
background: var(--color-green);
}
.settings-save-btn .icon {
width: 1rem;
height: 1rem;
}
/* ==================== */
/* Animations */
/* ==================== */
@@ -668,4 +863,16 @@ body {
max-width: 128px;
font-size: var(--s-1);
}
.settings-overlay {
padding: 0;
}
.settings-panel {
max-width: 100%;
max-height: 100dvh;
height: 100dvh;
border-radius: 0;
border: none;
}
}

View File

@@ -3,6 +3,7 @@ const ICONS_URL = '/static/icons.svg';
const MODELS_ENDPOINT = '/v1/models';
const MODEL_KEY = 'odidere_model';
const STORAGE_KEY = 'odidere_history';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const VOICES_ENDPOINT = '/v1/voices';
const VOICE_KEY = 'odidere_voice';
@@ -42,6 +43,15 @@ class Odidere {
this.$voice = document.getElementById('voice');
this.$mute = document.getElementById('mute');
// Settings modal
this.$settings = document.getElementById('settings');
this.$settingsOverlay = document.getElementById('settings-overlay');
this.$settingsClose = document.getElementById('settings-close');
this.$settingsTabs = document.querySelectorAll('.settings-tab');
this.$settingsPanels = document.querySelectorAll('.settings-tab-panel');
this.$systemMessageInput = document.getElementById('system-message-input');
this.$saveSystemMessage = document.getElementById('save-system-message');
// Templates
this.$tplAssistantMessage = document.getElementById(
'tpl-assistant-message',
@@ -151,6 +161,28 @@ class Odidere {
});
// Mute button
this.$mute.addEventListener('click', () => this.#toggleMute());
// Settings modal
this.$settings.addEventListener('click', () => this.openSettings());
this.$settingsClose.addEventListener('click', () => this.closeSettings());
this.$settingsOverlay.addEventListener('click', (e) => {
if (e.target === this.$settingsOverlay) this.closeSettings();
});
this.document.addEventListener('keydown', (e) => {
if (
e.key === 'Escape' &&
this.$settingsOverlay.classList.contains('open')
) {
e.preventDefault();
this.closeSettings();
}
});
this.$settingsTabs.forEach(($tab) => {
$tab.addEventListener('click', () => this.#switchTab($tab.dataset.tab));
});
this.$saveSystemMessage.addEventListener('click', () =>
this.#saveSystemMessage(),
);
}
/**
@@ -557,6 +589,10 @@ class Odidere {
voice: this.$voice.value,
model: this.$model.value,
};
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
if (systemMessage) {
payload.system_message = systemMessage;
}
if (audio) {
payload.audio = await this.#toBase64(audio);
}
@@ -674,7 +710,11 @@ class Odidere {
}
// Collect tool results and render once all have arrived.
if (message.role === 'tool' && pendingTools && pendingTools.assistant) {
if (
message.role === 'tool' &&
pendingTools &&
pendingTools.assistant
) {
pendingTools.results.push(message);
// Add to history (server needs it) but don't render yet.
this.#appendHistory([message]);
@@ -849,6 +889,67 @@ class Odidere {
}
}
/**
* openSettings opens the settings modal and loads current values.
*/
openSettings() {
this.$settingsOverlay.classList.add('open');
this.#loadSystemMessage();
this.#switchTab('model-voice');
// Focus the close button for accessibility.
this.$settingsClose.focus();
}
/**
* closeSettings closes the settings modal and restores focus.
*/
closeSettings() {
this.$settingsOverlay.classList.remove('open');
this.$settings.focus();
}
/**
* #switchTab switches the active tab in the settings modal.
* @param {string} tabName
*/
#switchTab(tabName) {
this.$settingsTabs.forEach(($tab) => {
const isActive = $tab.dataset.tab === tabName;
$tab.classList.toggle('active', isActive);
$tab.setAttribute('aria-selected', String(isActive));
});
this.$settingsPanels.forEach(($panel) => {
const isActive = $panel.dataset.tabPanel === tabName;
$panel.classList.toggle('active', isActive);
$panel.hidden = !isActive;
});
}
/**
* #saveSystemMessage saves the textarea value to localStorage.
*/
#saveSystemMessage() {
const value = this.$systemMessageInput.value;
localStorage.setItem(SYSTEM_MESSAGE_KEY, value);
// Brief visual feedback on the save button.
this.$saveSystemMessage.replaceChildren(this.#icon('check'));
this.$saveSystemMessage.classList.add('settings-save-btn--success');
setTimeout(() => {
this.$saveSystemMessage.textContent = 'Save';
this.$saveSystemMessage.classList.remove('settings-save-btn--success');
}, 1500);
}
/**
* #loadSystemMessage reads from localStorage and populates the textarea.
*/
#loadSystemMessage() {
const stored = localStorage.getItem(SYSTEM_MESSAGE_KEY);
this.$systemMessageInput.value = stored || '';
}
// ====================
// RENDER: SELECTS
// ====================

View File

@@ -2,5 +2,6 @@
<body>
{{ template "main" . }}
{{ template "templates" . }}
{{ template "modal/settings" . }}
</body>
{{ end }}

View File

@@ -1,5 +1,14 @@
{{ define "footer/toolbar" }}
<div class="footer__toolbar">
<button
type="button"
class="footer__toolbar-btn"
id="settings"
aria-label="Settings"
>
<svg class="icon"><use href="/static/icons.svg#settings"></use></svg>
</button>
<div class="footer__toolbar-spacer"></div>
<button
type="button"
class="footer__toolbar-btn"
@@ -8,17 +17,6 @@
>
<svg class="icon"><use href="/static/icons.svg#reset"></use></svg>
</button>
<div class="footer__toolbar-spacer"></div>
<select id="model" class="footer__select" aria-label="Model">
<option value="" disabled selected>
Loading...
</option>
</select>
<select id="voice" class="footer__select" aria-label="Voice">
<option value="" disabled selected>
Loading...
</option>
</select>
<button
type="button"
class="footer__toolbar-btn"

View File

@@ -0,0 +1,83 @@
{{ define "modal/settings" }}
<div class="settings-overlay" id="settings-overlay">
<div class="settings-panel" role="dialog" aria-modal="true"
aria-labelledby="settings-title">
<div class="settings-panel__header">
<h2 class="settings-panel__title" id="settings-title">Settings</h2>
<button
type="button"
class="footer__toolbar-btn"
id="settings-close"
aria-label="Close settings"
>
<svg class="icon"><use href="/static/icons.svg#close"></use></svg>
</button>
</div>
<div class="settings-tabs" role="tablist">
<button
type="button"
class="settings-tab active"
role="tab"
data-tab="model-voice"
aria-selected="true"
aria-controls="panel-model-voice"
>Model &amp; Voice</button>
<button
type="button"
class="settings-tab"
role="tab"
data-tab="system-message"
aria-selected="false"
aria-controls="panel-system-message"
>System Message</button>
</div>
<div class="settings-tab-panels">
<div
class="settings-tab-panel active"
role="tabpanel"
data-tab-panel="model-voice"
id="panel-model-voice"
aria-labelledby="tab-model-voice"
>
<label class="settings-label" for="model">Model</label>
<select id="model" class="settings-select" aria-label="Model">
<option value="" disabled selected>Loading...</option>
</select>
<label class="settings-label" for="voice">Voice</label>
<select id="voice" class="settings-select" aria-label="Voice">
<option value="" disabled selected>Loading...</option>
</select>
</div>
<div
class="settings-tab-panel"
role="tabpanel"
data-tab-panel="system-message"
id="panel-system-message"
aria-labelledby="tab-system-message"
hidden
>
<label class="settings-label" for="system-message-input">
System Message
</label>
<textarea
id="system-message-input"
class="settings-textarea"
rows="8"
placeholder="Enter system message..."
></textarea>
<div class="settings-save-row">
<button
type="button"
class="settings-save-btn"
id="save-system-message"
>Save</button>
</div>
</div>
</div>
</div>
</div>
{{ end }}