Bridge chat UI to CoreGUI actions
This commit is contained in:
parent
8c75e1c84b
commit
ae02c8574b
6 changed files with 1042 additions and 140 deletions
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue