Bridge chat UI to CoreGUI actions
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Snider 2026-04-15 11:04:35 +01:00
parent 8c75e1c84b
commit ae02c8574b
6 changed files with 1042 additions and 140 deletions

View file

@ -561,7 +561,8 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C
if !ok {
return Conversation{}, ChatMessage{}, ChatMessage{}, coreerr.E("display.chat.SendMessage", "conversation not found: "+conversationID, nil)
}
if strings.TrimSpace(content) == "" {
queuedAttachments := append([]ImageAttachment(nil), s.queuedImages[conversationID]...)
if strings.TrimSpace(content) == "" && len(queuedAttachments) == 0 {
return Conversation{}, ChatMessage{}, ChatMessage{}, coreerr.E("display.chat.SendMessage", "message content is required", nil)
}
@ -572,12 +573,12 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C
Role: "user",
Content: content,
CreatedAt: now,
Attachments: append([]ImageAttachment(nil), s.queuedImages[conversationID]...),
Attachments: queuedAttachments,
}
assistantMessage := ChatMessage{
ID: s.nextIdentifier("msg"),
Role: "assistant",
Content: buildAssistantPlaceholder(model, content),
Content: buildAssistantPlaceholder(model, content, len(queuedAttachments)),
CreatedAt: now.Add(250 * time.Millisecond),
Streaming: false,
}
@ -591,7 +592,7 @@ func (s *ChatStore) SendMessage(conversationID, content string) (Conversation, C
conv.Messages = append(conv.Messages, userMessage, assistantMessage)
if len(conv.Messages) == 2 {
conv.Title = deriveConversationTitle(content)
conv.Title = deriveConversationTitleForMessage(content, queuedAttachments)
}
conv.UpdatedAt = assistantMessage.CreatedAt
conv.Model = model
@ -879,6 +880,8 @@ type QueryChatHistory struct {
ConversationID string `json:"conversation_id"`
}
type QueryChatSnapshot struct{}
type TaskChatSend struct {
ConversationID string `json:"conversation_id"`
Content string `json:"content"`
@ -984,6 +987,8 @@ func (s *Service) handleChatQuery(_ *core.Core, q core.Query) (any, bool, error)
case QueryChatHistory:
history, err := s.chat.History(q.ConversationID)
return history, true, err
case QueryChatSnapshot:
return s.chat.Snapshot(), true, nil
case QueryChatModels:
return s.chat.Models(), true, nil
case QueryChatSettingsLoad:
@ -1289,6 +1294,19 @@ func deriveConversationTitle(content string) string {
return strings.TrimSpace(string(runes[:50])) + "..."
}
func deriveConversationTitleForMessage(content string, attachments []ImageAttachment) string {
if title := deriveConversationTitle(strings.TrimSpace(content)); title != "New conversation" {
return title
}
if len(attachments) == 0 {
return "New conversation"
}
if name := strings.TrimSpace(attachments[0].Filename); name != "" {
return deriveConversationTitle("Image: " + name)
}
return "Image conversation"
}
func conversationMatchesSearchQuery(conv Conversation, query string) bool {
if query == "" {
return true
@ -1323,14 +1341,18 @@ func messageSearchHaystack(message ChatMessage) string {
return strings.Join(parts, " ")
}
func buildAssistantPlaceholder(model, prompt string) string {
func buildAssistantPlaceholder(model, prompt string, attachmentCount int) string {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return "Waiting for the local inference pipeline."
}
if model == "" {
model = "local model"
}
if prompt == "" {
if attachmentCount > 0 {
return "Local inference is not wired in this workspace yet. " +
"Captured " + strconvFormatUint(uint64(attachmentCount)) + " image attachment(s) for " + model + " and stored them in chat history."
}
return "Waiting for the local inference pipeline."
}
return "Local inference is not wired in this workspace yet. " +
"Captured your prompt for " + model + " and stored it in chat history."
}
@ -1361,7 +1383,7 @@ func canReuseAssistantForStreaming(conv Conversation) bool {
if previous.Role != "user" {
return false
}
return last.Content == buildAssistantPlaceholder(conv.Model, previous.Content)
return last.Content == buildAssistantPlaceholder(conv.Model, previous.Content, len(previous.Attachments))
}
func parseCounter(value string) uint64 {
@ -1584,8 +1606,14 @@ func (s *ChatStore) normalizeMessagesLocked(messages []ChatMessage, base time.Ti
func firstUserMessage(messages []ChatMessage) string {
for _, message := range messages {
if message.Role == "user" && strings.TrimSpace(message.Content) != "" {
return message.Content
if message.Role != "user" {
continue
}
if content := strings.TrimSpace(message.Content); content != "" {
return content
}
if len(message.Attachments) > 0 {
return deriveConversationTitleForMessage("", message.Attachments)
}
}
return ""

View file

@ -221,6 +221,43 @@ func TestChatStreamingLifecycle_Good(t *testing.T) {
assert.False(t, history[1].Streaming)
}
func TestChatSendAllowsAttachmentOnlyMessages_Good(t *testing.T) {
_, c := newTestDisplayService(t)
convResult, handled, err := c.PERFORM(TaskConversationNew{})
require.NoError(t, err)
require.True(t, handled)
conv := convResult.(Conversation)
_, handled, err = c.PERFORM(TaskAttachImage{
ConversationID: conv.ID,
Attachment: ImageAttachment{
Filename: "reference.png",
MimeType: "image/png",
Data: "ZmFrZQ==",
Width: 512,
Height: 512,
},
})
require.NoError(t, err)
require.True(t, handled)
sendResult, handled, err := c.PERFORM(TaskChatSend{
ConversationID: conv.ID,
Content: "",
})
require.NoError(t, err)
require.True(t, handled)
updated := sendResult.(Conversation)
require.Len(t, updated.Messages, 2)
assert.Empty(t, updated.Messages[0].Content)
require.Len(t, updated.Messages[0].Attachments, 1)
assert.Equal(t, "reference.png", updated.Messages[0].Attachments[0].Filename)
assert.Contains(t, updated.Messages[1].Content, "image attachment")
assert.Equal(t, "Image: reference.png", updated.Title)
}
func TestChatPersistence_Good(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui.yaml")
@ -580,3 +617,33 @@ func TestRouteQueriesAndStoreGroups_Good(t *testing.T) {
}
assert.Contains(t, snippets, "invoice status is paid")
}
func TestChatSnapshotQuery_Good(t *testing.T) {
_, c := newTestDisplayService(t)
convResult, handled, err := c.PERFORM(TaskConversationNew{})
require.NoError(t, err)
require.True(t, handled)
conv := convResult.(Conversation)
_, handled, err = c.PERFORM(TaskAttachImage{
ConversationID: conv.ID,
Attachment: ImageAttachment{
Filename: "state.png",
MimeType: "image/png",
Data: "ZmFrZQ==",
},
})
require.NoError(t, err)
require.True(t, handled)
snapshotResult, handled, err := c.QUERY(QueryChatSnapshot{})
require.NoError(t, err)
require.True(t, handled)
snapshot := snapshotResult.(ChatSnapshot)
assert.Equal(t, "lemer", snapshot.SelectedModel)
require.Contains(t, snapshot.Conversations, conv.ID)
require.Contains(t, snapshot.QueuedImages, conv.ID)
require.Len(t, snapshot.QueuedImages[conv.ID], 1)
}

View file

@ -288,6 +288,17 @@ func wsRequire(data map[string]any, key string) (string, error) {
return v, nil
}
func decodeWSData(data map[string]any, target any) error {
encodedR := corego.JSONMarshal(data)
if !encodedR.OK {
return corego.NewError("ws: invalid payload")
}
if r := corego.JSONUnmarshal(encodedR.Value.([]byte), target); !r.OK {
return corego.E("display.ws", "invalid payload", nil)
}
return nil
}
// handleWSMessage bridges WebSocket commands to IPC calls.
func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
var result any
@ -336,6 +347,129 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
return nil, false, createErr
}
result, handled, err = info, true, nil
case "chat:snapshot":
result, handled, err = s.Core().QUERY(QueryChatSnapshot{})
case "chat:models":
result, handled, err = s.Core().QUERY(QueryChatModels{})
case "chat:model-select":
model, e := wsRequire(msg.Data, "model")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(TaskSelectModel{Model: model})
case "chat:settings-load":
result, handled, err = s.Core().QUERY(QueryChatSettingsLoad{})
case "chat:settings-save":
var settings ChatSettings
if err := decodeWSData(msg.Data, &settings); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(TaskChatSettingsSave{Settings: settings})
case "chat:settings-reset":
result, handled, err = s.Core().PERFORM(TaskChatSettingsReset{})
case "chat:conversations":
result, handled, err = s.Core().QUERY(QueryConversationsList{})
case "chat:conversation-get":
id, e := wsRequire(msg.Data, "id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().QUERY(QueryConversationGet{ID: id})
case "chat:conversation-search":
query, _ := msg.Data["q"].(string)
result, handled, err = s.Core().QUERY(QueryConversationsSearch{Query: query})
case "chat:conversation-new":
result, handled, err = s.Core().PERFORM(TaskConversationNew{})
case "chat:conversation-rename":
var input TaskConversationRename
if err := decodeWSData(msg.Data, &input); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(input)
case "chat:conversation-delete":
id, e := wsRequire(msg.Data, "id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(TaskConversationDelete{ID: id})
case "chat:conversation-export":
id, e := wsRequire(msg.Data, "id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().QUERY(QueryConversationExport{ID: id})
case "chat:queued-images":
conversationID, e := wsRequire(msg.Data, "conversation_id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().QUERY(QueryQueuedImages{ConversationID: conversationID})
case "chat:attach-image":
var input TaskAttachImage
if err := decodeWSData(msg.Data, &input); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(input)
case "chat:detach-image":
var input TaskDetachImage
if err := decodeWSData(msg.Data, &input); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(input)
case "chat:send":
var input TaskChatSend
if err := decodeWSData(msg.Data, &input); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(input)
case "chat:clear":
conversationID, e := wsRequire(msg.Data, "conversation_id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(TaskChatClear{ConversationID: conversationID})
case "chat:thinking-start":
conversationID, e := wsRequire(msg.Data, "conversation_id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(TaskThinkingStart{ConversationID: conversationID})
case "chat:thinking-append":
var input TaskThinkingAppend
if err := decodeWSData(msg.Data, &input); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(input)
case "chat:thinking-end":
conversationID, e := wsRequire(msg.Data, "conversation_id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(TaskThinkingEnd{ConversationID: conversationID})
case "chat:tool-call":
var input TaskRecordToolCall
if err := decodeWSData(msg.Data, &input); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(input)
case "chat:stream-start":
conversationID, e := wsRequire(msg.Data, "conversation_id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(TaskChatStreamStart{ConversationID: conversationID})
case "chat:stream-append":
var input TaskChatStreamAppend
if err := decodeWSData(msg.Data, &input); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(input)
case "chat:stream-finish":
var input TaskChatStreamFinish
if err := decodeWSData(msg.Data, &input); err != nil {
return nil, false, err
}
result, handled, err = s.Core().PERFORM(input)
case "route:resolve":
rawURL, e := wsRequire(msg.Data, "url")
if e != nil {

View file

@ -1270,6 +1270,79 @@ func TestHandleWSMessage_Extended_Good(t *testing.T) {
assert.Equal(t, "OK", result)
})
t.Run("chat actions", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{Action: "chat:snapshot"})
require.NoError(t, err)
assert.True(t, handled)
_, ok := result.(ChatSnapshot)
require.True(t, ok)
result, handled, err = svc.handleWSMessage(WSMessage{Action: "chat:conversation-new"})
require.NoError(t, err)
assert.True(t, handled)
conversation, ok := result.(Conversation)
require.True(t, ok)
require.NotEmpty(t, conversation.ID)
result, handled, err = svc.handleWSMessage(WSMessage{
Action: "chat:settings-save",
Data: map[string]any{
"temperature": 0.6,
"top_p": 0.9,
"top_k": float64(32),
"max_tokens": float64(1024),
"context_window": float64(4096),
"system_prompt": "Stay concise.",
"default_model": "lemma",
},
})
require.NoError(t, err)
assert.True(t, handled)
settings, ok := result.(ChatSettings)
require.True(t, ok)
assert.Equal(t, float32(0.6), settings.Temperature)
_, handled, err = svc.handleWSMessage(WSMessage{
Action: "chat:attach-image",
Data: map[string]any{
"conversation_id": conversation.ID,
"attachment": map[string]any{
"filename": "ws-chat.png",
"mime_type": "image/png",
"data": "ZmFrZQ==",
"width": float64(320),
"height": float64(180),
},
},
})
require.NoError(t, err)
assert.True(t, handled)
result, handled, err = svc.handleWSMessage(WSMessage{
Action: "chat:send",
Data: map[string]any{
"conversation_id": conversation.ID,
"content": "",
},
})
require.NoError(t, err)
assert.True(t, handled)
updatedConversation, ok := result.(Conversation)
require.True(t, ok)
require.Len(t, updatedConversation.Messages, 2)
assert.Len(t, updatedConversation.Messages[0].Attachments, 1)
result, handled, err = svc.handleWSMessage(WSMessage{
Action: "chat:conversation-export",
Data: map[string]any{"id": conversation.ID},
})
require.NoError(t, err)
assert.True(t, handled)
exported, ok := result.(string)
require.True(t, ok)
assert.Contains(t, exported, "ws-chat.png")
})
t.Run("event info", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{Action: "event:info"})
require.NoError(t, err)

View file

@ -203,7 +203,10 @@ interface ConversationGroup {
<article class="message" [class.user]="message.role === 'user'">
<div class="message-meta">
<span>{{ message.role === 'user' ? 'You' : 'Assistant' }}</span>
<time [attr.datetime]="message.createdAt" [title]="message.createdAt | date: 'medium'">
<time
[attr.datetime]="message.createdAt"
[title]="message.createdAt | date: 'medium'"
>
{{ message.createdAt | date: 'shortTime' }}
</time>
</div>
@ -226,7 +229,11 @@ interface ConversationGroup {
@if (message.thinking?.content) {
<section class="thinking-panel">
<button type="button" class="collapse-toggle" (click)="toggleThinking(message.id)">
<button
type="button"
class="collapse-toggle"
(click)="toggleThinking(message.id)"
>
<span class="thinking-label" [class.active]="message.thinking?.active">
@if (message.thinking?.active) {
<span class="thinking-pulse" aria-hidden="true"></span>
@ -258,11 +265,7 @@ interface ConversationGroup {
@if (message.toolCalls?.length) {
@for (tool of message.toolCalls || []; track tool.id) {
<section class="tool-panel">
<button
type="button"
class="collapse-toggle"
(click)="toggleTool(tool.id)"
>
<button type="button" class="collapse-toggle" (click)="toggleTool(tool.id)">
<span>{{ tool.name }}</span>
<small
class="tool-status"
@ -302,7 +305,9 @@ interface ConversationGroup {
</div>
@if (!autoScroll()) {
<button type="button" class="scroll-pill" (click)="jumpToBottom()">Scroll to bottom</button>
<button type="button" class="scroll-pill" (click)="jumpToBottom()">
Scroll to bottom
</button>
}
<footer class="composer">
@ -1046,7 +1051,7 @@ export class DashboardComponent implements AfterViewChecked {
}
protected createConversation(): void {
this.chat.createConversation();
void this.chat.createConversation();
this.uiState.clearSearchQuery();
this.cancelRename();
}
@ -1057,7 +1062,7 @@ export class DashboardComponent implements AfterViewChecked {
this.jumpToBottom();
}
protected deleteActiveConversation(): void {
protected async deleteActiveConversation(): Promise<void> {
const active = this.chat.activeConversation();
if (!active) {
return;
@ -1065,11 +1070,11 @@ export class DashboardComponent implements AfterViewChecked {
if (!this.confirmDeleteConversation(active.title)) {
return;
}
this.chat.deleteConversation(active.id);
await this.chat.deleteConversation(active.id);
this.cancelRename();
}
protected clearActiveConversation(): void {
protected async clearActiveConversation(): Promise<void> {
const active = this.chat.activeConversation();
if (!active) {
return;
@ -1077,16 +1082,16 @@ export class DashboardComponent implements AfterViewChecked {
if (!window.confirm(`Clear every message from "${active.title}"?`)) {
return;
}
this.chat.clearActiveConversation();
await this.chat.clearActiveConversation();
this.cancelRename();
}
protected exportActiveConversation(): void {
protected async exportActiveConversation(): Promise<void> {
const active = this.chat.activeConversation();
if (!active) {
return;
}
const blob = new Blob([this.chat.exportConversation(active)], { type: 'text/markdown' });
const blob = new Blob([await this.chat.exportConversation(active)], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
@ -1095,12 +1100,12 @@ export class DashboardComponent implements AfterViewChecked {
URL.revokeObjectURL(url);
}
protected deleteConversation(conversation: Conversation, event: Event): void {
protected async deleteConversation(conversation: Conversation, event: Event): Promise<void> {
event.stopPropagation();
if (!this.confirmDeleteConversation(conversation.title)) {
return;
}
this.chat.deleteConversation(conversation.id);
await this.chat.deleteConversation(conversation.id);
this.cancelRename();
}
@ -1227,7 +1232,7 @@ export class DashboardComponent implements AfterViewChecked {
if (this.editingConversationId() !== id) {
return;
}
this.chat.renameConversation(id, this.renameDraft());
void this.chat.renameConversation(id, this.renameDraft());
this.cancelRename();
}
@ -1368,7 +1373,10 @@ export class DashboardComponent implements AfterViewChecked {
);
}
} catch (error) {
const message = error instanceof Error ? error.message : `Supported image formats: ${SUPPORTED_CHAT_IMAGE_LABEL}.`;
const message =
error instanceof Error
? error.message
: `Supported image formats: ${SUPPORTED_CHAT_IMAGE_LABEL}.`;
this.showComposerNotice(message);
}
}
@ -1669,6 +1677,9 @@ function toolRuntime(tool: ToolInvocation): string {
return '';
}
const duration = Math.max(new Date(tool.endedAt).getTime() - new Date(tool.startedAt).getTime(), 0);
const duration = Math.max(
new Date(tool.endedAt).getTime() - new Date(tool.startedAt).getTime(),
0,
);
return `${(duration / 1000).toFixed(1)}s`;
}

File diff suppressed because it is too large Load diff