Rules read from the main model decide who it is offered as a crowd member, a friend and on its roster; any-to-any with denies by default, or none-to-none with allows. The crowd picker names what it holds back and why, and a model held back only by a person's own rule -- or by anything, with the new rules.override -- can still be added by hand. Another data group is now a deny that an explicit rule opens. Admin -> Model rules and a card in Settings, each with a matrix drawn by the enforcing function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
685 lines
36 KiB
HTML
685 lines
36 KiB
HTML
{% extends "base.html" %}
|
|
{% from "_macros.html" import icon, model_avatar %}
|
|
|
|
{% block title %}Your settings - {{ brand.name }}{% endblock %}
|
|
|
|
{% block head %}
|
|
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
|
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
|
{% endblock %}
|
|
|
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
|
|
|
{% block body %}
|
|
<div class="shell">
|
|
{% include "partials/sidebar.html" %}
|
|
|
|
<main class="main">
|
|
<header class="topbar">
|
|
{% include "partials/_sidebar_toggle.html" %}
|
|
<h1 class="topbar__title">{{ t("Your settings") }}</h1>
|
|
</header>
|
|
|
|
{#
|
|
Tabs are radio inputs plus sibling selectors: no JavaScript, and the
|
|
chosen tab survives a re-render because the browser keeps the checked
|
|
state. Each panel is a real fragment of the page, not a fetch.
|
|
#}
|
|
<div class="tabs">
|
|
{#
|
|
Deliberately no `role="tablist"`.
|
|
|
|
It had one, and the children are `<input type="radio">` and `<label>` -- so a
|
|
screen reader announced a tablist containing no tabs, and the panels carried
|
|
neither `role="tabpanel"` nor an `aria-labelledby` to be announced as. What
|
|
this actually is, is a radio group, and a perfectly good one: arrow keys move
|
|
between the options, the checked one is announced, and the CSS that reveals
|
|
the matching panel keys off exactly that. Being an honest radio group beats
|
|
claiming to be a tab interface and then not behaving as one.
|
|
#}
|
|
<div class="tabs__bar" role="radiogroup" aria-label="{{ t('Settings sections') }}">
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-account" checked>
|
|
<label class="tabs__tab" for="tab-account">{{ icon("user", "icon--sm") }} Account</label>
|
|
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-models">
|
|
<label class="tabs__tab" for="tab-models">{{ icon("sliders", "icon--sm") }} Models</label>
|
|
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-appearance">
|
|
<label class="tabs__tab" for="tab-appearance">{{ icon("sun", "icon--sm") }} Appearance</label>
|
|
|
|
{% if audio.stt_enabled or audio.tts_enabled %}
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-audio">
|
|
<label class="tabs__tab" for="tab-audio">{{ icon("speaker", "icon--sm") }} Audio</label>
|
|
{% endif %}
|
|
|
|
{% if can.get("library.use") %}
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-memory">
|
|
<label class="tabs__tab" for="tab-memory">{{ icon("sparkle", "icon--sm") }} Memory</label>
|
|
{% endif %}
|
|
|
|
{% if several_groups or may_manage_groups %}
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-data">
|
|
<label class="tabs__tab" for="tab-data">{{ icon("shield", "icon--sm") }} {{ t("Data") }}</label>
|
|
{% endif %}
|
|
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-security">
|
|
<label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label>
|
|
</div>
|
|
|
|
<div class="tabs__body">
|
|
<div class="page">
|
|
{% if error %}
|
|
<div class="alert alert--error" role="alert">
|
|
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if saved %}
|
|
<div class="alert alert--success" role="status">
|
|
{{ icon("check", "icon--sm") }} <span>{{ saved }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# --- Account --- #}
|
|
<section class="tabs__panel" data-tab="tab-account">
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Account") }}</h2>
|
|
<dl class="detail-list">
|
|
<dt>Name</dt><dd>{{ user.name }}</dd>
|
|
<dt>Email</dt><dd class="mono">{{ user.email }}</dd>
|
|
<dt>Role</dt>
|
|
<dd><span class="badge {{ 'badge--leaf' if user.is_admin }}">{{ user.role }}</span></dd>
|
|
<dt>Groups</dt>
|
|
<dd>
|
|
{% if user.groups %}
|
|
{% for group in user.groups %}<span class="badge">{{ group.name }}</span> {% endfor %}
|
|
{% else %}
|
|
<span class="faint">{{ t("None") }}</span>
|
|
{% endif %}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("What you can do") }}</h2>
|
|
<p class="card__lede">
|
|
{% if user.is_admin %}
|
|
You are an administrator, so every permission applies.
|
|
{% else %}
|
|
From the instance baseline plus whatever your groups add.
|
|
{% endif %}
|
|
</p>
|
|
<ul class="perm-list">
|
|
{% for key, granted in can.items() %}
|
|
<li class="perm-list__item">
|
|
<span class="perm-list__state {{ 'is-on' if granted }}">
|
|
{{ icon("check" if granted else "x", "icon--sm") }}
|
|
</span>
|
|
<code>{{ key }}</code>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
{# --- Models --- #}
|
|
<section class="tabs__panel" data-tab="tab-models">
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Default model") }}</h2>
|
|
<p class="card__lede">{{ t("What a new chat starts with. Existing chats keep their model.") }}</p>
|
|
{% if models %}
|
|
<form method="post" action="/api/preferences/default-model">
|
|
<div class="field">
|
|
<select class="select" name="model_id" aria-label="{{ t('Default model') }}">
|
|
<option value="">{{ t("Use the instance default") }}</option>
|
|
{% for model in models %}
|
|
<option value="{{ model.model_id }}"
|
|
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
|
|
{{ model.label }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
|
</form>
|
|
{% else %}
|
|
<p class="muted text-sm">{{ t("No models are available to you yet.") }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if models %}
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Available to you") }}</h2>
|
|
<p class="card__lede">{{ t("In the order an administrator arranged them.") }}</p>
|
|
{# Name, context window and vision on one line, on the list's
|
|
column tracks so they line up down the card; the capability
|
|
switches wrap underneath at the full width. They sat beside
|
|
the name until 1.8.2 and, twenty tags long, squeezed it to a
|
|
word per line and ran over it. #}
|
|
<ul class="model-list model-list--models">
|
|
{% for model in models %}
|
|
<li class="model-list__item">
|
|
{{ model_avatar(model, cls="nav-item__avatar") }}
|
|
<strong class="model-list__name">
|
|
<span>{{ model.label }}</span>
|
|
{% if model.pinned %}<span class="badge">{{ t("pinned") }}</span>{% endif %}
|
|
</strong>
|
|
<span class="model-ctx mono"
|
|
{% if model.context_length %}title="{{ t('Context window') }}: {{ model.context_length }}"{% endif %}>
|
|
{%- if model.context_length %}CTX {{ model.context_length|context_size }}{% endif -%}
|
|
</span>
|
|
<span class="model-vision">
|
|
{%- if (model.capabilities_json or {}).get("vision") -%}
|
|
{{ icon("eye", "icon--sm") }}<span class="visually-hidden">{{ t("Sees images") }}</span>
|
|
{%- endif -%}
|
|
</span>
|
|
{% if model.description %}
|
|
<div class="model-list__more text-xs faint">{{ model.description }}</div>
|
|
{% endif %}
|
|
<div class="model-list__more model-list__tags">
|
|
{%- for name, on in (model.capabilities_json or {}).items() -%}
|
|
{%- if on %}<span class="tag">{{ name }}</span>{% endif -%}
|
|
{%- endfor -%}
|
|
</div>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
{#
|
|
Who your models may talk to. Anybody may narrow the instance's rules
|
|
for themselves; widening them takes `rules.override`, and the text
|
|
below says which of the two this person has, so a rule that does
|
|
nothing is not a mystery.
|
|
#}
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Who your models may talk to") }}</h2>
|
|
<p class="card__lede">
|
|
{% if talk_override %}
|
|
{{ t("Your rules and your setting win over the instance's, for you. You can also add any model to a crowd by hand.") }}
|
|
{% else %}
|
|
{{ t("Your rules can only narrow the instance's. A model your own rule holds back can still be added to a crowd by hand; one the instance holds back cannot.") }}
|
|
{% endif %}
|
|
</p>
|
|
<form method="post" action="/api/preferences/talk-mode" class="row"
|
|
style="gap: var(--sp-2); margin-bottom: var(--sp-4)">
|
|
<label class="visually-hidden" for="talk-mode">{{ t("Your starting point") }}</label>
|
|
<select class="select" id="talk-mode" name="mode" style="flex: 1; min-width: 0">
|
|
<option value="" {{ 'selected' if not talk_mode }}>{{ t("Follow the instance") }}</option>
|
|
<option value="open" {{ 'selected' if talk_mode == 'open' }}>{{ t("Any model may talk to any other") }}</option>
|
|
<option value="closed" {{ 'selected' if talk_mode == 'closed' }}>{{ t("No model may talk to another") }}</option>
|
|
</select>
|
|
<button class="btn" type="submit">{{ t("Save") }}</button>
|
|
</form>
|
|
{% set rules_action = "/api/preferences/talk-rules" %}
|
|
{% set rules = talk_rules %}
|
|
{% set matrix = talk_matrix %}
|
|
{% set matrix_models = talk_matrix_models %}
|
|
{% include "partials/_talk_rules.html" %}
|
|
</div>
|
|
</section>
|
|
|
|
{# --- Appearance --- #}
|
|
<section class="tabs__panel" data-tab="tab-appearance">
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Language") }}</h2>
|
|
<p class="card__lede">{{ t("What you see the interface in. Saved to your account rather than to this browser, so it follows you — and it overrides whatever the instance is set to.") }}</p>
|
|
<form method="post" action="/api/preferences/language">
|
|
<div class="field">
|
|
<label class="field__label visually-hidden" for="language">{{ t("Language") }}</label>
|
|
<div class="row" style="gap: var(--sp-2)">
|
|
<select class="select" id="language" name="language" style="flex: 1">
|
|
<option value="">Follow the instance ({{ instance_language }})</option>
|
|
{% for code, name in languages %}
|
|
<option value="{{ code }}" {{ 'selected' if chosen_language == code }}>
|
|
{{ name }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
|
</div>
|
|
<p class="field__hint">{{ t("The interface only. Models answer in the language you write to them in, whatever this says.") }}</p>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Theme") }}</h2>
|
|
<p class="card__lede">{{ t("Saved to this browser and to your account, so it follows you.") }}</p>
|
|
{#
|
|
A loop rather than two buttons, because an administrator can
|
|
define more. The names come from the branding snapshot, so a
|
|
theme renamed on Customization is renamed here without this
|
|
template knowing there was ever a fixed pair.
|
|
#}
|
|
<div class="btn-row">
|
|
{% for entry in brand.themes %}
|
|
<button class="btn" type="button"
|
|
onclick="window.lembas.applyTheme('{{ entry.id }}')">
|
|
{{ icon("moon" if entry.scheme == "dark" else "sun", "icon--sm") }}
|
|
{{ entry.label }} — {{ entry.scheme }}
|
|
</button>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Timezone") }}</h2>
|
|
<p class="card__lede">{{ t("What time a model is told it is, and the zone anything you schedule runs in. Leave it unset to follow the server.") }}</p>
|
|
{#
|
|
A plain form posting to its own route, not the composer's
|
|
pattern: this page has no htmx target to swap into and a
|
|
redirect back is what stops a refresh re-submitting it.
|
|
#}
|
|
<form method="post" action="/api/preferences/timezone" class="btn-row">
|
|
<select class="select" name="timezone" style="flex: 1" aria-label="{{ t('Your timezone') }}">
|
|
<option value="" {{ 'selected' if not timezone }}>
|
|
Follow the server ({{ server_timezone }})
|
|
</option>
|
|
{% for zone in timezones %}
|
|
<option value="{{ zone }}" {{ 'selected' if zone == timezone }}>{{ zone }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button class="btn" type="submit">{{ t("Save") }}</button>
|
|
</form>
|
|
{% if saved == "timezone" %}
|
|
<p class="field__hint">Saved. It is {{ local_now }} where you are.</p>
|
|
{% elif error == "timezone" %}
|
|
<p class="field__hint">{{ t("That is not a timezone this server knows about.") }}</p>
|
|
{% else %}
|
|
<p class="field__hint">It is currently {{ local_now }} where you are.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Notifications") }}</h2>
|
|
<p class="card__lede">{{ t("A reply, a filed report or a scheduled run arriving while you are looking at something else. In the page always; from the browser as well if you allow it here.") }}</p>
|
|
{# A button rather than a checkbox, and that is not a style choice:
|
|
the browser refuses `requestPermission` unless it is called from
|
|
something somebody pressed. A checkbox restored on load and
|
|
acted upon would be silently declined. ui.js writes the label
|
|
and the line below it, because the true state lives in the
|
|
browser's own permission and not in anything rendered here. #}
|
|
<div class="btn-row">
|
|
<button class="btn" type="button" data-notify-toggle aria-pressed="false">{{ t("Turn on notifications") }}</button>
|
|
</div>
|
|
<p class="field__hint" data-notify-state>{{ t("Checking what this browser allows…") }}</p>
|
|
<p class="field__hint">{{ t("Kept per browser, because the permission is. Nothing is shown while you are looking at the page — the toast is there for that.") }}</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Install as an app") }}</h2>
|
|
<p class="card__lede">{{ t("Runs in its own window, without browser chrome. Everything still comes from your server — there is no offline mode beyond a page saying so.") }}</p>
|
|
{# Revealed by app.js only when the browser actually offers an
|
|
install. Firefox and desktop Safari never do, and a button that
|
|
does nothing is worse than no button. #}
|
|
<div class="btn-row" data-install-app hidden>
|
|
<button class="btn btn--primary" type="button"
|
|
onclick="window.lembas.promptInstall()">
|
|
{{ icon("plus", "icon--sm") }} Install
|
|
</button>
|
|
</div>
|
|
{# Filled by app.js from what actually happened, because every
|
|
reason the button is absent looks the same from here. The static
|
|
line below it used to be the only explanation, and it named the
|
|
one cause that is least likely on a home network. #}
|
|
<p class="field__hint" data-install-status hidden></p>
|
|
<p class="field__hint">{{ t("Installing needs a secure connection — HTTPS with a certificate this device trusts, or localhost — and some browsers never offer it. On iOS, use Share → Add to Home Screen.") }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
{# --- Audio --- #}
|
|
{% if audio.stt_enabled or audio.tts_enabled %}
|
|
<section class="tabs__panel" data-tab="tab-audio">
|
|
<form method="post" action="/api/preferences/audio">
|
|
{% if audio.tts_enabled %}
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Reading replies aloud") }}</h2>
|
|
<p class="card__lede">{{ t("Overrides what the administrator chose, for you only.") }}</p>
|
|
|
|
<div class="grid grid--2">
|
|
<div class="field">
|
|
<label class="field__label" for="audio-voice">{{ t("Voice") }}</label>
|
|
{# Options are fetched from the speech endpoint rather than
|
|
stored, so the list follows the server. #}
|
|
<select class="select" id="audio-voice" name="voice">
|
|
{% with selected = user_audio.get('voice', ''),
|
|
instance_voice = audio.tts_voice %}
|
|
{% include "partials/_voice_options.html" %}
|
|
{% endwith %}
|
|
</select>
|
|
<p class="field__hint">
|
|
{% if voice_error %}
|
|
The voice list could not be read: {{ voice_error }}
|
|
{% else %}
|
|
{{ voices | length }} available.
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="audio-speed">{{ t("Speed") }}</label>
|
|
<input class="input" id="audio-speed" name="speed" type="number"
|
|
min="0.25" max="4" step="0.05"
|
|
value="{{ user_audio.get('speed', '') }}"
|
|
placeholder="{{ audio.tts_speed }}">
|
|
<p class="field__hint">{{ t("Leave empty to use the instance default.") }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="checkbox">
|
|
<input type="checkbox" name="autoplay" value="true"
|
|
{{ 'checked' if user_audio.get('autoplay', audio.tts_autoplay) }}>
|
|
<span>{{ t("Read each reply aloud as it finishes") }}</span>
|
|
</label>
|
|
<p class="field__hint">{{ t("Only replies that arrive while you are looking at the chat.") }}</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if audio.stt_enabled %}
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Dictation") }}</h2>
|
|
<div class="field">
|
|
<label class="field__label" for="audio-language">{{ t("Language") }}</label>
|
|
<input class="input" id="audio-language" name="language" maxlength="16"
|
|
value="{{ user_audio.get('language', '') }}"
|
|
placeholder="{{ audio.stt_language or 'detect' }}">
|
|
<p class="field__hint">
|
|
An ISO code such as <code>en</code> or <code>sk</code>. Empty
|
|
lets the server work it out, which is usually best.
|
|
</p>
|
|
</div>
|
|
<p class="field__hint">{{ t("The microphone needs HTTPS or localhost — browsers do not grant it over plain HTTP.") }}</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="btn-row">
|
|
<button class="btn btn--primary" type="submit">{{ t("Save audio preferences") }}</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# --- Memory --- #}
|
|
{% if can.get("library.use") %}
|
|
<section class="tabs__panel" data-tab="tab-memory">
|
|
<div class="card">
|
|
<h2 class="card__title">
|
|
What models remember about you
|
|
<span class="badge">{{ memories|length }}</span>
|
|
</h2>
|
|
<p class="card__lede">
|
|
Short facts, shown to every model you talk to on every turn — so
|
|
they are few and short on purpose. Anything longer belongs in a
|
|
<a href="/library/notes">note</a>. Nothing here is shared with
|
|
anyone else, ever.
|
|
</p>
|
|
|
|
{% if memories %}
|
|
<ul class="model-list">
|
|
{% for memory in memories %}
|
|
<li class="model-list__item">
|
|
<form method="post" action="/api/library/memories/{{ memory.id }}"
|
|
class="row" style="flex: 1; gap: var(--sp-2); min-width: 0">
|
|
<input class="input" name="content" value="{{ memory.content }}"
|
|
maxlength="{{ memory_limit }}" style="flex: 1"
|
|
aria-label="{{ t('What this memory says') }}">
|
|
{% if several_groups %}
|
|
<span class="badge" title="{{ t('Data group') }}">{{ group_names.get(memory.data_group_id or 'default', '') }}</span>
|
|
{% endif %}
|
|
<button class="btn btn--sm" type="submit">{{ t("Save") }}</button>
|
|
<button class="btn btn--sm btn--danger" type="submit"
|
|
formaction="/api/library/memories/{{ memory.id }}/delete"
|
|
data-confirm-button="{{ t('Forget this?') }}"
|
|
data-confirm-title="{{ t('Forget') }}"
|
|
aria-label="{{ t('Forget this') }}" title="{{ t('Forget this') }}">
|
|
{{ icon("trash", "icon--sm") }}
|
|
</button>
|
|
</form>
|
|
{% if memory.author == "model" %}
|
|
<span class="badge badge--leaf">{{ t("remembered") }}</span>
|
|
{% endif %}
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<p class="muted text-sm">{{ t("Nothing yet. A model with the memory tool adds to this when you tell it something worth keeping.") }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Add one") }}</h2>
|
|
<form method="post" action="/api/library/memories" class="row"
|
|
style="gap: var(--sp-2)">
|
|
<input class="input" name="content" required style="flex: 1"
|
|
maxlength="{{ memory_limit }}"
|
|
aria-label="{{ t('Something worth remembering') }}"
|
|
placeholder="{{ t('Prefers metric units and a 24-hour clock.') }}">
|
|
{% if several_groups %}
|
|
<select class="select" name="data_group_id" aria-label="{{ t('Data group') }}"
|
|
style="flex: none">
|
|
{% for group in data_groups %}
|
|
<option value="{{ group.id }}">{{ group.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
<button class="btn btn--primary" type="submit">{{ t("Remember") }}</button>
|
|
</form>
|
|
<p class="field__hint">
|
|
One fact per record, at most {{ memory_limit }} characters. Never
|
|
put a password or a key here — it is sent to the model with every
|
|
message.
|
|
</p>
|
|
</div>
|
|
|
|
{# Both halves are shown whether or not any model may still write
|
|
one: a model whose permission was taken away has not forgotten, and
|
|
this is the only place either text can be read or removed. #}
|
|
{% if personalities %}
|
|
<div class="card">
|
|
<h2 class="card__title">
|
|
Who each model is with you
|
|
<span class="badge">{{ personalities|length }}</span>
|
|
</h2>
|
|
<p class="card__lede">{{ t("A model's character is something it works out with a particular person, so this is yours — somebody else talking to the same model is talking to a different one, and neither of you can see the other's. Delete one and that model starts again from the default its administrator wrote.") }}</p>
|
|
<ul class="model-list">
|
|
{% for personality in personalities %}
|
|
<li class="model-list__item">
|
|
<div style="min-width: 0">
|
|
{% set key = split_key(personality.model_key) %}
|
|
<strong>{{ key[0] }}</strong>
|
|
{% if several_groups %}<span class="badge">{{ group_names.get(key[1], key[1]) }}</span>{% endif %}
|
|
{% if personality.author == "model" %}
|
|
<span class="badge badge--leaf">{{ t("its own words") }}</span>
|
|
{% endif %}
|
|
<div class="text-sm">{{ personality.content }}</div>
|
|
{% if personality.revisions %}
|
|
<details class="text-xs faint">
|
|
<summary>{{ personality.revisions|length }} earlier version(s)</summary>
|
|
<ul>
|
|
{% for revision in personality.revisions %}
|
|
<li>
|
|
{{ revision.created_at.strftime("%Y-%m-%d %H:%M") }} —
|
|
{{ revision.content }}
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</details>
|
|
{% endif %}
|
|
</div>
|
|
<form method="post"
|
|
action="/api/library/personalities/{{ personality.id }}/delete">
|
|
<button class="btn btn--sm btn--danger" type="submit"
|
|
data-confirm-button="{{ t('Reset this model\'s personality with you?') }}"
|
|
data-confirm-title="{{ t('Reset') }}"
|
|
aria-label="{{ t('Reset this') }}" title="{{ t('Reset this') }}">
|
|
{{ icon("trash", "icon--sm") }}
|
|
</button>
|
|
</form>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if impressions %}
|
|
<div class="card">
|
|
<h2 class="card__title">
|
|
What models make of you
|
|
<span class="badge">{{ impressions|length }}</span>
|
|
</h2>
|
|
<p class="card__lede">{{ t("Each model's own impression of how you work, kept by that model and read back to it in every conversation. Opinions rather than facts, and each one is that model's alone — the others cannot see it, and neither can anybody else. Delete any of them; it will form another if it has reason to.") }}</p>
|
|
<ul class="model-list">
|
|
{% for impression in impressions %}
|
|
<li class="model-list__item">
|
|
<div style="min-width: 0">
|
|
{% set key = split_key(impression.model_key) %}
|
|
<strong>{{ key[0] }}</strong>
|
|
{% if several_groups %}<span class="badge">{{ group_names.get(key[1], key[1]) }}</span>{% endif %}
|
|
<div class="text-sm">{{ impression.content }}</div>
|
|
</div>
|
|
<form method="post"
|
|
action="/api/library/impressions/{{ impression.id }}/delete">
|
|
<button class="btn btn--sm btn--danger" type="submit"
|
|
data-confirm-button="{{ t('Delete what this model makes of you?') }}"
|
|
data-confirm-title="{{ t('Delete') }}"
|
|
aria-label="{{ t('Delete this') }}" title="{{ t('Delete this') }}">
|
|
{{ icon("trash", "icon--sm") }}
|
|
</button>
|
|
</form>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# --- Data groups --- #}
|
|
{#
|
|
Which provider may read which of this person's data. Shown to anybody
|
|
once there is more than one group, because "which provider can read
|
|
my notes?" is a question anybody may ask; editable only with
|
|
`data.manage`, because the answer changes what a provider can see.
|
|
#}
|
|
{% if several_groups or may_manage_groups %}
|
|
<section class="tabs__panel" data-tab="tab-data">
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Your data groups") }}</h2>
|
|
<p class="card__lede">{{ t("A model reads only the memories, notes, skills, knowledge, reports and personality of the data group its connection is in, and a chat stays in the group it was started in. Two providers in different groups never see each other's.") }}</p>
|
|
<ul class="model-list">
|
|
{% for group in data_groups %}
|
|
<li class="model-list__item">
|
|
<div style="min-width: 0">
|
|
<strong>{{ group.name }}</strong>
|
|
{% if group.personal %}<span class="badge">{{ t("yours") }}</span>{% endif %}
|
|
<div class="text-xs faint">
|
|
{% for label, n in group_counts[group.id].items() %}{{ n }} {{ group_labels.get(label, label) }}{% if not loop.last %} · {% endif %}{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% if group.personal and may_manage_groups %}
|
|
<form method="post" action="/api/preferences/data-groups/{{ group.id }}/delete">
|
|
<button class="btn btn--sm btn--danger" type="submit"
|
|
data-confirm-button="{{ t('Delete the data group “%(name)s”?', name=group.name) }}"
|
|
aria-label="{{ t('Delete this') }}" title="{{ t('Delete this') }}">
|
|
{{ icon("trash", "icon--sm") }}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% if may_manage_groups %}
|
|
<form method="post" action="/api/preferences/data-groups/new" class="row"
|
|
style="gap: var(--sp-2); margin-top: var(--sp-4)">
|
|
<input class="input" name="name" required maxlength="120" style="flex: 1"
|
|
aria-label="{{ t('New data group name') }}"
|
|
placeholder="{{ t('New data group name') }}">
|
|
<button class="btn" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Create") }}</button>
|
|
</form>
|
|
<p class="field__hint">{{ t("A group of your own, for keeping one provider away from the rest of your data. Nobody else can see it.") }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Which group each connection reads") }}</h2>
|
|
{% if may_manage_groups %}
|
|
<p class="card__lede">{{ t("Your choice applies to you alone. Following the instance keeps up with whatever the administrator sets. Moving a connection moves no data: from then on it reads the other group, and your chats on it that were started in the old group can no longer use it.") }}</p>
|
|
<form method="post" action="/api/preferences/data-groups" class="form-grid">
|
|
{% for row in group_connections %}
|
|
<div class="field">
|
|
<label class="field__label" for="dg-{{ row.connection.id }}">{{ row.connection.name }}</label>
|
|
<select class="select" id="dg-{{ row.connection.id }}" name="group__{{ row.connection.id }}">
|
|
<option value="">{{ t("Follow the instance (%(group)s)", group=group_names.get(row.instance, '')) }}</option>
|
|
{% for group in data_groups %}
|
|
<option value="{{ group.id }}" {{ 'selected' if row.chosen == group.id }}>{{ group.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p class="field__hint">{{ t("Reads: %(group)s", group=group_names.get(row.in_force, '')) }}</p>
|
|
</div>
|
|
{% endfor %}
|
|
<div class="btn-row">
|
|
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
|
</div>
|
|
</form>
|
|
{% else %}
|
|
<p class="card__lede">{{ t("Set by the administrator. Changing it for yourself needs the permission to manage your own data groups.") }}</p>
|
|
<ul class="model-list">
|
|
{% for row in group_connections %}
|
|
<li class="model-list__item">
|
|
<strong>{{ row.connection.name }}</strong>
|
|
<span class="badge">{{ group_names.get(row.in_force, '') }}</span>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% endif %}
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# --- Security --- #}
|
|
<section class="tabs__panel" data-tab="tab-security">
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Change password") }}</h2>
|
|
<p class="card__lede">{{ t("Every other session is signed out when the password changes. You stay signed in here.") }}</p>
|
|
<form method="post" action="/api/preferences/password">
|
|
<div class="grid grid--2">
|
|
<div class="field">
|
|
<label class="field__label" for="current-password">{{ t("Current password") }}</label>
|
|
<input class="input" type="password" id="current-password"
|
|
name="current_password" required autocomplete="current-password">
|
|
</div>
|
|
<div class="field"></div>
|
|
<div class="field">
|
|
<label class="field__label" for="new-password">{{ t("New password") }}</label>
|
|
<input class="input" type="password" id="new-password" name="new_password"
|
|
required minlength="8" autocomplete="new-password">
|
|
<p class="field__hint">{{ t("At least 8 characters.") }}</p>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="confirm-password">{{ t("Confirm new password") }}</label>
|
|
<input class="input" type="password" id="confirm-password"
|
|
name="confirm_password" required minlength="8"
|
|
autocomplete="new-password">
|
|
</div>
|
|
</div>
|
|
<button class="btn btn--primary" type="submit">{{ t("Change password") }}</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">{{ t("Session") }}</h2>
|
|
<form method="post" action="/auth/logout">
|
|
<button class="btn btn--danger" type="submit">
|
|
{{ icon("logout", "icon--sm") }} Sign out
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
{% endblock %}
|