feat(gui): implement chat-first UI and display primitives
- 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:
parent
89fb765ef5
commit
8d3c0fb6d2
31 changed files with 1649 additions and 584 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
461
ui/src/services/chat.service.ts
Normal file
461
ui/src/services/chat.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue