Implement chat UI spec gaps
This commit is contained in:
parent
e0722c3690
commit
50de8fd4e9
5 changed files with 411 additions and 89 deletions
|
|
@ -43,6 +43,7 @@ import { ChatStateService } from './chat-state.service';
|
|||
<chat-model-selector
|
||||
[models]="state.models()"
|
||||
[value]="state.selectedModel()"
|
||||
[loading]="state.modelSwitching()"
|
||||
(valueChange)="state.changeModel($event)"
|
||||
/>
|
||||
<button type="button" class="settings" (click)="state.settingsOpen.set(!state.settingsOpen())">Settings</button>
|
||||
|
|
@ -59,7 +60,10 @@ import { ChatStateService } from './chat-state.service';
|
|||
/>
|
||||
|
||||
<section class="chat-shell__thread">
|
||||
<chat-message-list [messages]="state.activeConversation()?.messages || []" />
|
||||
<chat-message-list
|
||||
[messages]="state.activeConversation()?.messages || []"
|
||||
[streaming]="state.sending()"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<chat-input-area
|
||||
|
|
@ -82,10 +86,10 @@ import { ChatStateService } from './chat-state.service';
|
|||
linear-gradient(160deg, #020617 0%, #081121 46%, #111827 100%);
|
||||
font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', serif; }
|
||||
.workspace { min-height: 100vh; display: grid; grid-template-columns: 20rem 1fr; }
|
||||
.chat-shell { display: grid; grid-template-rows: auto auto 1fr auto; gap: 1rem; padding: 1.5rem; }
|
||||
.chat-shell { min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 1rem; padding: 1.5rem; }
|
||||
.chat-shell__header { display: flex; justify-content: space-between; gap: 1rem; align-items: end; }
|
||||
.chat-shell__controls { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
|
||||
.chat-shell__thread { overflow: auto; padding: 1rem 0.2rem 1rem 0; }
|
||||
.chat-shell__thread { min-height: 0; overflow: hidden; padding: 1rem 0.2rem 1rem 0; }
|
||||
.eyebrow { margin: 0; color: #f59e0b; text-transform: uppercase; letter-spacing: 0.18em; font-size: 0.72rem; }
|
||||
h1 { margin: 0.2rem 0 0; font-size: clamp(2rem, 3vw, 3rem); line-height: 1; }
|
||||
.settings { border: 1px solid rgba(251, 191, 36, 0.22); border-radius: 999px; background: rgba(124, 45, 18, 0.25); color: #fde68a; padding: 0.85rem 1.2rem; cursor: pointer; }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ declare global {
|
|||
@Injectable({ providedIn: 'root' })
|
||||
export class ChatStateService {
|
||||
private readonly ws = inject(WebSocketService);
|
||||
private mockConversations: Conversation[] = [];
|
||||
|
||||
readonly conversations = signal<ConversationSummary[]>([]);
|
||||
readonly activeConversation = signal<Conversation | null>(null);
|
||||
|
|
@ -33,12 +34,13 @@ export class ChatStateService {
|
|||
max_tokens: 2048,
|
||||
context_window: 8192,
|
||||
system_prompt: 'You are a helpful assistant.',
|
||||
default_model: 'local-default',
|
||||
default_model: '',
|
||||
});
|
||||
readonly draft = signal('');
|
||||
readonly historyQuery = signal('');
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly sending = signal(false);
|
||||
readonly modelSwitching = signal(false);
|
||||
readonly selectedModel = signal('');
|
||||
|
||||
constructor() {
|
||||
|
|
@ -142,16 +144,26 @@ export class ChatStateService {
|
|||
if (!model) {
|
||||
return;
|
||||
}
|
||||
const settings = await this.invoke<ChatSettings>('gui.chat.selectModel', {
|
||||
model,
|
||||
conversation_id: this.activeConversation()?.id,
|
||||
});
|
||||
this.selectedModel.set(model);
|
||||
if (settings) {
|
||||
this.settings.set(settings);
|
||||
this.modelSwitching.set(true);
|
||||
try {
|
||||
const settings = await this.invoke<ChatSettings>('gui.chat.selectModel', {
|
||||
model,
|
||||
conversation_id: this.activeConversation()?.id,
|
||||
});
|
||||
this.selectedModel.set(model);
|
||||
if (settings) {
|
||||
this.settings.set(settings);
|
||||
}
|
||||
const currentId = this.activeConversation()?.id;
|
||||
this.activeConversation.update((conversation) => (conversation ? { ...conversation, model } : conversation));
|
||||
if (currentId) {
|
||||
this.conversations.update((items) =>
|
||||
items.map((item) => (item.id === currentId ? { ...item, model } : item)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.modelSwitching.set(false);
|
||||
}
|
||||
this.activeConversation.update((conversation) => (conversation ? { ...conversation, model } : conversation));
|
||||
this.conversations.update((items) => items.map((item) => (item.id === this.activeConversation()?.id ? { ...item, model } : item)));
|
||||
}
|
||||
|
||||
async queueImageFiles(files: FileList | File[]): Promise<void> {
|
||||
|
|
@ -457,27 +469,127 @@ export class ChatStateService {
|
|||
{ name: 'lemma', architecture: 'qwen3', quant_bits: 8, size_bytes: 3200000000, loaded: false, backend: 'ollama' },
|
||||
] as T;
|
||||
}
|
||||
if (route === 'gui.chat.settings.load' || route === 'gui.chat.settings.save' || route === 'gui.chat.settings.reset') {
|
||||
return (payload ?? this.settings()) as T;
|
||||
if (route === 'gui.chat.settings.load') {
|
||||
return this.settings() as T;
|
||||
}
|
||||
if (route === 'gui.chat.settings.save') {
|
||||
const settings = payload as ChatSettings;
|
||||
this.settings.set(settings);
|
||||
if (settings.default_model) {
|
||||
this.selectedModel.set(settings.default_model);
|
||||
}
|
||||
return settings as T;
|
||||
}
|
||||
if (route === 'gui.chat.settings.reset') {
|
||||
const defaults: ChatSettings = {
|
||||
temperature: 1,
|
||||
top_p: 0.95,
|
||||
top_k: 64,
|
||||
max_tokens: 2048,
|
||||
context_window: 8192,
|
||||
system_prompt: 'You are a helpful assistant.',
|
||||
default_model: '',
|
||||
};
|
||||
this.settings.set(defaults);
|
||||
return defaults as T;
|
||||
}
|
||||
if (route === 'gui.chat.selectModel') {
|
||||
return {
|
||||
const model = (payload as { model?: string })?.model ?? this.settings().default_model;
|
||||
const updated = {
|
||||
...this.settings(),
|
||||
default_model: (payload as { model?: string })?.model ?? this.settings().default_model,
|
||||
} as T;
|
||||
default_model: model,
|
||||
};
|
||||
if (this.activeConversation()) {
|
||||
const currentId = this.activeConversation()?.id;
|
||||
this.activeConversation.update((conversation) => (conversation ? { ...conversation, model } : conversation));
|
||||
if (currentId) {
|
||||
this.conversations.update((items) =>
|
||||
items.map((item) => (item.id === currentId ? { ...item, model } : item)),
|
||||
);
|
||||
this.mockConversations = this.mockConversations.map((item) => (item.id === currentId ? { ...item, model } : item));
|
||||
}
|
||||
}
|
||||
return updated as T;
|
||||
}
|
||||
if (route === 'gui.chat.conversations.get') {
|
||||
const id = (payload as { id?: string; conversation_id?: string })?.id ?? (payload as { id?: string; conversation_id?: string })?.conversation_id;
|
||||
const found = this.mockConversations.find((item) => item.id === id);
|
||||
if (found) {
|
||||
return found as T;
|
||||
}
|
||||
if (this.activeConversation()?.id === id) {
|
||||
return this.activeConversation() as T;
|
||||
}
|
||||
return null as T;
|
||||
}
|
||||
if (route === 'gui.chat.conversations.rename') {
|
||||
const { id, title } = payload as { id?: string; title?: string };
|
||||
const titleValue = title?.trim() || 'New Chat';
|
||||
const currentId = this.activeConversation()?.id;
|
||||
if (currentId === id) {
|
||||
this.activeConversation.update((conversation) => (conversation ? { ...conversation, title: titleValue } : conversation));
|
||||
}
|
||||
this.conversations.update((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, title: titleValue } : item)),
|
||||
);
|
||||
this.mockConversations = this.mockConversations.map((item) => (item.id === id ? { ...item, title: titleValue } : item));
|
||||
const found = this.mockConversations.find((item) => item.id === id);
|
||||
if (found) {
|
||||
return found as T;
|
||||
}
|
||||
if (this.activeConversation()?.id === id) {
|
||||
return this.activeConversation() as T;
|
||||
}
|
||||
return { id, title: titleValue } as T;
|
||||
}
|
||||
if (route === 'gui.chat.conversations.delete') {
|
||||
const id = (payload as { id?: string; conversation_id?: string })?.id ?? (payload as { id?: string; conversation_id?: string })?.conversation_id;
|
||||
this.conversations.update((items) => items.filter((item) => item.id !== id));
|
||||
this.mockConversations = this.mockConversations.filter((item) => item.id !== id);
|
||||
if (this.activeConversation()?.id === id) {
|
||||
this.activeConversation.set(null);
|
||||
}
|
||||
return undefined as T;
|
||||
}
|
||||
if (route === 'gui.chat.conversations.list' || route === 'gui.chat.conversations.search') {
|
||||
return this.conversations() as T;
|
||||
const query = ((payload as { q?: string })?.q ?? '').trim().toLowerCase();
|
||||
if (!query) {
|
||||
return this.mockConversations.map((item) => this.toSummary(item)) as T;
|
||||
}
|
||||
return this.mockConversations
|
||||
.filter((item) =>
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.model.toLowerCase().includes(query) ||
|
||||
item.messages.some((message) => message.content.toLowerCase().includes(query)),
|
||||
)
|
||||
.map((item) => this.toSummary(item)) as T;
|
||||
}
|
||||
if (route === 'gui.chat.conversations.export') {
|
||||
return '# Exported Conversation\n' as T;
|
||||
const id = (payload as { id?: string; conversation_id?: string })?.id ?? (payload as { id?: string; conversation_id?: string })?.conversation_id;
|
||||
const found = this.mockConversations.find((item) => item.id === id);
|
||||
const conversation = found ?? (this.activeConversation()?.id === id ? this.activeConversation() : null);
|
||||
if (!conversation) {
|
||||
return '# Exported Conversation\n' as T;
|
||||
}
|
||||
return [
|
||||
`# ${conversation.title}`,
|
||||
'',
|
||||
...conversation.messages.map((message) => {
|
||||
const heading = `## ${message.role.charAt(0).toUpperCase() + message.role.slice(1)}`;
|
||||
const body = message.content ? `${message.content}\n` : '';
|
||||
return `${heading}\n\n${body}`.trimEnd();
|
||||
}),
|
||||
'',
|
||||
].join('\n') as T;
|
||||
}
|
||||
if (route === 'gui.chat.attachImage') {
|
||||
const attachment = payload as ImageAttachment;
|
||||
this.queuedAttachments.update((items) => [...items, attachment]);
|
||||
return payload as T;
|
||||
}
|
||||
if (route === 'gui.chat.conversations.new') {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
const conversation = {
|
||||
id: `conv-${Date.now().toString(36)}`,
|
||||
title: 'New Chat',
|
||||
model: this.selectedModel() || 'lemer',
|
||||
|
|
@ -485,19 +597,24 @@ export class ChatStateService {
|
|||
updated_at: now,
|
||||
message_count: 0,
|
||||
messages: [],
|
||||
} as T;
|
||||
};
|
||||
this.activeConversation.set(conversation);
|
||||
this.mockConversations = [conversation, ...this.mockConversations.filter((item) => item.id !== conversation.id)];
|
||||
this.upsertSummary(conversation);
|
||||
return conversation as T;
|
||||
}
|
||||
if (route === 'gui.chat.send') {
|
||||
const conversation = this.activeConversation() ?? ((await this.mockInvoke('gui.chat.conversations.new')) as Conversation);
|
||||
const content = (payload as { content?: string })?.content ?? '';
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
const attachments = this.queuedAttachments();
|
||||
const updated = {
|
||||
...conversation,
|
||||
updated_at: now,
|
||||
title: conversation.title === 'New Chat' ? content.slice(0, 48) || 'New Chat' : conversation.title,
|
||||
messages: [
|
||||
...conversation.messages,
|
||||
{ id: `user-${Date.now()}`, role: 'user', content, created_at: now, model: this.selectedModel(), attachments: this.queuedAttachments() },
|
||||
{ id: `user-${Date.now()}`, role: 'user', content, created_at: now, model: this.selectedModel(), attachments },
|
||||
{
|
||||
id: `assistant-${Date.now() + 1}`,
|
||||
role: 'assistant',
|
||||
|
|
@ -506,8 +623,15 @@ export class ChatStateService {
|
|||
model: this.selectedModel(),
|
||||
},
|
||||
],
|
||||
} as T;
|
||||
};
|
||||
this.draft.set('');
|
||||
this.queuedAttachments.set([]);
|
||||
this.activeConversation.set(updated);
|
||||
this.mockConversations = [updated, ...this.mockConversations.filter((item) => item.id !== updated.id)];
|
||||
this.upsertSummary(updated);
|
||||
return updated as T;
|
||||
}
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,24 +15,40 @@ type ConversationGroup = { label: string; items: ConversationSummary[] };
|
|||
<button type="button" class="ghost" (click)="create.emit()">New chat</button>
|
||||
<input [ngModel]="query" (ngModelChange)="queryChange.emit($event)" placeholder="Search history" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar__list">
|
||||
<section *ngFor="let group of groupedConversations" class="group">
|
||||
<p class="group__label">{{ group.label }}</p>
|
||||
<button
|
||||
|
||||
<article
|
||||
*ngFor="let item of group.items"
|
||||
type="button"
|
||||
class="conversation"
|
||||
[class.conversation--active]="item.id === activeId"
|
||||
(click)="select.emit(item.id)"
|
||||
>
|
||||
<span class="conversation__title">{{ item.title }}</span>
|
||||
<span class="conversation__meta">{{ item.model }} · {{ item.updated_at | date: 'MMM d, HH:mm' }}</span>
|
||||
<span class="conversation__actions" (click)="$event.stopPropagation()">
|
||||
<button type="button" (click)="renameConversation(item)">Rename</button>
|
||||
<button type="button" class="conversation__select" (click)="select.emit(item.id)">
|
||||
<span class="conversation__title">{{ item.title }}</span>
|
||||
<span class="conversation__meta">{{ item.model }} · {{ item.updated_at | date: 'MMM d, HH:mm' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="conversation__actions">
|
||||
<button type="button" (click)="beginRename(item)">Rename</button>
|
||||
<button type="button" (click)="export.emit(item.id)">Export</button>
|
||||
<button type="button" class="danger" (click)="removeConversation(item)">Delete</button>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form *ngIf="editingId === item.id" class="conversation__rename" (ngSubmit)="commitRename(item.id)">
|
||||
<input
|
||||
[ngModel]="draftTitle"
|
||||
(ngModelChange)="draftTitle = $event"
|
||||
name="rename-{{ item.id }}"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="conversation__rename-actions">
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" class="ghost" (click)="cancelRename()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -44,14 +60,23 @@ type ConversationGroup = { label: string; items: ConversationSummary[] };
|
|||
.sidebar__list { display: grid; gap: 1rem; align-content: start; overflow: auto; }
|
||||
.group { display: grid; gap: 0.5rem; }
|
||||
.group__label { margin: 0; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.7rem; }
|
||||
.ghost, input, .conversation__actions button { border-radius: 0.9rem; border: 1px solid rgba(124, 156, 191, 0.2); background: rgba(11, 27, 44, 0.7); color: #eaf4ff; padding: 0.8rem 0.9rem; }
|
||||
.ghost, input, .conversation__actions button, .conversation__rename-actions button {
|
||||
border-radius: 0.9rem;
|
||||
border: 1px solid rgba(124, 156, 191, 0.2);
|
||||
background: rgba(11, 27, 44, 0.7);
|
||||
color: #eaf4ff;
|
||||
padding: 0.8rem 0.9rem;
|
||||
}
|
||||
.ghost { cursor: pointer; text-align: left; font-weight: 700; }
|
||||
.conversation { display: grid; gap: 0.35rem; padding: 0.9rem; border: 0; border-radius: 1rem; background: rgba(8, 21, 35, 0.55); color: #dbeafe; text-align: left; cursor: pointer; }
|
||||
.conversation { display: grid; gap: 0.7rem; padding: 0.9rem; border: 0; border-radius: 1rem; background: rgba(8, 21, 35, 0.55); color: #dbeafe; text-align: left; }
|
||||
.conversation--active { background: linear-gradient(135deg, rgba(14, 116, 144, 0.55), rgba(8, 47, 73, 0.82)); box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.28); }
|
||||
.conversation__select { display: grid; gap: 0.35rem; border: 0; padding: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||
.conversation__title { font-weight: 700; }
|
||||
.conversation__meta { color: #94a3b8; font-size: 0.8rem; }
|
||||
.conversation__actions { display: flex; gap: 0.45rem; flex-wrap: wrap; }
|
||||
.conversation__actions button { padding: 0.35rem 0.6rem; font-size: 0.72rem; }
|
||||
.conversation__actions button, .conversation__rename-actions button { padding: 0.35rem 0.6rem; font-size: 0.72rem; cursor: pointer; }
|
||||
.conversation__rename { display: grid; gap: 0.5rem; }
|
||||
.conversation__rename-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; }
|
||||
.danger { color: #fecaca; border-color: rgba(248, 113, 113, 0.28); }
|
||||
`,
|
||||
],
|
||||
|
|
@ -67,6 +92,9 @@ export class ConversationSidebarComponent {
|
|||
@Output() delete = new EventEmitter<string>();
|
||||
@Output() export = new EventEmitter<string>();
|
||||
|
||||
editingId: string | null = null;
|
||||
draftTitle = '';
|
||||
|
||||
get groupedConversations(): ConversationGroup[] {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
|
|
@ -92,11 +120,22 @@ export class ConversationSidebarComponent {
|
|||
.map((label) => ({ label, items: groups.get(label) ?? [] }));
|
||||
}
|
||||
|
||||
renameConversation(item: ConversationSummary): void {
|
||||
const title = window.prompt('Rename conversation', item.title)?.trim();
|
||||
beginRename(item: ConversationSummary): void {
|
||||
this.editingId = item.id;
|
||||
this.draftTitle = item.title;
|
||||
}
|
||||
|
||||
commitRename(id: string): void {
|
||||
const title = this.draftTitle.trim();
|
||||
if (title) {
|
||||
this.rename.emit({ id: item.id, title });
|
||||
this.rename.emit({ id, title });
|
||||
}
|
||||
this.cancelRename();
|
||||
}
|
||||
|
||||
cancelRename(): void {
|
||||
this.editingId = null;
|
||||
this.draftTitle = '';
|
||||
}
|
||||
|
||||
removeConversation(item: ConversationSummary): void {
|
||||
|
|
|
|||
|
|
@ -1,74 +1,154 @@
|
|||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import {
|
||||
AfterViewChecked,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { ChatMessage, ImageAttachment } from './chat.types';
|
||||
|
||||
type RenderBlock =
|
||||
| { kind: 'text'; html: SafeHtml }
|
||||
| { kind: 'code'; code: string; language: string };
|
||||
|
||||
@Component({
|
||||
selector: 'chat-message-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, DatePipe],
|
||||
template: `
|
||||
<div class="thread">
|
||||
<article *ngFor="let message of messages" class="bubble" [class.bubble--user]="message.role === 'user'">
|
||||
<header>
|
||||
<strong>{{ message.role }}</strong>
|
||||
<span>{{ message.model || 'local' }}</span>
|
||||
</header>
|
||||
<section class="content" [innerHTML]="renderMarkdown(message.content)"></section>
|
||||
<section *ngIf="message.attachments?.length" class="attachments">
|
||||
<figure *ngFor="let attachment of message.attachments" class="attachment">
|
||||
<img [src]="attachmentSource(attachment)" [alt]="attachment.filename" />
|
||||
<figcaption>{{ attachment.filename }} · {{ attachment.width }}×{{ attachment.height }}</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
<details *ngIf="message.thinking?.content" class="thinking">
|
||||
<summary>Thinking · {{ thinkingDuration(message) }}</summary>
|
||||
<p>{{ message.thinking?.content }}</p>
|
||||
</details>
|
||||
<section *ngIf="message.tool_calls?.length" class="tool">
|
||||
<strong>Tool calls</strong>
|
||||
<div *ngFor="let call of message.tool_calls" class="tool__block">
|
||||
<div class="tool__title">{{ call.name }}</div>
|
||||
<pre>{{ call.arguments | json }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
<section *ngIf="message.tool_results?.length" class="tool">
|
||||
<strong>Tool results</strong>
|
||||
<div *ngFor="let result of message.tool_results" class="tool__block">
|
||||
<div class="tool__title">{{ result.tool_call_id }}</div>
|
||||
<pre>{{ result.content }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
<footer>{{ message.created_at | date: 'MMM d, HH:mm:ss' }}</footer>
|
||||
</article>
|
||||
<div class="thread-shell">
|
||||
<div #viewport class="thread" (scroll)="onScroll()">
|
||||
<article *ngFor="let message of messages; trackBy: trackByMessage" class="bubble" [class.bubble--user]="message.role === 'user'">
|
||||
<header>
|
||||
<strong>{{ message.role }}</strong>
|
||||
<span>{{ message.model || 'local' }}</span>
|
||||
</header>
|
||||
|
||||
<ng-container *ngFor="let block of renderBlocks(message.content)">
|
||||
<section *ngIf="block.kind === 'text'" class="content" [innerHTML]="block.html"></section>
|
||||
<section *ngIf="block.kind === 'code'" class="code-block">
|
||||
<div class="code-block__bar">
|
||||
<span>{{ block.language || 'code' }}</span>
|
||||
<button type="button" class="code-block__copy" (click)="copyCode(block.code)">Copy</button>
|
||||
</div>
|
||||
<pre><code>{{ block.code }}</code></pre>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
<section *ngIf="message.attachments?.length" class="attachments">
|
||||
<figure *ngFor="let attachment of message.attachments" class="attachment">
|
||||
<img [src]="attachmentSource(attachment)" [alt]="attachment.filename" />
|
||||
<figcaption>{{ attachment.filename }} · {{ attachment.width }}×{{ attachment.height }}</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section *ngIf="message.thinking" class="thinking">
|
||||
<div class="thinking__badge" [class.thinking__badge--active]="message.thinking.active">
|
||||
Thinking...
|
||||
</div>
|
||||
<details>
|
||||
<summary>Thought for {{ thinkingDuration(message) }}</summary>
|
||||
<p>{{ message.thinking.content || 'Thinking in progress' }}</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section *ngIf="message.tool_calls?.length" class="tool">
|
||||
<strong>Tool calls</strong>
|
||||
<div *ngFor="let call of message.tool_calls" class="tool__block">
|
||||
<div class="tool__title">{{ call.name }}</div>
|
||||
<pre>{{ call.arguments | json }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section *ngIf="message.tool_results?.length" class="tool">
|
||||
<strong>Tool results</strong>
|
||||
<div *ngFor="let result of message.tool_results" class="tool__block">
|
||||
<div class="tool__title">{{ result.tool_call_id }}</div>
|
||||
<pre>{{ result.content }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer [attr.title]="message.created_at | date : 'full'">
|
||||
{{ message.created_at | date: 'MMM d, HH:mm:ss' }}
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<button *ngIf="streaming && !pinnedToBottom" type="button" class="scroll-pill" (click)="scrollToBottom()">
|
||||
Scroll to bottom
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.thread { display: grid; gap: 1rem; align-content: start; }
|
||||
.thread-shell { position: relative; min-height: 100%; height: 100%; }
|
||||
.thread { display: grid; gap: 1rem; align-content: start; min-height: 0; max-height: 100%; overflow: auto; padding-right: 0.25rem; }
|
||||
.bubble { max-width: 54rem; padding: 1rem 1.1rem; border-radius: 1.25rem; background: rgba(11, 27, 44, 0.88); border: 1px solid rgba(125, 211, 252, 0.12); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); }
|
||||
.bubble--user { margin-left: auto; background: linear-gradient(135deg, rgba(8, 47, 73, 0.95), rgba(14, 116, 144, 0.7)); }
|
||||
header, footer { display: flex; justify-content: space-between; gap: 1rem; color: #7dd3fc; font-size: 0.72rem; }
|
||||
header { margin-bottom: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
footer { margin-top: 0.9rem; color: #94a3b8; }
|
||||
footer { margin-top: 0.9rem; color: #94a3b8; opacity: 0; transition: opacity 0.2s ease; }
|
||||
.bubble:hover footer { opacity: 1; }
|
||||
.content { color: #ecfeff; line-height: 1.6; }
|
||||
.attachments { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.9rem; }
|
||||
.attachment { width: min(16rem, 100%); margin: 0; display: grid; gap: 0.4rem; }
|
||||
.attachment img { width: 100%; border-radius: 1rem; }
|
||||
.attachment figcaption { color: #cbd5e1; font-size: 0.76rem; }
|
||||
.thinking, .tool { margin-top: 0.85rem; padding-top: 0.85rem; border-top: 1px solid rgba(125, 211, 252, 0.12); color: #cbd5e1; }
|
||||
.thinking { display: grid; gap: 0.5rem; }
|
||||
.thinking__badge { display: inline-flex; width: fit-content; gap: 0.45rem; align-items: center; border-radius: 999px; padding: 0.3rem 0.65rem; background: rgba(15, 23, 42, 0.7); color: #f8fafc; }
|
||||
.thinking__badge--active::before { content: ''; width: 0.55rem; height: 0.55rem; border-radius: 50%; background: #f59e0b; box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.5); animation: pulse 1.4s infinite; }
|
||||
details summary { cursor: pointer; color: #e2e8f0; }
|
||||
.tool { display: grid; gap: 0.65rem; }
|
||||
.tool__block { display: grid; gap: 0.35rem; }
|
||||
.tool__title { color: #f8fafc; font-weight: 700; }
|
||||
pre { margin: 0; overflow: auto; background: rgba(2, 6, 23, 0.6); padding: 0.85rem; border-radius: 0.8rem; white-space: pre-wrap; }
|
||||
.code-block { margin-top: 0.9rem; overflow: hidden; border-radius: 0.95rem; border: 1px solid rgba(148, 163, 184, 0.16); background: rgba(2, 6, 23, 0.64); }
|
||||
.code-block__bar { display: flex; justify-content: space-between; gap: 1rem; align-items: center; padding: 0.55rem 0.8rem; border-bottom: 1px solid rgba(148, 163, 184, 0.12); color: #cbd5e1; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.code-block__copy { border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 999px; background: rgba(15, 23, 42, 0.8); color: #e2e8f0; padding: 0.3rem 0.7rem; cursor: pointer; text-transform: none; letter-spacing: normal; }
|
||||
pre { margin: 0; overflow: auto; padding: 0.85rem; white-space: pre-wrap; color: #f8fafc; }
|
||||
.scroll-pill { position: absolute; right: 0.75rem; bottom: 0.75rem; border: 1px solid rgba(251, 191, 36, 0.28); border-radius: 999px; background: rgba(124, 45, 18, 0.92); color: #fde68a; padding: 0.75rem 1rem; cursor: pointer; box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25); }
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.92); opacity: 0.75; }
|
||||
70% { transform: scale(1.08); opacity: 1; }
|
||||
100% { transform: scale(0.92); opacity: 0.75; }
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class MessageListComponent {
|
||||
export class MessageListComponent implements OnChanges, AfterViewChecked {
|
||||
@Input() messages: ChatMessage[] = [];
|
||||
@Input() streaming = false;
|
||||
|
||||
@ViewChild('viewport') private readonly viewport?: ElementRef<HTMLDivElement>;
|
||||
|
||||
private pendingScroll = false;
|
||||
pinnedToBottom = true;
|
||||
|
||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['messages'] || changes['streaming']) {
|
||||
this.pendingScroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
if (this.pendingScroll) {
|
||||
this.pendingScroll = false;
|
||||
if (this.pinnedToBottom) {
|
||||
queueMicrotask(() => this.scrollToBottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackByMessage(_index: number, message: ChatMessage): string {
|
||||
return message.id;
|
||||
}
|
||||
|
||||
attachmentSource(attachment: ImageAttachment): string {
|
||||
return `data:${attachment.mime_type};base64,${attachment.data}`;
|
||||
}
|
||||
|
|
@ -78,13 +158,81 @@ export class MessageListComponent {
|
|||
return duration > 0 ? `${(duration / 1000).toFixed(1)}s` : 'in progress';
|
||||
}
|
||||
|
||||
renderMarkdown(content: string): SafeHtml {
|
||||
renderBlocks(content: string): RenderBlock[] {
|
||||
const source = content ?? '';
|
||||
if (!source.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks: RenderBlock[] = [];
|
||||
const pattern = /```([\w-]+)?\n([\s\S]*?)```/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const before = source.slice(lastIndex, match.index).trim();
|
||||
if (before) {
|
||||
blocks.push({ kind: 'text', html: this.renderInlineMarkdown(before) });
|
||||
}
|
||||
blocks.push({
|
||||
kind: 'code',
|
||||
language: match[1] ?? '',
|
||||
code: match[2].replace(/\n+$/, ''),
|
||||
});
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
const after = source.slice(lastIndex).trim();
|
||||
if (after) {
|
||||
blocks.push({ kind: 'text', html: this.renderInlineMarkdown(after) });
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
blocks.push({ kind: 'text', html: this.renderInlineMarkdown(source) });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
copyCode(code: string): void {
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
if (navigator.clipboard?.writeText) {
|
||||
void navigator.clipboard.writeText(code);
|
||||
return;
|
||||
}
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = code;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
onScroll(): void {
|
||||
const element = this.viewport?.nativeElement;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
this.pinnedToBottom = distanceFromBottom < 48;
|
||||
}
|
||||
|
||||
scrollToBottom(): void {
|
||||
const element = this.viewport?.nativeElement;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.scrollTop = element.scrollHeight;
|
||||
this.pinnedToBottom = true;
|
||||
}
|
||||
|
||||
private renderInlineMarkdown(content: string): SafeHtml {
|
||||
const escaped = this.escapeHTML(content ?? '');
|
||||
const blocks = escaped.replace(/```([\w-]+)?\n([\s\S]*?)```/g, (_, language, code) => {
|
||||
const label = language ? `<div class="code-lang">${language}</div>` : '';
|
||||
return `${label}<pre><code>${code.trim()}</code></pre>`;
|
||||
});
|
||||
const inline = blocks
|
||||
const inline = escaped
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
|
|
|
|||
|
|
@ -10,22 +10,29 @@ import { ModelEntry } from './chat.types';
|
|||
template: `
|
||||
<label class="selector">
|
||||
<span>Model</span>
|
||||
<select [ngModel]="value" (ngModelChange)="valueChange.emit($event)">
|
||||
<option *ngFor="let model of models" [ngValue]="model.name">
|
||||
{{ model.loaded ? '● ' : '' }}{{ model.name }} · {{ model.architecture }} · {{ model.backend }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="selector__row">
|
||||
<select [ngModel]="value" (ngModelChange)="valueChange.emit($event)" [disabled]="loading">
|
||||
<option *ngFor="let model of models" [ngValue]="model.name">
|
||||
{{ model.loaded ? '● ' : '' }}{{ model.name }} · {{ model.architecture }} · {{ model.backend }}
|
||||
</option>
|
||||
</select>
|
||||
<span *ngIf="loading" class="spinner" aria-hidden="true"></span>
|
||||
</div>
|
||||
</label>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.selector { display: grid; gap: 0.35rem; color: #cbd5e1; font-size: 0.82rem; }
|
||||
.selector__row { display: flex; align-items: center; gap: 0.65rem; }
|
||||
select { min-width: 18rem; border-radius: 0.8rem; border: 1px solid rgba(124, 156, 191, 0.2); background: rgba(8, 21, 35, 0.8); color: #e2e8f0; padding: 0.72rem 0.9rem; }
|
||||
.spinner { width: 1rem; height: 1rem; border-radius: 999px; border: 2px solid rgba(148, 163, 184, 0.24); border-top-color: #f59e0b; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ModelSelectorComponent {
|
||||
@Input() models: ModelEntry[] = [];
|
||||
@Input() value = '';
|
||||
@Input() loading = false;
|
||||
@Output() valueChange = new EventEmitter<string>();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue