Major rebranding, i18n, dashboard, shared folders, role capabilities

- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette
  (terracotta accent, --radius tokens, WCAG contrast, reduced-motion),
  updated favicon, tray icon, skaldkonur asset
- i18n: backend crate (i18n.rs, locale column, ui_locale config),
  frontend library (web/lib/i18n.js, I18nMixin, t(key)),
  translation files (web/i18n/), every component wired
- Dashboard: <dashboard-page> replaces old home-page content;
  <app-copilot> becomes the landing page (full/dock layout modes)
- Shared folders: API endpoints (shared_folders.rs), frontend page,
  can_write membership, container mount topology, user_fs routing
- Role capabilities: new db table & authorization seam (data not enums),
  roles.attrs JSON for ui_mode / interface select
- Setup: skald-setup prompts for language + password, sets ui_locale
- General: components migrated to CSS variables, Lit conventions cleanup,
  connectors/catalog/marketplace/approval refactoring
This commit is contained in:
2026-07-18 21:38:42 +01:00
parent 2b35312abd
commit 126886e309
109 changed files with 6228 additions and 1390 deletions
+124 -43
View File
@@ -1,26 +1,29 @@
import { html, nothing } from 'lit';
import { ChatSession } from '../lib/chat-session.js';
import { t, I18nMixin } from '../lib/i18n.js';
import { renderMsg, renderAttachmentChips } from './copilot-render.js';
// Built-in (server-handled) slash commands shown at the top of the composer
// autocomplete. Custom commands (from `commands/<name>/`) are fetched from
// `/api/commands` and appended below.
const SYSTEM_COMMAND_ITEMS = [
{ name: 'help', description: 'Show available commands' },
{ name: 'clear', description: 'Start a new conversation' },
{ name: 'new', description: 'Alias for /clear' },
{ name: 'models', description: 'List available LLM models' },
{ name: 'model', description: 'Select the model for this chat' },
{ name: 'context', description: "Last turn's token usage" },
{ name: 'cost', description: 'Session spend (USD)' },
{ name: 'compact', description: 'Force context compaction' },
{ name: 'resettools', description: 'Remove activated tool groups' },
{ name: 'sethome', description: 'Set web as notification home' },
{ name: 'help', description: () => t('copilot.cmd.help') },
{ name: 'clear', description: () => t('copilot.cmd.clear') },
{ name: 'new', description: () => t('copilot.cmd.new') },
{ name: 'models', description: () => t('copilot.cmd.models') },
{ name: 'model', description: () => t('copilot.cmd.model') },
{ name: 'context', description: () => t('copilot.cmd.context') },
{ name: 'cost', description: () => t('copilot.cmd.cost') },
{ name: 'compact', description: () => t('copilot.cmd.compact') },
{ name: 'resettools', description: () => t('copilot.cmd.resettools') },
{ name: 'sethome', description: () => t('copilot.cmd.sethome') },
];
export class AppCopilot extends ChatSession {
export class AppCopilot extends I18nMixin(ChatSession) {
static properties = {
_collapsed: { state: true },
_mode: { state: true },
_me: { state: true },
_modelOpen: { state: true },
_tabs: { state: true },
_activeSource: { state: true },
@@ -31,6 +34,9 @@ export class AppCopilot extends ChatSession {
constructor() {
super();
this._collapsed = false;
// 'full' fills the workspace (home route), 'dock' is the side panel.
this._mode = 'dock';
this._me = null;
this._modelOpen = false;
this._resizing = false;
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
@@ -41,23 +47,53 @@ export class AppCopilot extends ChatSession {
this._allCommands = null;
// Browser-style tabs: 'General' (the default 'web' source) is always present and
// not closable; project chats are added on demand and addressed by their source.
this._tabs = [{ source: 'web', label: 'General' }];
this._tabs = [{ source: 'web', label: t('chat.tab.general') }];
this._onResizeMove = this._onResizeMove.bind(this);
this._onResizeUp = this._onResizeUp.bind(this);
this._onKeydown = this._onKeydown.bind(this);
this._onKeyup = this._onKeyup.bind(this);
this._onProjectChatOpen = this._onProjectChatOpen.bind(this);
this._onCopilotOpen = this._onCopilotOpen.bind(this);
this._onPageChange = this._onPageChange.bind(this);
}
connectedCallback() {
super.connectedCallback?.();
this._restoreState();
this._loadCommands();
this._loadMe();
// Same element, two layouts: the chat is the home page ('full') and docks
// to the side on every other route — state is never lost, it only resizes.
this._applyMode(this._pageFromHash() === 'home' ? 'full' : 'dock');
window.addEventListener('keydown', this._onKeydown);
window.addEventListener('keyup', this._onKeyup);
window.addEventListener('project-chat-open', this._onProjectChatOpen);
window.addEventListener('copilot-open', this._onCopilotOpen);
window.addEventListener('llm-page-change', this._onPageChange);
}
_pageFromHash() {
const m = location.hash.slice(1).match(/^([^/?]+)/);
const seg = m ? m[1] : '';
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'];
return known.includes(seg) ? seg : 'home';
}
_onPageChange(e) {
this._applyMode(e.detail?.page === 'home' ? 'full' : 'dock');
}
_applyMode(mode) {
if (mode === this._mode && this.getAttribute('mode') === mode) return;
this._mode = mode;
this.setAttribute('mode', mode);
}
async _loadMe() {
try {
const res = await fetch('/api/auth/me');
if (res.ok) this._me = await res.json();
} catch { /* ignore */ }
}
_restoreState() {
@@ -74,6 +110,7 @@ export class AppCopilot extends ChatSession {
window.removeEventListener('keyup', this._onKeyup);
window.removeEventListener('project-chat-open', this._onProjectChatOpen);
window.removeEventListener('copilot-open', this._onCopilotOpen);
window.removeEventListener('llm-page-change', this._onPageChange);
}
_onCopilotOpen() {
@@ -254,36 +291,82 @@ export class AppCopilot extends ChatSession {
// ── Render ────────────────────────────────────────────────────────────────────
_sendSuggestion(text) {
const el = this._inputEl();
if (!el) return;
el.value = text;
this._send();
}
_renderEmptyState() {
// Dock mode keeps the compact greeting bubble; full mode (home) shows the
// welcome hero with a few prompt suggestions to get a conversation going.
if (this._mode !== 'full') {
return html`<div class="copilot-msg assistant">${t('chat.hello')}</div>`;
}
const name = this._me?.display_name || this._me?.username;
const suggestions = [
{ icon: 'bi-stars', text: t('chat.suggest.1') },
{ icon: 'bi-calendar-check', text: t('chat.suggest.2') },
{ icon: 'bi-book', text: t('chat.suggest.3') },
{ icon: 'bi-heart', text: t('chat.suggest.4') },
];
return html`
<div class="chat-hero">
<img class="chat-hero-logo" src="/assets/icons/icon-192.png" alt="" />
<h1 class="chat-hero-title">${name ? t('chat.greeting.named', { name }) : t('chat.greeting')}</h1>
<p class="chat-hero-sub">${t('chat.greeting.sub')}</p>
<div class="chat-suggestions">
${suggestions.map(s => html`
<button class="chat-suggestion" @click=${() => this._sendSuggestion(s.text)}>
<i class="bi ${s.icon}"></i>
<span>${s.text}</span>
</button>
`)}
</div>
</div>
`;
}
render() {
if (this._collapsed) return nothing;
// Collapse applies to the dock only: on the home route the chat IS the page.
if (this._collapsed && this._mode !== 'full') return nothing;
const full = this._mode === 'full';
return html`
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
${!full ? html`
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
` : nothing}
<div class="copilot-header">
<i class="bi bi-stars"></i>
<span>Copilot</span>
<button
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
title="Collapse copilot"
@click=${() => { this._setCollapsed(true); }}
>
<i class="bi bi-chevron-right"></i>
</button>
<span>${t('chat.title')}</span>
<span class="chat-privacy" title=${t('chat.privacy.hint')}>
<i class="bi bi-lock-fill"></i>${t('chat.privacy')}
</span>
${!full ? html`
<button
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
title=${t('chat.collapse')}
@click=${() => { this._setCollapsed(true); }}
>
<i class="bi bi-chevron-right"></i>
</button>
` : nothing}
</div>
${this._tabs.length > 1 ? html`
<div class="copilot-tabs">
${this._tabs.map(t => html`
${this._tabs.map(tab => html`
<div
class="copilot-tab ${t.source === this._source ? 'copilot-tab--active' : ''}"
@click=${() => this._selectTab(t.source)}
title=${t.label}
class="copilot-tab ${tab.source === this._source ? 'copilot-tab--active' : ''}"
@click=${() => this._selectTab(tab.source)}
title=${tab.label}
>
<span class="copilot-tab-label">${t.label}</span>
${t.source !== 'web' ? html`
<button class="copilot-tab-close" title="Close tab"
@click=${e => this._closeTab(t.source, e)}>
<span class="copilot-tab-label">${tab.label}</span>
${tab.source !== 'web' ? html`
<button class="copilot-tab-close" title=${t('chat.close_tab')}
@click=${e => this._closeTab(tab.source, e)}>
<i class="bi bi-x"></i>
</button>
` : nothing}
@@ -293,16 +376,14 @@ export class AppCopilot extends ChatSession {
` : nothing}
<div class="copilot-messages">
${this._messages.length === 0 ? html`
<div class="copilot-msg assistant">
Hello! How can I help you today?
</div>
` : this._messages.map(m => renderMsg(this, m))}
${this._messages.length === 0
? this._renderEmptyState()
: this._messages.map(m => renderMsg(this, m))}
${this._waiting ? html`
<div class="copilot-msg assistant copilot-thinking">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Thinking…
${t('chat.thinking')}
</div>
` : nothing}
</div>
@@ -319,7 +400,7 @@ export class AppCopilot extends ChatSession {
@mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }}
>
<span class="copilot-cmd-name">/${c.name}</span>
<span class="copilot-cmd-desc">${c.description}</span>
<span class="copilot-cmd-desc">${typeof c.description === 'function' ? c.description() : c.description}</span>
</button>
`)}
</div>
@@ -335,7 +416,7 @@ export class AppCopilot extends ChatSession {
<textarea
class="copilot-textarea"
rows="1"
placeholder="Ask the copilot… (Enter to send, Shift+Enter for new line)"
placeholder=${t('chat.placeholder')}
@keydown=${this._composerKeydown}
@input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }}
@paste=${(e) => this._onPaste(e)}
@@ -344,7 +425,7 @@ export class AppCopilot extends ChatSession {
<div class="copilot-toolbar-left">
<button
class="copilot-toolbar-btn"
title="Attach files"
title=${t('chat.attach')}
@click=${() => this.querySelector('.copilot-file-input')?.click()}
><i class="bi bi-paperclip"></i></button>
${this._providers.length > 1 ? html`
@@ -369,7 +450,7 @@ export class AppCopilot extends ChatSession {
` : nothing}
<button
class="copilot-toolbar-btn"
title="New session"
title=${t('chat.new_session')}
@click=${() => this._startNewSession()}
><i class="bi bi-trash"></i></button>
</div>
@@ -377,18 +458,18 @@ export class AppCopilot extends ChatSession {
${this._hasTranscribe ? html`
<button
class="copilot-send-btn ${this._recording ? 'copilot-send-btn--recording' : ''}"
title="${this._recording ? 'Stop recording' : 'Record voice (Ctrl+Space)'}"
title="${this._recording ? t('chat.stop') : t('chat.attach')}"
@click=${() => this._toggleRecording()}
>
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
</button>
` : nothing}
${this._waiting
? html`<button class="copilot-send-btn copilot-send-btn--stop" @click=${() => this._cancel()} title="Stop">
? html`<button class="copilot-send-btn copilot-send-btn--stop" @click=${() => this._cancel()} title=${t('chat.stop')}>
<i class="bi bi-stop-fill"></i>
</button>`
: nothing}
<button class="copilot-send-btn" @click=${() => this._send()} title="Send">
<button class="copilot-send-btn" @click=${() => this._send()} title=${t('chat.send')}>
<i class="bi bi-send-fill"></i>
</button>
</div>