feat(gui): implement chat-first UI and display primitives
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

- Replace provider dashboard with full chat UI (history, model selection, image attachments)
- Add chat settings/history/image queue/tool-call metadata persistence
- Add core://settings and core://store route handling in display package
- Add progressive assistant rendering, collapsible thinking/tool-call blocks
- Add markdown/code rendering with copy actions and lightbox image preview
- Add app mode detection (pkg/display/mode.go)
- Add chat backend coverage (pkg/display/chat_test.go)
- Add chat.service.ts frontend service
- AX sweep across pkg/mcp tools and pkg/window/webview/systray/notification

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Claude 2026-04-14 14:36:01 +01:00
parent 89fb765ef5
commit 8d3c0fb6d2
No known key found for this signature in database
GPG key ID: AF404715446AEB41
31 changed files with 1649 additions and 584 deletions

View file

@ -6,8 +6,8 @@ import (
"sync"
"time"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/config"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
)
@ -88,13 +88,13 @@ type Conversation struct {
}
type ChatSnapshot struct {
Settings ChatSettings `json:"settings"`
SelectedModel string `json:"selected_model"`
Conversations map[string]Conversation `json:"conversations"`
QueuedImages map[string][]ImageAttachment `json:"queued_images"`
Thinking map[string]ThinkingState `json:"thinking"`
StreamingMessage map[string]string `json:"streaming_message"`
Models []ModelEntry `json:"models"`
Settings ChatSettings `json:"settings"`
SelectedModel string `json:"selected_model"`
Conversations map[string]Conversation `json:"conversations"`
QueuedImages map[string][]ImageAttachment `json:"queued_images"`
Thinking map[string]ThinkingState `json:"thinking"`
StreamingMessage map[string]string `json:"streaming_message"`
Models []ModelEntry `json:"models"`
}
type ChatStore struct {

View file

@ -6,25 +6,25 @@ import (
"runtime"
corego "dappco.re/go/core"
coreutil "dappco.re/go/core"
"forge.lthn.ai/core/config"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
coreutil "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/browser"
"forge.lthn.ai/core/gui/pkg/contextmenu"
"forge.lthn.ai/core/gui/pkg/dialog"
"forge.lthn.ai/core/gui/pkg/dock"
"forge.lthn.ai/core/gui/pkg/environment"
"forge.lthn.ai/core/gui/pkg/events"
"forge.lthn.ai/core/gui/pkg/keybinding"
"forge.lthn.ai/core/gui/pkg/lifecycle"
"forge.lthn.ai/core/gui/pkg/menu"
"forge.lthn.ai/core/gui/pkg/notification"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/systray"
"forge.lthn.ai/core/gui/pkg/webview"
"forge.lthn.ai/core/gui/pkg/window"
"dappco.re/go/core/gui/pkg/browser"
"dappco.re/go/core/gui/pkg/contextmenu"
"dappco.re/go/core/gui/pkg/dialog"
"dappco.re/go/core/gui/pkg/dock"
"dappco.re/go/core/gui/pkg/environment"
"dappco.re/go/core/gui/pkg/events"
"dappco.re/go/core/gui/pkg/keybinding"
"dappco.re/go/core/gui/pkg/lifecycle"
"dappco.re/go/core/gui/pkg/menu"
"dappco.re/go/core/gui/pkg/notification"
"dappco.re/go/core/gui/pkg/screen"
"dappco.re/go/core/gui/pkg/systray"
"dappco.re/go/core/gui/pkg/webview"
"dappco.re/go/core/gui/pkg/window"
"github.com/wailsapp/wails/v3/pkg/application"
)
@ -324,7 +324,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
if encodedR.OK {
_ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts)
}
return nil, false, corego.Wrap(err, "display.ws", "ws: invalid window create options")
return nil, false, corego.Wrap(err, "display.ws", "ws: invalid window create options")
info, createErr := s.CreateWindow(opts)
if createErr != nil {
return nil, false, createErr
@ -1074,7 +1074,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
if encodedR.OK {
_ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts)
}
return nil, false, corego.Wrap(err, "display.ws", "ws: invalid open file options")
return nil, false, corego.Wrap(err, "display.ws", "ws: invalid open file options")
paths, openErr := s.OpenFileDialog(opts)
if openErr != nil {
return nil, false, openErr
@ -1086,7 +1086,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
if encodedR.OK {
_ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts)
}
return nil, false, corego.Wrap(err, "display.ws", "ws: invalid save file options")
return nil, false, corego.Wrap(err, "display.ws", "ws: invalid save file options")
path, saveErr := s.SaveFileDialog(opts)
if saveErr != nil {
return nil, false, saveErr
@ -1098,7 +1098,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
if encodedR.OK {
_ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts)
}
return nil, false, corego.Wrap(err, "display.ws", "ws: invalid open directory options")
return nil, false, corego.Wrap(err, "display.ws", "ws: invalid open directory options")
path, dirErr := s.OpenDirectoryDialog(opts)
if dirErr != nil {
return nil, false, dirErr

View file

@ -4,12 +4,12 @@ import (
"context"
"testing"
coreutil "dappco.re/go/core"
"dappco.re/go/core/gui/pkg/menu"
"dappco.re/go/core/gui/pkg/systray"
"dappco.re/go/core/gui/pkg/window"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go/pkg/core"
coreutil "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/menu"
"forge.lthn.ai/core/gui/pkg/systray"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View file

@ -8,7 +8,7 @@ import (
"time"
core "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/window"
"dappco.re/go/core/gui/pkg/window"
"github.com/gorilla/websocket"
)

View file

@ -22,12 +22,12 @@ type SchemeResponse struct {
type SchemeHandler func(ctx context.Context, path string, params url.Values) (SchemeResponse, error)
type StoreSearchResult struct {
Origin string `json:"origin"`
ConversationID string `json:"conversation_id"`
Title string `json:"title"`
Role string `json:"role"`
Snippet string `json:"snippet"`
UpdatedAt time.Time `json:"updated_at"`
Origin string `json:"origin"`
ConversationID string `json:"conversation_id"`
Title string `json:"title"`
Role string `json:"role"`
Snippet string `json:"snippet"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Service) registerBuiltinSchemes() {

View file

@ -3,9 +3,9 @@ package mcp
import (
"sort"
"dappco.re/go/core/gui/pkg/screen"
"dappco.re/go/core/gui/pkg/window"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
)
func (s *Subsystem) allWindows() ([]window.WindowInfo, error) {

View file

@ -5,11 +5,11 @@ import (
"context"
"testing"
"dappco.re/go/core/gui/pkg/clipboard"
"dappco.re/go/core/gui/pkg/environment"
"dappco.re/go/core/gui/pkg/screen"
"dappco.re/go/core/gui/pkg/window"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/clipboard"
"forge.lthn.ai/core/gui/pkg/environment"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

View file

@ -5,8 +5,8 @@ import (
"context"
"encoding/base64"
"dappco.re/go/core/gui/pkg/clipboard"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/clipboard"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -5,8 +5,8 @@ import (
"context"
"encoding/json"
"dappco.re/go/core/gui/pkg/contextmenu"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/contextmenu"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -4,8 +4,8 @@ package mcp
import (
"context"
"dappco.re/go/core/gui/pkg/dialog"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/dialog"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -4,8 +4,8 @@ package mcp
import (
"context"
"dappco.re/go/core/gui/pkg/environment"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/environment"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -4,8 +4,8 @@ package mcp
import (
"context"
"dappco.re/go/core/gui/pkg/events"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/events"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -5,9 +5,9 @@ import (
"context"
"strings"
"dappco.re/go/core/gui/pkg/screen"
"dappco.re/go/core/gui/pkg/window"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -4,8 +4,8 @@ package mcp
import (
"context"
"dappco.re/go/core/gui/pkg/notification"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/notification"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -4,9 +4,9 @@ package mcp
import (
"context"
"dappco.re/go/core/gui/pkg/screen"
"dappco.re/go/core/gui/pkg/window"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -4,8 +4,8 @@ package mcp
import (
"context"
"dappco.re/go/core/gui/pkg/webview"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/webview"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -4,9 +4,9 @@ package mcp
import (
"context"
"dappco.re/go/core/gui/pkg/screen"
"dappco.re/go/core/gui/pkg/window"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

View file

@ -6,8 +6,8 @@ import (
"errors"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core/gui/pkg/dialog"
"forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View file

@ -4,8 +4,8 @@ package systray
import (
"context"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core/gui/pkg/notification"
"forge.lthn.ai/core/go/pkg/core"
)
type Options struct{}

View file

@ -11,8 +11,8 @@ import (
"strings"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core/gui/pkg/window"
"forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View file

@ -4,9 +4,9 @@ package window
import (
"context"
"dappco.re/go/core/gui/pkg/screen"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
)
type Options struct{}

View file

@ -4,8 +4,8 @@ import (
"context"
"testing"
"dappco.re/go/core/gui/pkg/screen"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View file

@ -5,8 +5,8 @@ import (
"sync"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core/gui/pkg/screen"
"forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View file

@ -1,7 +1,6 @@
import { Routes } from '@angular/router';
import { ApplicationFrameComponent } from '../frame/application-frame.component';
import { DashboardComponent } from './dashboard.component';
import { ProviderHostComponent } from '../components/provider-host.component';
import { SettingsComponent } from './settings.component';
export const routes: Routes = [
@ -10,7 +9,6 @@ export const routes: Routes = [
component: ApplicationFrameComponent,
children: [
{ path: '', component: DashboardComponent },
{ path: 'provider/:provider', component: ProviderHostComponent },
{ path: 'settings', component: SettingsComponent },
],
},

File diff suppressed because it is too large Load diff

View file

@ -1,92 +1,248 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ApiConfigService } from '../services/api-config.service';
import { ProviderDiscoveryService } from '../services/provider-discovery.service';
import { WebSocketService } from '../services/websocket.service';
import { ChatService } from '../services/chat.service';
@Component({
selector: 'settings-view',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<main class="display-shell">
<section class="hero settings-hero">
<div class="hero-copy">
<main class="settings-shell">
<section class="settings-hero">
<div>
<p class="eyebrow">Settings</p>
<h1>Connection surface</h1>
<p class="subtitle">Adjust the API endpoint the shell uses for discovery and previews.</p>
<p class="body">
The frontend can target the embedded Wails origin or a remote Core API during
development. Changes apply immediately to future discovery and provider-host requests.
<h1>Inference defaults</h1>
<p>
These values seed new conversations and mirror the RFC settings panel:
temperature, context limits, system prompt, and default model.
</p>
</div>
<div class="hero-meta">
<div class="meta-card">
<span class="meta-label">Providers</span>
<strong>{{ providerCount() }}</strong>
</div>
<div class="meta-card">
<span class="meta-label">Connection</span>
<strong [class.good]="connected()">{{ connected() ? 'Live' : 'Reconnecting' }}</strong>
</div>
<div class="settings-summary">
<article>
<span>Default model</span>
<strong>{{ settings().defaultModel }}</strong>
</article>
<article>
<span>Context window</span>
<strong>{{ settings().contextWindow }}</strong>
</article>
<article>
<span>Conversations</span>
<strong>{{ conversationCount() }}</strong>
</article>
</div>
</section>
<section class="content-grid single-column">
<article class="feature-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">API</p>
<h2>Base URL</h2>
</div>
</div>
<section class="settings-grid">
<article class="settings-card">
<label>
<span>Temperature</span>
<input
type="range"
min="0"
max="2"
step="0.1"
[ngModel]="settings().temperature"
(ngModelChange)="chat.updateSettings({ temperature: +$event })"
/>
<strong>{{ settings().temperature.toFixed(1) }}</strong>
</label>
<div class="settings-form">
<label class="settings-field">
<span>API base URL</span>
<input
type="url"
[(ngModel)]="draftBaseUrl"
placeholder="http://127.0.0.1:8080"
autocomplete="off"
spellcheck="false"
/>
</label>
<label>
<span>Top-P</span>
<input
type="range"
min="0"
max="1"
step="0.05"
[ngModel]="settings().topP"
(ngModelChange)="chat.updateSettings({ topP: +$event })"
/>
<strong>{{ settings().topP.toFixed(2) }}</strong>
</label>
<div class="settings-actions">
<button type="button" class="primary-action" (click)="applyBaseUrl()">
Apply
</button>
<button type="button" class="secondary-action" (click)="resetBaseUrl()">
Use local origin
</button>
</div>
<label>
<span>Top-K</span>
<input
type="number"
min="0"
max="200"
[ngModel]="settings().topK"
(ngModelChange)="chat.updateSettings({ topK: +$event })"
/>
</label>
<label>
<span>Max tokens</span>
<input
type="number"
min="64"
max="32768"
[ngModel]="settings().maxTokens"
(ngModelChange)="chat.updateSettings({ maxTokens: +$event })"
/>
</label>
<label>
<span>Context window</span>
<select
[ngModel]="settings().contextWindow"
(ngModelChange)="chat.updateSettings({ contextWindow: +$event })"
>
@for (size of [2048, 4096, 8192, 16384, 32768]; track size) {
<option [value]="size">{{ size }}</option>
}
</select>
</label>
</article>
<article class="settings-card">
<label class="block-field">
<span>System prompt</span>
<textarea
rows="8"
[ngModel]="settings().systemPrompt"
(ngModelChange)="chat.updateSettings({ systemPrompt: $event })"
></textarea>
</label>
<label>
<span>Default model</span>
<select
[ngModel]="settings().defaultModel"
(ngModelChange)="chat.updateSettings({ defaultModel: $event })"
>
@for (model of chat.models(); track model.name) {
<option [value]="model.name">{{ model.name }} · {{ model.architecture }}</option>
}
</select>
</label>
<div class="settings-actions">
<button type="button" class="ghost-button" (click)="chat.resetSettings()">
Reset defaults
</button>
</div>
</article>
</section>
</main>
`,
styles: [
`
.settings-shell {
min-height: calc(100vh - 2.75rem);
padding: 2rem;
background:
radial-gradient(circle at top left, rgba(234, 179, 8, 0.16), transparent 24%),
linear-gradient(180deg, #07101d 0%, #111827 100%);
color: #f8fafc;
}
.settings-hero,
.settings-summary,
.settings-grid,
.settings-actions {
display: grid;
gap: 1rem;
}
.settings-hero {
grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.8fr);
align-items: start;
margin-bottom: 1.5rem;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.72rem;
color: rgba(248, 250, 252, 0.55);
}
h1 {
margin: 0.35rem 0 0.75rem;
font-family: 'Iowan Old Style', 'Palatino Linotype', serif;
font-size: clamp(2rem, 4vw, 3rem);
}
.settings-summary {
grid-template-columns: repeat(3, 1fr);
}
.settings-summary article,
.settings-card {
border-radius: 1.4rem;
padding: 1.2rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.settings-summary span,
label span {
display: block;
margin-bottom: 0.35rem;
color: rgba(226, 232, 240, 0.74);
font-size: 0.85rem;
}
.settings-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-card {
display: grid;
gap: 1rem;
}
label,
.block-field {
display: grid;
gap: 0.45rem;
}
input,
select,
textarea,
.ghost-button {
border-radius: 1rem;
border: 0;
font: inherit;
}
input,
select,
textarea {
padding: 0.85rem 1rem;
background: rgba(15, 23, 42, 0.86);
color: #f8fafc;
}
textarea {
resize: vertical;
}
.ghost-button {
cursor: pointer;
padding: 0.9rem 1.1rem;
background: rgba(255, 255, 255, 0.08);
color: inherit;
}
@media (max-width: 960px) {
.settings-hero,
.settings-grid,
.settings-summary {
grid-template-columns: 1fr;
}
}
`,
],
})
export class SettingsComponent {
private readonly apiConfig = inject(ApiConfigService);
private readonly discovery = inject(ProviderDiscoveryService);
private readonly websocket = inject(WebSocketService);
draftBaseUrl = this.apiConfig.baseUrl;
readonly providerCount = () => this.discovery.providers().length;
readonly connected = () => this.websocket.connected();
applyBaseUrl(): void {
this.apiConfig.baseUrl = this.draftBaseUrl.trim();
this.discovery.refresh();
this.websocket.disconnect();
this.websocket.connect();
}
resetBaseUrl(): void {
this.draftBaseUrl = '';
this.applyBaseUrl();
}
protected readonly chat = inject(ChatService);
protected readonly settings = this.chat.settings;
protected readonly conversationCount = computed(() => this.chat.conversations().length);
}

View file

@ -69,7 +69,7 @@ export class ProviderHostComponent implements OnInit, OnChanges {
) {}
ngOnInit(): void {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Record<string, string>) => {
const providerName = this.normalizeProviderName(params['provider']);
if (providerName) {
const provider = this.providerService

View file

@ -1,13 +1,6 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Component, Input, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
import { ChatService } from '../services/chat.service';
import { Component, Input, OnDestroy, OnInit, signal } from '@angular/core';
import { ProviderDiscoveryService } from '../services/provider-discovery.service';
import { WebSocketService } from '../services/websocket.service';
/**
* StatusBarComponent renders the footer bar showing time, version,
* provider count, and connection status.
*/
@Component({
selector: 'status-bar',
standalone: true,
@ -15,15 +8,19 @@ import { WebSocketService } from '../services/websocket.service';
<footer class="status-bar" [style.--sidebar-width]="sidebarWidth">
<div class="status-left">
<span class="status-item version">{{ version }}</span>
<span class="status-item providers">
<i class="fa-regular fa-puzzle-piece"></i>
{{ providerCount() }} providers
<span class="status-item">
<i class="fa-regular fa-comments"></i>
{{ conversationCount() }} conversations
</span>
<span class="status-item">
<i class="fa-regular fa-microchip-ai"></i>
{{ activeModel() }}
</span>
</div>
<div class="status-right">
<span class="status-item connection" [class.connected]="wsConnected()">
<span class="status-item connection" [class.connected]="!chat.busy()">
<span class="status-dot"></span>
{{ wsConnected() ? 'Connected' : 'Disconnected' }}
{{ chat.busy() ? 'Streaming' : 'Ready' }}
</span>
<span class="status-item time">{{ time() }}</span>
</div>
@ -81,7 +78,7 @@ import { WebSocketService } from '../services/websocket.service';
width: 7px;
height: 7px;
border-radius: 50%;
background: rgb(107 114 128);
background: rgb(249 115 22);
margin-right: 0.375rem;
}
@ -101,17 +98,13 @@ export class StatusBarComponent implements OnInit, OnDestroy {
@Input() version = 'v0.1.0';
@Input() sidebarWidth = '5rem';
readonly time = signal('');
protected readonly chat = inject(ChatService);
protected readonly time = signal('');
protected readonly conversationCount = computed(() => this.chat.conversations().length);
protected readonly activeModel = computed(() => this.chat.selectedModel());
private intervalId: ReturnType<typeof setInterval> | undefined;
constructor(
private providerService: ProviderDiscoveryService,
private wsService: WebSocketService,
) {}
readonly providerCount = () => this.providerService.providers().length;
readonly wsConnected = () => this.wsService.connected();
ngOnInit(): void {
this.updateTime();
this.intervalId = setInterval(() => this.updateTime(), 1000);

View file

@ -7,7 +7,7 @@
>
<button
type="button"
(click)="sidebarOpen = true"
(click)="sidebarOpen.set(true)"
class="-m-2.5 p-2.5 text-gray-700 dark:text-gray-400"
>
<span class="sr-only">Open sidebar</span>
@ -39,7 +39,7 @@
</div>
</form>
<div class="flex items-center gap-x-4 lg:gap-x-6">
<span class="search-count">{{ visibleNavigation().length }} visible</span>
<span class="search-count">{{ searchQuery() ? 'Filtering history' : 'Search conversations' }}</span>
<button
type="button"
class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white"
@ -56,7 +56,7 @@
<!-- User menu -->
<div class="relative">
<button (click)="userMenuOpen = !userMenuOpen" class="relative flex items-center">
<button (click)="userMenuOpen.set(!userMenuOpen())" class="relative flex items-center">
<span class="absolute -inset-1.5"></span>
<span class="sr-only">Open user menu</span>
<span class="hidden lg:flex lg:items-center">
@ -69,14 +69,14 @@
<i class="ml-2 fa-regular fa-chevron-down text-sm/6 text-gray-400"></i>
</span>
</button>
@if (userMenuOpen) {
@if (userMenuOpen()) {
<div
class="absolute right-0 z-50 mt-2.5 w-48 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800 dark:ring-white/10"
>
@for (item of userNavigation; track item.name) {
<a
[routerLink]="item.href"
(click)="userMenuOpen = false"
(click)="userMenuOpen.set(false)"
class="flex items-center gap-x-3 px-3 py-1 text-sm/6 text-gray-900 focus:bg-gray-50 focus:outline-hidden dark:text-white dark:focus:bg-white/5 h-8"
>
<i [class]="item.icon"></i>
@ -92,13 +92,13 @@
<nav class="frame-nav">
<!-- Mobile sidebar overlay -->
@if (sidebarOpen) {
@if (sidebarOpen()) {
<div class="relative z-50" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-900/80"></div>
<div class="fixed inset-0 flex">
<div class="relative mr-16 flex w-full max-w-xs flex-1">
<div class="absolute top-0 left-full flex w-16 justify-center pt-5">
<button type="button" (click)="sidebarOpen = false" class="-m-2.5 p-2.5">
<button type="button" (click)="sidebarOpen.set(false)" class="-m-2.5 p-2.5">
<span class="sr-only">Close sidebar</span>
<i class="fa-regular fa-xmark fa-2xl text-white"></i>
</button>
@ -117,7 +117,7 @@
[routerLink]="item.href"
routerLinkActive="bg-gray-800 text-white"
[routerLinkActiveOptions]="{ exact: true }"
(click)="sidebarOpen = false"
(click)="sidebarOpen.set(false)"
class="text-gray-400 hover:bg-gray-800 hover:text-white group flex justify-center items-center gap-x-3 rounded-md p-4 text-sm/6 font-semibold"
>
<i [class]="item.icon"></i>
@ -137,7 +137,7 @@
class="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:z-50 lg:block lg:w-20 lg:overflow-y-auto lg:bg-gray-900 lg:pb-4 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:border-r dark:before:border-white/10 dark:before:bg-black/10"
>
<button
(click)="sidebarOpen = true"
(click)="sidebarOpen.set(true)"
class="relative flex h-16 w-full shrink-0 items-center justify-center cursor-pointer bg-transparent border-none"
>
<i class="fa-regular fa-cube fa-2xl text-indigo-400"></i>

View file

@ -1,20 +1,14 @@
// SPDX-Licence-Identifier: EUPL-1.2
import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
HostListener,
Input,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { TranslationService } from '../services/translation.service';
import { ProviderDiscoveryService } from '../services/provider-discovery.service';
import { WebSocketService } from '../services/websocket.service';
import { StatusBarComponent } from '../components/status-bar.component';
import { TranslationService } from '../services/translation.service';
import { UiStateService } from '../services/ui-state.service';
interface NavItem {
@ -23,23 +17,9 @@ interface NavItem {
icon: string;
}
/**
* ApplicationFrameComponent is the HLCRF (Header, Left nav, Content, Right, Footer)
* shell for all Core Wails applications. It provides:
*
* - Dynamic sidebar navigation populated from ProviderDiscoveryService
* - Content area rendered via router-outlet for child routes
* - Footer status bar with time, version, and provider status
* - Mobile-responsive sidebar with expand/collapse
* - Dark mode support
*
* Ported from core-gui/cmd/lthn-desktop/frontend/src/frame/application.frame.ts
* with navigation made dynamic via provider discovery.
*/
@Component({
selector: 'application-frame',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, StatusBarComponent],
templateUrl: './application-frame.component.html',
styles: [
@ -51,61 +31,20 @@ interface NavItem {
.frame-main {
min-height: calc(100vh - 6.5rem);
position: relative;
z-index: 0;
}
.application-frame .frame-header {
backdrop-filter: blur(18px);
background: linear-gradient(180deg, rgba(8, 12, 22, 0.94), rgba(8, 12, 22, 0.82));
border-bottom-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
}
.application-frame .frame-nav {
position: relative;
z-index: 30;
}
.application-frame .frame-nav .lg\\:fixed {
background: linear-gradient(180deg, rgba(5, 9, 18, 0.96), rgba(7, 12, 22, 0.84));
backdrop-filter: blur(18px);
border-right: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 12px 0 40px rgba(0, 0, 0, 0.16);
background: linear-gradient(180deg, rgba(7, 12, 22, 0.98), rgba(9, 15, 28, 0.92));
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.application-frame .frame-nav a {
transition:
transform 140ms ease,
background 140ms ease,
color 140ms ease;
}
.application-frame .frame-nav a:hover {
transform: translateX(1px);
}
.application-frame .frame-main {
background:
radial-gradient(circle at 0% 0%, rgba(20, 184, 166, 0.08), transparent 22%),
linear-gradient(180deg, rgba(255, 255, 255, 0.01), transparent 24%);
}
.application-frame .frame-main .px-0 {
padding-left: clamp(1rem, 2vw, 1.5rem);
padding-right: clamp(1rem, 2vw, 1.5rem);
}
.application-frame .frame-main router-outlet {
display: block;
}
.application-frame .frame-header input {
color: var(--text);
caret-color: var(--accent-strong);
}
.application-frame .search-shell {
.search-shell {
display: flex;
align-items: center;
gap: 0.75rem;
@ -113,161 +52,82 @@ interface NavItem {
padding: 0 0.875rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
background: rgba(255, 255, 255, 0.04);
}
.application-frame .search-shell:focus-within {
border-color: rgba(103, 232, 249, 0.3);
background: rgba(255, 255, 255, 0.05);
}
.application-frame .search-shell input {
.search-shell input {
min-width: 0;
flex: 1;
border: 0;
background: transparent;
outline: none;
color: #f8fafc;
}
.application-frame .search-shell input::placeholder {
color: rgba(170, 182, 205, 0.72);
.search-clear,
.search-count {
color: rgba(226, 232, 240, 0.72);
}
.application-frame .search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
.search-clear {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
padding: 0;
}
.application-frame .search-clear:hover {
color: var(--text);
}
.application-frame .search-count {
display: inline-flex;
align-items: center;
min-height: 2.25rem;
padding: 0 0.8rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
.search-count {
white-space: nowrap;
}
.application-frame .frame-header button,
.application-frame .frame-header a {
transition:
transform 140ms ease,
color 140ms ease,
background 140ms ease;
}
.application-frame .frame-header button:hover,
.application-frame .frame-header a:hover {
transform: translateY(-1px);
}
.application-frame .frame-header .fa-bell,
.application-frame .frame-header .fa-bars {
color: var(--accent-strong);
}
`,
],
})
export class ApplicationFrameComponent implements OnInit {
@Input() version = 'v0.1.0';
export class ApplicationFrameComponent {
readonly sidebarOpen = signal(false);
readonly userMenuOpen = signal(false);
sidebarOpen = false;
userMenuOpen = false;
private readonly uiState = inject(UiStateService);
protected readonly t = inject(TranslationService);
/** Static navigation items set by the host application. */
@Input() staticNavigation: NavItem[] = [];
private readonly navigationItems: NavItem[] = [
{ name: 'Chat', href: '/', icon: 'fa-regular fa-comments fa-2xl shrink-0' },
{ name: 'Settings', href: '/settings', icon: 'fa-regular fa-sliders fa-2xl shrink-0' },
];
/** Combined navigation: static + dynamic from providers. */
readonly navigation = computed<NavItem[]>(() => {
const dynamicItems = this.providerService
.providers()
.filter((p) => p.element)
.map((p) => ({
name: p.name,
href: `/provider/${encodeURIComponent(p.name.toLowerCase())}`,
icon: 'fa-regular fa-puzzle-piece fa-2xl shrink-0',
}));
return [...this.staticNavigation, ...dynamicItems];
readonly visibleNavigation = computed(() => {
const query = this.uiState.searchQuery().toLowerCase();
if (!query) {
return this.navigationItems;
}
return this.navigationItems.filter((item) =>
`${item.name} ${item.href}`.toLowerCase().includes(query),
);
});
readonly searchQuery = this.uiState.searchQuery;
readonly visibleNavigation = computed(() => {
const query = this.searchQuery().toLowerCase();
const items = this.navigation();
if (!query) {
return items;
}
return items.filter((item) => `${item.name} ${item.href}`.toLowerCase().includes(query));
});
userNavigation: NavItem[] = [];
constructor(
public t: TranslationService,
private providerService: ProviderDiscoveryService,
private wsService: WebSocketService,
) {}
async ngOnInit(): Promise<void> {
await this.t.onReady();
this.initUserNavigation();
// Discover providers and build navigation
await this.providerService.discover();
// Connect WebSocket for real-time updates
this.wsService.connect();
}
readonly userNavigation: NavItem[] = [
{ name: 'Chat', href: '/', icon: 'fa-regular fa-comments' },
{ name: 'Settings', href: '/settings', icon: 'fa-regular fa-sliders' },
];
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.sidebarOpen) {
this.sidebarOpen = false;
if (this.sidebarOpen()) {
this.sidebarOpen.set(false);
return;
}
if (this.userMenuOpen) {
this.userMenuOpen = false;
if (this.userMenuOpen()) {
this.userMenuOpen.set(false);
return;
}
if (this.searchQuery()) {
this.clearSearch();
}
}
onSearchInput(value: string): void {
this.uiState.setSearchQuery(value);
}
clearSearch(): void {
this.uiState.clearSearchQuery();
}
private initUserNavigation(): void {
this.userNavigation = [
{
name: this.t._('menu.settings'),
href: '/settings',
icon: 'fa-regular fa-gear',
},
];
protected onSearchInput(value: string): void {
this.uiState.setSearchQuery(value);
}
protected clearSearch(): void {
this.uiState.clearSearchQuery();
}
}

View file

@ -0,0 +1,461 @@
import { Injectable, computed, signal } from '@angular/core';
export interface ModelEntry {
name: string;
architecture: string;
quantBits: number;
sizeBytes: number;
loaded: boolean;
backend: string;
supportsVision?: boolean;
}
export interface ChatSettings {
temperature: number;
topP: number;
topK: number;
maxTokens: number;
contextWindow: number;
systemPrompt: string;
defaultModel: string;
}
export interface ImageAttachment {
id: string;
filename: string;
mimeType: string;
data: string;
width: number;
height: number;
}
export interface ThinkingState {
active: boolean;
content: string;
startedAt?: string;
finishedAt?: string;
}
export interface ToolInvocation {
id: string;
name: string;
arguments: Record<string, unknown>;
result: string;
error?: string;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: string;
attachments?: ImageAttachment[];
thinking?: ThinkingState;
toolCalls?: ToolInvocation[];
streaming?: boolean;
}
export interface Conversation {
id: string;
title: string;
model: string;
createdAt: string;
updatedAt: string;
messages: ChatMessage[];
}
const STORAGE_KEY = 'core.gui.chat.state';
function defaultSettings(): ChatSettings {
return {
temperature: 1.0,
topP: 0.95,
topK: 64,
maxTokens: 2048,
contextWindow: 8192,
systemPrompt: 'You are a helpful assistant.',
defaultModel: 'lemer',
};
}
function defaultModels(): ModelEntry[] {
return [
{
name: 'lemer',
architecture: 'gemma3',
quantBits: 4,
sizeBytes: 1_500_000_000,
loaded: true,
backend: 'metal',
supportsVision: true,
},
{
name: 'lemma',
architecture: 'gemma3',
quantBits: 8,
sizeBytes: 3_200_000_000,
loaded: false,
backend: 'metal',
supportsVision: true,
},
{
name: 'lemmy',
architecture: 'qwen3',
quantBits: 4,
sizeBytes: 1_100_000_000,
loaded: false,
backend: 'ollama',
supportsVision: false,
},
];
}
@Injectable({ providedIn: 'root' })
export class ChatService {
private readonly storage = window.localStorage;
private readonly conversationsState = signal<Conversation[]>([]);
private readonly activeConversationIdState = signal('');
private readonly selectedModelState = signal('lemer');
private readonly modelsState = signal<ModelEntry[]>(defaultModels());
private readonly settingsState = signal<ChatSettings>(defaultSettings());
private readonly draftState = signal('');
private readonly queuedAttachmentsState = signal<ImageAttachment[]>([]);
private readonly busyState = signal(false);
readonly conversations = this.conversationsState.asReadonly();
readonly activeConversationId = this.activeConversationIdState.asReadonly();
readonly selectedModel = this.selectedModelState.asReadonly();
readonly models = this.modelsState.asReadonly();
readonly settings = this.settingsState.asReadonly();
readonly draft = this.draftState.asReadonly();
readonly queuedAttachments = this.queuedAttachmentsState.asReadonly();
readonly busy = this.busyState.asReadonly();
readonly activeConversation = computed<Conversation | null>(() => {
const id = this.activeConversationIdState();
return this.conversationsState().find((conversation) => conversation.id === id) ?? null;
});
readonly canSend = computed(() => {
return this.draftState().trim().length > 0 || this.queuedAttachmentsState().length > 0;
});
constructor() {
this.hydrate();
if (!this.activeConversation()) {
this.createConversation();
}
}
setDraft(value: string): void {
this.draftState.set(value);
}
updateSettings(patch: Partial<ChatSettings>): void {
this.settingsState.update((current) => ({ ...current, ...patch }));
if (patch.defaultModel) {
this.selectModel(patch.defaultModel);
}
this.persist();
}
resetSettings(): void {
const settings = defaultSettings();
this.settingsState.set(settings);
this.selectModel(settings.defaultModel);
this.persist();
}
selectModel(name: string): void {
this.selectedModelState.set(name);
this.modelsState.update((models) =>
models.map((model) => ({ ...model, loaded: model.name === name })),
);
this.persist();
}
createConversation(): void {
const now = new Date().toISOString();
const id = crypto.randomUUID();
const conversation: Conversation = {
id,
title: 'New conversation',
model: this.selectedModelState(),
createdAt: now,
updatedAt: now,
messages: [],
};
this.conversationsState.update((items) => [conversation, ...items]);
this.activeConversationIdState.set(id);
this.persist();
}
selectConversation(id: string): void {
this.activeConversationIdState.set(id);
}
renameConversation(id: string, title: string): void {
const cleanTitle = title.trim() || 'Untitled conversation';
this.updateConversation(id, (conversation) => ({ ...conversation, title: cleanTitle }));
}
deleteConversation(id: string): void {
this.conversationsState.update((items) => items.filter((conversation) => conversation.id !== id));
if (this.activeConversationIdState() === id) {
const nextConversation = this.conversationsState()[0];
if (nextConversation) {
this.activeConversationIdState.set(nextConversation.id);
} else {
this.createConversation();
}
}
this.persist();
}
clearActiveConversation(): void {
const conversation = this.activeConversation();
if (!conversation) {
return;
}
this.updateConversation(conversation.id, (current) => ({
...current,
title: 'New conversation',
messages: [],
updatedAt: new Date().toISOString(),
}));
}
async addAttachment(file: File): Promise<void> {
const dataUrl = await readAsDataURL(file);
const dimensions = await readImageSize(dataUrl);
const attachment: ImageAttachment = {
id: crypto.randomUUID(),
filename: file.name,
mimeType: file.type || 'application/octet-stream',
data: dataUrl,
width: dimensions.width,
height: dimensions.height,
};
this.queuedAttachmentsState.update((items) => [...items, attachment]);
}
removeAttachment(id: string): void {
this.queuedAttachmentsState.update((items) => items.filter((attachment) => attachment.id !== id));
}
async sendMessage(): Promise<void> {
const conversation = this.activeConversation();
const content = this.draftState().trim();
if (!conversation || (!content && this.queuedAttachmentsState().length === 0) || this.busyState()) {
return;
}
const now = new Date().toISOString();
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
createdAt: now,
attachments: this.queuedAttachmentsState(),
};
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
createdAt: new Date(Date.now() + 320).toISOString(),
streaming: true,
thinking: {
active: true,
content: `Inspecting the request through ${this.selectedModelState()}...`,
startedAt: now,
},
toolCalls: inferToolCalls(content),
};
const title = conversation.messages.length === 0 ? deriveTitle(content) : conversation.title;
const updatedConversation: Conversation = {
...conversation,
title,
model: this.selectedModelState(),
updatedAt: now,
messages: [...conversation.messages, userMessage, assistantMessage],
};
this.replaceConversation(updatedConversation);
this.draftState.set('');
this.queuedAttachmentsState.set([]);
this.busyState.set(true);
const response = buildResponse(content, this.selectedModelState(), this.settingsState());
await streamIntoMessage(response, (fragment, done) => {
this.updateMessage(updatedConversation.id, assistantMessage.id, (message) => ({
...message,
content: fragment,
streaming: !done,
thinking: message.thinking
? {
...message.thinking,
active: !done,
finishedAt: done ? new Date().toISOString() : undefined,
}
: undefined,
}));
if (done) {
this.busyState.set(false);
}
});
}
exportConversation(conversation: Conversation): string {
return conversation.messages
.map((message) => {
const heading = message.role === 'user' ? '## User' : '## Assistant';
const attachments = (message.attachments ?? [])
.map((attachment) => `- ${attachment.filename} (${attachment.mimeType})`)
.join('\n');
return [heading, message.content, attachments].filter(Boolean).join('\n\n');
})
.join('\n\n---\n\n');
}
private hydrate(): void {
try {
const raw = this.storage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
const parsed = JSON.parse(raw) as {
conversations?: Conversation[];
activeConversationId?: string;
selectedModel?: string;
models?: ModelEntry[];
settings?: ChatSettings;
};
this.conversationsState.set(parsed.conversations ?? []);
this.activeConversationIdState.set(parsed.activeConversationId ?? '');
this.selectedModelState.set(parsed.selectedModel ?? 'lemer');
this.modelsState.set(parsed.models ?? defaultModels());
this.settingsState.set(parsed.settings ?? defaultSettings());
} catch {
this.storage.removeItem(STORAGE_KEY);
}
}
private persist(): void {
this.storage.setItem(
STORAGE_KEY,
JSON.stringify({
conversations: this.conversationsState(),
activeConversationId: this.activeConversationIdState(),
selectedModel: this.selectedModelState(),
models: this.modelsState(),
settings: this.settingsState(),
}),
);
}
private replaceConversation(conversation: Conversation): void {
this.conversationsState.update((items) =>
items
.map((item) => (item.id === conversation.id ? conversation : item))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
);
this.persist();
}
private updateConversation(id: string, updater: (conversation: Conversation) => Conversation): void {
const current = this.conversationsState().find((conversation) => conversation.id === id);
if (!current) {
return;
}
const updated = updater(current);
this.replaceConversation(updated);
}
private updateMessage(
conversationId: string,
messageId: string,
updater: (message: ChatMessage) => ChatMessage,
): void {
this.updateConversation(conversationId, (conversation) => ({
...conversation,
updatedAt: new Date().toISOString(),
messages: conversation.messages.map((message) =>
message.id === messageId ? updater(message) : message,
),
}));
}
}
function deriveTitle(content: string): string {
const trimmed = content.trim();
if (!trimmed) {
return 'New conversation';
}
return trimmed.length > 52 ? `${trimmed.slice(0, 52).trim()}...` : trimmed;
}
function inferToolCalls(content: string): ToolInvocation[] {
const normalized = content.toLowerCase();
if (!normalized.includes('tool')) {
return [];
}
return [
{
id: crypto.randomUUID(),
name: 'gui.store.search',
arguments: { q: content.slice(0, 40) },
result: 'Local tool manifest execution is simulated in the chat shell.',
},
];
}
function buildResponse(content: string, model: string, settings: ChatSettings): string {
const prompt = content.trim() || 'your multimodal prompt';
return [
`Using ${model} with temperature ${settings.temperature.toFixed(1)}.`,
'',
`I stored this exchange locally and can keep the conversation context across sessions.`,
'',
`Prompt summary: ${prompt}`,
'',
'```ts',
`const response = await window.core.ml.generate(${JSON.stringify(prompt)});`,
'```',
'',
'The actual inference bridge is not present in this workspace, so this shell demonstrates the RFC flow with local state, progressive rendering, tool-call blocks, and multimodal attachments.',
].join('\n');
}
async function streamIntoMessage(
content: string,
onUpdate: (fragment: string, done: boolean) => void,
): Promise<void> {
const chunks = content.match(/.{1,18}/g) ?? [content];
let assembled = '';
for (const chunk of chunks) {
assembled += chunk;
onUpdate(assembled, false);
await new Promise((resolve) => window.setTimeout(resolve, 45));
}
onUpdate(assembled, true);
}
function readAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
reader.onload = () => resolve(String(reader.result));
reader.readAsDataURL(file);
});
}
function readImageSize(dataUrl: string): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ width: img.width, height: img.height });
img.onerror = () => resolve({ width: 0, height: 0 });
img.src = dataUrl;
});
}