feat(web): support image messages in pico chat (#2299)
This commit is contained in:
parent
f3ad5d9305
commit
f2a19ab947
21 changed files with 1009 additions and 79 deletions
|
|
@ -602,14 +602,16 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
// Add conversation history
|
// Add conversation history
|
||||||
messages = append(messages, history...)
|
messages = append(messages, history...)
|
||||||
|
|
||||||
// Add current user message
|
// Add current user message. Media-only turns must still be preserved so
|
||||||
if strings.TrimSpace(currentMessage) != "" {
|
// multimodal providers receive the uploaded image even when the user sends
|
||||||
|
// no accompanying text.
|
||||||
|
if strings.TrimSpace(currentMessage) != "" || len(media) > 0 {
|
||||||
msg := providers.Message{
|
msg := providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: currentMessage,
|
Content: currentMessage,
|
||||||
}
|
}
|
||||||
if len(media) > 0 {
|
if len(media) > 0 {
|
||||||
msg.Media = media
|
msg.Media = append([]string(nil), media...)
|
||||||
}
|
}
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -707,6 +707,38 @@ func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildMessages_IncludesMediaOnlyCurrentMessage(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
msgs := cb.BuildMessages(
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
[]string{"data:image/png;base64,abc123"},
|
||||||
|
"pico",
|
||||||
|
"chat-1",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("len(msgs) = %d, want 2", len(msgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
userMsg := msgs[1]
|
||||||
|
if userMsg.Role != "user" {
|
||||||
|
t.Fatalf("userMsg.Role = %q, want %q", userMsg.Role, "user")
|
||||||
|
}
|
||||||
|
if userMsg.Content != "" {
|
||||||
|
t.Fatalf("userMsg.Content = %q, want empty string", userMsg.Content)
|
||||||
|
}
|
||||||
|
if len(userMsg.Media) != 1 || userMsg.Media[0] != "data:image/png;base64,abc123" {
|
||||||
|
t.Fatalf("userMsg.Media = %#v, want image payload", userMsg.Media)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BenchmarkBuildMessagesWithCache measures caching performance.
|
// BenchmarkBuildMessagesWithCache measures caching performance.
|
||||||
func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||||
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
|
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
|
||||||
|
|
|
||||||
|
|
@ -262,3 +262,57 @@ func TestSend_ClosedConnection(t *testing.T) {
|
||||||
|
|
||||||
ch.Stop(ctx)
|
ch.Stop(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseInlineImageMedia_Valid(t *testing.T) {
|
||||||
|
media, err := parseInlineImageMedia(map[string]any{
|
||||||
|
"media": []any{
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInlineImageMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(media) != 1 {
|
||||||
|
t.Fatalf("len(media) = %d, want 1", len(media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) {
|
||||||
|
mb := bus.NewMessageBus()
|
||||||
|
ch, err := NewPicoChannel(config.PicoConfig{
|
||||||
|
Token: *config.NewSecureString("test-token"),
|
||||||
|
}, mb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPicoChannel() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := ch.Start(ctx); err != nil {
|
||||||
|
t.Fatalf("Start() error = %v", err)
|
||||||
|
}
|
||||||
|
defer ch.Stop(ctx)
|
||||||
|
|
||||||
|
pc := &picoConn{id: "conn-1", sessionID: "sess-1"}
|
||||||
|
ch.handleMessageSend(pc, PicoMessage{
|
||||||
|
ID: "msg-1",
|
||||||
|
Payload: map[string]any{
|
||||||
|
"media": []any{
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-mb.InboundChan():
|
||||||
|
if msg.Content != "" {
|
||||||
|
t.Fatalf("msg.Content = %q, want empty", msg.Content)
|
||||||
|
}
|
||||||
|
if len(msg.Media) != 1 || !strings.HasPrefix(msg.Media[0], "data:image/png;base64,") {
|
||||||
|
t.Fatalf("msg.Media = %#v, want inline image payload", msg.Media)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("timed out waiting for inbound media message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package pico
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -30,6 +31,14 @@ type picoConn struct {
|
||||||
cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop)
|
cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allowedInlineImageMIMETypes = map[string]struct{}{
|
||||||
|
"image/jpeg": {},
|
||||||
|
"image/png": {},
|
||||||
|
"image/gif": {},
|
||||||
|
"image/webp": {},
|
||||||
|
"image/bmp": {},
|
||||||
|
}
|
||||||
|
|
||||||
// writeJSON sends a JSON message to the connection with write locking.
|
// writeJSON sends a JSON message to the connection with write locking.
|
||||||
func (pc *picoConn) writeJSON(v any) error {
|
func (pc *picoConn) writeJSON(v any) error {
|
||||||
if pc.closed.Load() {
|
if pc.closed.Load() {
|
||||||
|
|
@ -516,6 +525,9 @@ func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
|
||||||
case TypeMessageSend:
|
case TypeMessageSend:
|
||||||
c.handleMessageSend(pc, msg)
|
c.handleMessageSend(pc, msg)
|
||||||
|
|
||||||
|
case TypeMediaSend:
|
||||||
|
c.handleMessageSend(pc, msg)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||||
pc.writeJSON(errMsg)
|
pc.writeJSON(errMsg)
|
||||||
|
|
@ -525,8 +537,19 @@ func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
|
||||||
// handleMessageSend processes an inbound message.send from a client.
|
// handleMessageSend processes an inbound message.send from a client.
|
||||||
func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
||||||
content, _ := msg.Payload["content"].(string)
|
content, _ := msg.Payload["content"].(string)
|
||||||
if strings.TrimSpace(content) == "" {
|
media, err := parseInlineImageMedia(msg.Payload)
|
||||||
errMsg := newError("empty_content", "message content is empty")
|
if err != nil {
|
||||||
|
errMsg := newErrorWithPayload("invalid_media", err.Error(), map[string]any{
|
||||||
|
"request_id": msg.ID,
|
||||||
|
})
|
||||||
|
pc.writeJSON(errMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(content) == "" && len(media) == 0 {
|
||||||
|
errMsg := newErrorWithPayload("empty_content", "message content is empty", map[string]any{
|
||||||
|
"request_id": msg.ID,
|
||||||
|
})
|
||||||
pc.writeJSON(errMsg)
|
pc.writeJSON(errMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -550,6 +573,7 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
||||||
logger.DebugCF("pico", "Received message", map[string]any{
|
logger.DebugCF("pico", "Received message", map[string]any{
|
||||||
"session_id": sessionID,
|
"session_id": sessionID,
|
||||||
"preview": truncate(content, 50),
|
"preview": truncate(content, 50),
|
||||||
|
"media": len(media),
|
||||||
})
|
})
|
||||||
|
|
||||||
sender := bus.SenderInfo{
|
sender := bus.SenderInfo{
|
||||||
|
|
@ -562,7 +586,7 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender)
|
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, media, metadata, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncate truncates a string to maxLen runes.
|
// truncate truncates a string to maxLen runes.
|
||||||
|
|
@ -573,3 +597,99 @@ func truncate(s string, maxLen int) string {
|
||||||
}
|
}
|
||||||
return string(runes[:maxLen]) + "..."
|
return string(runes[:maxLen]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseInlineImageMedia(payload map[string]any) ([]string, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := payload["media"]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch values := raw.(type) {
|
||||||
|
case []any:
|
||||||
|
media := make([]string, 0, len(values))
|
||||||
|
for i, item := range values {
|
||||||
|
value, err := inlineImageValue(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
if err := validateInlineImageDataURL(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
media = append(media, value)
|
||||||
|
}
|
||||||
|
return media, nil
|
||||||
|
case []string:
|
||||||
|
media := make([]string, 0, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if err := validateInlineImageDataURL(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
media = append(media, value)
|
||||||
|
}
|
||||||
|
return media, nil
|
||||||
|
case string:
|
||||||
|
value := strings.TrimSpace(values)
|
||||||
|
if err := validateInlineImageDataURL(value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{value}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("media must be a string or array of strings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inlineImageValue(item any) (string, error) {
|
||||||
|
switch value := item.(type) {
|
||||||
|
case string:
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", fmt.Errorf("image payload is empty")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
case map[string]any:
|
||||||
|
for _, key := range []string{"url", "data_url"} {
|
||||||
|
if raw, ok := value[key].(string); ok && strings.TrimSpace(raw) != "" {
|
||||||
|
return strings.TrimSpace(raw), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("image payload must include url or data_url")
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("image payload must be a string or object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInlineImageDataURL(mediaURL string) error {
|
||||||
|
if mediaURL == "" {
|
||||||
|
return fmt.Errorf("image payload is empty")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(mediaURL, "data:image/") {
|
||||||
|
return fmt.Errorf("only inline image data URLs are supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
header, data, found := strings.Cut(mediaURL, ",")
|
||||||
|
if !found || strings.TrimSpace(data) == "" {
|
||||||
|
return fmt.Errorf("image data URL is malformed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(header, ";base64") {
|
||||||
|
return fmt.Errorf("image data URL must be base64 encoded")
|
||||||
|
}
|
||||||
|
mimeType, _, _ := strings.Cut(strings.TrimPrefix(header, "data:"), ";")
|
||||||
|
if _, ok := allowedInlineImageMIMETypes[mimeType]; !ok {
|
||||||
|
return fmt.Errorf("unsupported image format: %s", mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
data = strings.TrimSpace(data)
|
||||||
|
if base64.StdEncoding.DecodedLen(len(data)) > config.DefaultMaxMediaSize {
|
||||||
|
return fmt.Errorf("image exceeds %d byte limit", config.DefaultMaxMediaSize)
|
||||||
|
}
|
||||||
|
if _, err := base64.StdEncoding.DecodeString(data); err != nil {
|
||||||
|
return fmt.Errorf("invalid base64 image data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,18 @@ func newMessage(msgType string, payload map[string]any) PicoMessage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newError creates an error PicoMessage.
|
func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage {
|
||||||
func newError(code, message string) PicoMessage {
|
payload := map[string]any{
|
||||||
return newMessage(TypeError, map[string]any{
|
|
||||||
"code": code,
|
"code": code,
|
||||||
"message": message,
|
"message": message,
|
||||||
})
|
}
|
||||||
|
for key, value := range extra {
|
||||||
|
payload[key] = value
|
||||||
|
}
|
||||||
|
return newMessage(TypeError, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newError creates an error PicoMessage.
|
||||||
|
func newError(code, message string) PicoMessage {
|
||||||
|
return newErrorWithPayload(code, message, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ type sessionListItem struct {
|
||||||
Updated string `json:"updated"`
|
Updated string `json:"updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sessionChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Media []string `json:"media,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type sessionMetaFile struct {
|
type sessionMetaFile struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
|
|
@ -62,8 +68,12 @@ type sessionMetaFile struct {
|
||||||
const (
|
const (
|
||||||
picoSessionPrefix = "agent:main:pico:direct:pico:"
|
picoSessionPrefix = "agent:main:pico:direct:pico:"
|
||||||
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
|
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
|
||||||
maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB
|
// Keep the session API aligned with the shared JSONL store reader limit in
|
||||||
maxSessionTitleRunes = 60
|
// pkg/memory/jsonl.go so oversized lines fail consistently everywhere.
|
||||||
|
maxSessionJSONLLineSize = 10 * 1024 * 1024
|
||||||
|
maxSessionTitleRunes = 60
|
||||||
|
|
||||||
|
handledToolResponseSummaryText = "Requested output delivered via tool attachment."
|
||||||
)
|
)
|
||||||
|
|
||||||
// extractPicoSessionID extracts the session UUID from a full session key.
|
// extractPicoSessionID extracts the session UUID from a full session key.
|
||||||
|
|
@ -195,32 +205,21 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
|
||||||
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
|
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
|
||||||
preview := ""
|
preview := ""
|
||||||
for _, msg := range sess.Messages {
|
for _, msg := range sess.Messages {
|
||||||
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
|
if msg.Role == "user" {
|
||||||
preview = msg.Content
|
preview = sessionMessagePreview(msg)
|
||||||
|
}
|
||||||
|
if preview != "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
title := strings.TrimSpace(sess.Summary)
|
|
||||||
if title == "" {
|
|
||||||
title = preview
|
|
||||||
}
|
|
||||||
|
|
||||||
title = truncateRunes(title, maxSessionTitleRunes)
|
|
||||||
preview = truncateRunes(preview, maxSessionTitleRunes)
|
preview = truncateRunes(preview, maxSessionTitleRunes)
|
||||||
|
|
||||||
if preview == "" {
|
if preview == "" {
|
||||||
preview = "(empty)"
|
preview = "(empty)"
|
||||||
}
|
}
|
||||||
if title == "" {
|
title := preview
|
||||||
title = preview
|
|
||||||
}
|
|
||||||
|
|
||||||
validMessageCount := 0
|
validMessageCount := len(visibleSessionMessages(sess.Messages))
|
||||||
for _, msg := range sess.Messages {
|
|
||||||
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
|
|
||||||
validMessageCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionListItem{
|
return sessionListItem{
|
||||||
ID: sessionID,
|
ID: sessionID,
|
||||||
|
|
@ -247,6 +246,99 @@ func truncateRunes(s string, maxLen int) string {
|
||||||
return string(runes[:maxLen]) + "..."
|
return string(runes[:maxLen]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sessionMessageVisible(msg providers.Message) bool {
|
||||||
|
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionMessagePreview(msg providers.Message) string {
|
||||||
|
if content := strings.TrimSpace(msg.Content); content != "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
if len(msg.Media) > 0 {
|
||||||
|
return "[image]"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
|
||||||
|
transcript := make([]sessionChatMessage, 0, len(messages))
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
switch msg.Role {
|
||||||
|
case "user":
|
||||||
|
if sessionMessageVisible(msg) {
|
||||||
|
transcript = append(transcript, sessionChatMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: msg.Content,
|
||||||
|
Media: append([]string(nil), msg.Media...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assistant":
|
||||||
|
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
|
||||||
|
if len(visibleToolMessages) > 0 {
|
||||||
|
transcript = append(transcript, visibleToolMessages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pico web chat can persist both visible `message` tool output and a
|
||||||
|
// later plain assistant reply in the same turn. Hide only the fixed
|
||||||
|
// internal summary that marks handled tool delivery.
|
||||||
|
if len(visibleToolMessages) > 0 || !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript = append(transcript, sessionChatMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: msg.Content,
|
||||||
|
Media: append([]string(nil), msg.Media...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcript
|
||||||
|
}
|
||||||
|
|
||||||
|
func assistantMessageInternalOnly(msg providers.Message) bool {
|
||||||
|
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
|
||||||
|
if len(toolCalls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||||
|
for _, tc := range toolCalls {
|
||||||
|
name := tc.Name
|
||||||
|
argsJSON := ""
|
||||||
|
if tc.Function != nil {
|
||||||
|
if name == "" {
|
||||||
|
name = tc.Function.Name
|
||||||
|
}
|
||||||
|
argsJSON = tc.Function.Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "message":
|
||||||
|
var args struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(args.Content) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messages = append(messages, sessionChatMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: args.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
// sessionsDir resolves the path to the gateway's session storage directory.
|
// sessionsDir resolves the path to the gateway's session storage directory.
|
||||||
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
|
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
|
||||||
func (h *Handler) sessionsDir() (string, error) {
|
func (h *Handler) sessionsDir() (string, error) {
|
||||||
|
|
@ -437,22 +529,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to a simpler format for the frontend
|
messages := visibleSessionMessages(sess.Messages)
|
||||||
type chatMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := make([]chatMessage, 0, len(sess.Messages))
|
|
||||||
for _, msg := range sess.Messages {
|
|
||||||
// Only include user and assistant messages that have actual content
|
|
||||||
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
|
|
||||||
messages = append(messages, chatMessage{
|
|
||||||
Role: msg.Role,
|
|
||||||
Content: msg.Content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -87,15 +88,19 @@ func TestHandleListSessions_JSONLStorage(t *testing.T) {
|
||||||
if items[0].MessageCount != 2 {
|
if items[0].MessageCount != 2 {
|
||||||
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
|
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
|
||||||
}
|
}
|
||||||
if items[0].Title != "JSONL-backed session" {
|
if items[0].Title != "Explain why the history API is empty after migration." {
|
||||||
t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session")
|
t.Fatalf(
|
||||||
|
"items[0].Title = %q, want %q",
|
||||||
|
items[0].Title,
|
||||||
|
"Explain why the history API is empty after migration.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if items[0].Preview != "Explain why the history API is empty after migration." {
|
if items[0].Preview != "Explain why the history API is empty after migration." {
|
||||||
t.Fatalf("items[0].Preview = %q", items[0].Preview)
|
t.Fatalf("items[0].Preview = %q", items[0].Preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {
|
func TestHandleListSessions_TitleUsesFirstUserMessage(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
|
|
@ -139,10 +144,7 @@ func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {
|
||||||
if len(items) != 1 {
|
if len(items) != 1 {
|
||||||
t.Fatalf("len(items) = %d, want 1", len(items))
|
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||||
}
|
}
|
||||||
expectedTitle := truncateRunes(
|
expectedTitle := truncateRunes("fallback preview", maxSessionTitleRunes)
|
||||||
"This summary is intentionally longer than sixty characters so it must be truncated in the history menu.",
|
|
||||||
maxSessionTitleRunes,
|
|
||||||
)
|
|
||||||
if items[0].Title != expectedTitle {
|
if items[0].Title != expectedTitle {
|
||||||
t.Fatalf("items[0].Title = %q", items[0].Title)
|
t.Fatalf("items[0].Title = %q", items[0].Title)
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +217,359 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "detail-message-tool"
|
||||||
|
for _, msg := range []providers.Message{
|
||||||
|
{Role: "user", Content: "test"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "message",
|
||||||
|
Arguments: `{"content":"visible tool output"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "tool", Content: "Message sent to pico:pico:detail-message-tool", ToolCallID: "call_1"},
|
||||||
|
{Role: "assistant", Content: handledToolResponseSummaryText},
|
||||||
|
} {
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Messages) != 2 {
|
||||||
|
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
||||||
|
}
|
||||||
|
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
||||||
|
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "detail-message-tool-final-reply"
|
||||||
|
for _, msg := range []providers.Message{
|
||||||
|
{Role: "user", Content: "test"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "message",
|
||||||
|
Arguments: `{"content":"visible tool output"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "tool", Content: "Message sent to pico:pico:detail-message-tool-final-reply", ToolCallID: "call_1"},
|
||||||
|
{Role: "assistant", Content: "final assistant reply"},
|
||||||
|
} {
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool-final-reply", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Messages) != 3 {
|
||||||
|
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||||
|
}
|
||||||
|
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
||||||
|
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1])
|
||||||
|
}
|
||||||
|
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" {
|
||||||
|
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "list-visible-count"
|
||||||
|
for _, msg := range []providers.Message{
|
||||||
|
{Role: "user", Content: "test"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "message",
|
||||||
|
Arguments: `{"content":"visible tool output"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "tool", Content: "Message sent to pico:pico:list-visible-count", ToolCallID: "call_1"},
|
||||||
|
{Role: "assistant", Content: handledToolResponseSummaryText},
|
||||||
|
} {
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []sessionListItem
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
if items[0].MessageCount != 2 {
|
||||||
|
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "detail-media-only"
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Media: []string{"data:image/png;base64,abc123"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage(user) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-media-only", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Media []string `json:"media"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Messages) != 1 {
|
||||||
|
t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
|
||||||
|
}
|
||||||
|
if resp.Messages[0].Role != "user" || len(resp.Messages[0].Media) != 1 {
|
||||||
|
t.Fatalf("message = %#v, want user message with media", resp.Messages[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSessions_SupportsJSONLMessagesUpToStoreCap(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "detail-large-jsonl"
|
||||||
|
largeContent := strings.Repeat("x", 9*1024*1024)
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: largeContent,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
listRec := httptest.NewRecorder()
|
||||||
|
listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
|
||||||
|
mux.ServeHTTP(listRec, listReq)
|
||||||
|
|
||||||
|
if listRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []sessionListItem
|
||||||
|
if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("list Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRec := httptest.NewRecorder()
|
||||||
|
detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-large-jsonl", nil)
|
||||||
|
mux.ServeHTTP(detailRec, detailReq)
|
||||||
|
|
||||||
|
if detailRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf(
|
||||||
|
"detail status = %d, want %d, body=%s",
|
||||||
|
detailRec.Code,
|
||||||
|
http.StatusOK,
|
||||||
|
detailRec.Body.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(detailRec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("detail Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Messages) != 1 {
|
||||||
|
t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
|
||||||
|
}
|
||||||
|
if resp.Messages[0].Role != "user" {
|
||||||
|
t.Fatalf("resp.Messages[0].Role = %q, want %q", resp.Messages[0].Role, "user")
|
||||||
|
}
|
||||||
|
if got := len(resp.Messages[0].Content); got != len(largeContent) {
|
||||||
|
t.Fatalf("len(resp.Messages[0].Content) = %d, want %d", got, len(largeContent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListSessions_UsesImagePreviewForMediaOnlyMessage(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
dir := sessionsTestDir(t, configPath)
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := picoSessionPrefix + "preview-media-only"
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Media: []string{"data:image/png;base64,abc123"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []sessionListItem
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Preview != "[image]" {
|
||||||
|
t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "[image]")
|
||||||
|
}
|
||||||
|
if items[0].MessageCount != 1 {
|
||||||
|
t.Fatalf("items[0].MessageCount = %d, want 1", items[0].MessageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
|
func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,12 @@ export default defineConfig([
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ["src/routes/**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
// TanStack Router route modules must export Route objects, so this rule
|
||||||
|
// produces false positives for framework-managed files.
|
||||||
|
"react-refresh/only-export-components": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// Sessions API — list and retrieve chat session history
|
|
||||||
|
|
||||||
import { launcherFetch } from "@/api/http"
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
|
|
@ -13,7 +11,11 @@ export interface SessionSummary {
|
||||||
|
|
||||||
export interface SessionDetail {
|
export interface SessionDetail {
|
||||||
id: string
|
id: string
|
||||||
messages: { role: "user" | "assistant"; content: string }[]
|
messages: {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
media?: string[]
|
||||||
|
}[]
|
||||||
summary: string
|
summary: string
|
||||||
created: string
|
created: string
|
||||||
updated: string
|
updated: string
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export function AssistantMessage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
|
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
|
||||||
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed">
|
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed [overflow-wrap:anywhere] break-words">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,34 @@
|
||||||
import { IconArrowUp } from "@tabler/icons-react"
|
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
|
||||||
import type { KeyboardEvent } from "react"
|
import type { KeyboardEvent } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import TextareaAutosize from "react-textarea-autosize"
|
import TextareaAutosize from "react-textarea-autosize"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { ChatAttachment } from "@/store/chat"
|
||||||
|
|
||||||
interface ChatComposerProps {
|
interface ChatComposerProps {
|
||||||
input: string
|
input: string
|
||||||
|
attachments: ChatAttachment[]
|
||||||
onInputChange: (value: string) => void
|
onInputChange: (value: string) => void
|
||||||
|
onAddImages: () => void
|
||||||
|
onRemoveAttachment: (index: number) => void
|
||||||
onSend: () => void
|
onSend: () => void
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
hasDefaultModel: boolean
|
hasDefaultModel: boolean
|
||||||
|
canSend: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatComposer({
|
export function ChatComposer({
|
||||||
input,
|
input,
|
||||||
|
attachments,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
|
onAddImages,
|
||||||
|
onRemoveAttachment,
|
||||||
onSend,
|
onSend,
|
||||||
isConnected,
|
isConnected,
|
||||||
hasDefaultModel,
|
hasDefaultModel,
|
||||||
|
canSend,
|
||||||
}: ChatComposerProps) {
|
}: ChatComposerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const canInput = isConnected && hasDefaultModel
|
const canInput = isConnected && hasDefaultModel
|
||||||
|
|
@ -35,6 +44,32 @@ export function ChatComposer({
|
||||||
return (
|
return (
|
||||||
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
|
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
|
||||||
<div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
|
<div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2 px-2">
|
||||||
|
{attachments.map((attachment, index) => (
|
||||||
|
<div
|
||||||
|
key={`${attachment.url}-${index}`}
|
||||||
|
className="bg-background relative h-20 w-20 overflow-hidden rounded-xl border"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.filename || t("chat.uploadedImage")}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemoveAttachment(index)}
|
||||||
|
className="bg-background/85 text-foreground absolute top-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm transition hover:bg-white"
|
||||||
|
aria-label={t("chat.removeImage")}
|
||||||
|
title={t("chat.removeImage")}
|
||||||
|
>
|
||||||
|
<IconX className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
|
|
@ -42,7 +77,7 @@ export function ChatComposer({
|
||||||
placeholder={t("chat.placeholder")}
|
placeholder={t("chat.placeholder")}
|
||||||
disabled={!canInput}
|
disabled={!canInput}
|
||||||
className={cn(
|
className={cn(
|
||||||
"placeholder:text-muted-foreground max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
||||||
!canInput && "cursor-not-allowed",
|
!canInput && "cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
minRows={1}
|
minRows={1}
|
||||||
|
|
@ -50,13 +85,27 @@ export function ChatComposer({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center justify-between px-1">
|
<div className="mt-2 flex items-center justify-between px-1">
|
||||||
<div className="flex items-center gap-1">{/* action buttons */}</div>
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full"
|
||||||
|
onClick={onAddImages}
|
||||||
|
disabled={!canInput}
|
||||||
|
aria-label={t("chat.attachImage")}
|
||||||
|
title={t("chat.attachImage")}
|
||||||
|
>
|
||||||
|
<IconPhotoPlus className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
||||||
onClick={onSend}
|
onClick={onSend}
|
||||||
disabled={!input.trim() || !canInput}
|
disabled={!canSend}
|
||||||
>
|
>
|
||||||
<IconArrowUp className="size-4" />
|
<IconArrowUp className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { IconPlus } from "@tabler/icons-react"
|
import { IconPlus } from "@tabler/icons-react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { type ChangeEvent, useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { AssistantMessage } from "@/components/chat/assistant-message"
|
import { AssistantMessage } from "@/components/chat/assistant-message"
|
||||||
import { ChatComposer } from "@/components/chat/chat-composer"
|
import { ChatComposer } from "@/components/chat/chat-composer"
|
||||||
|
|
@ -15,13 +16,42 @@ import { useChatModels } from "@/hooks/use-chat-models"
|
||||||
import { useGateway } from "@/hooks/use-gateway"
|
import { useGateway } from "@/hooks/use-gateway"
|
||||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||||
|
import type { ChatAttachment } from "@/store/chat"
|
||||||
|
|
||||||
|
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
||||||
|
const MAX_IMAGE_SIZE_LABEL = "7 MB"
|
||||||
|
const ALLOWED_IMAGE_TYPES = new Set([
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/bmp",
|
||||||
|
])
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === "string") {
|
||||||
|
resolve(reader.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error("Failed to read file"))
|
||||||
|
}
|
||||||
|
reader.onerror = () =>
|
||||||
|
reject(reader.error || new Error("Failed to read file"))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
const [hasScrolled, setHasScrolled] = useState(false)
|
const [hasScrolled, setHasScrolled] = useState(false)
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -80,18 +110,84 @@ export function ChatPage() {
|
||||||
}, [messages, isTyping, isAtBottom])
|
}, [messages, isTyping, isAtBottom])
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!input.trim() || !canSend) return
|
if ((!input.trim() && attachments.length === 0) || !canSend) return
|
||||||
if (sendMessage(input.trim())) {
|
if (
|
||||||
|
sendMessage({
|
||||||
|
content: input,
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
|
) {
|
||||||
setInput("")
|
setInput("")
|
||||||
|
setAttachments([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddImages = () => {
|
||||||
|
if (!canSend) return
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveAttachment = (index: number) => {
|
||||||
|
setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageSelection = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? [])
|
||||||
|
event.target.value = ""
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttachments: ChatAttachment[] = []
|
||||||
|
for (const file of files) {
|
||||||
|
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||||
|
toast.error(
|
||||||
|
t("chat.invalidImage", {
|
||||||
|
name: file.name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
toast.error(
|
||||||
|
t("chat.imageTooLarge", {
|
||||||
|
name: file.name,
|
||||||
|
size: MAX_IMAGE_SIZE_LABEL,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
nextAttachments.push({
|
||||||
|
type: "image",
|
||||||
|
filename: file.name,
|
||||||
|
url: await readFileAsDataUrl(file),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
t("chat.imageReadFailed", {
|
||||||
|
name: file.name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextAttachments.length > 0) {
|
||||||
|
setAttachments(nextAttachments.slice(0, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = canSend && (Boolean(input.trim()) || attachments.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background/95 flex h-full flex-col">
|
<div className="bg-background/95 flex h-full flex-col">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t("navigation.chat")}
|
title={t("navigation.chat")}
|
||||||
className={`transition-shadow ${
|
className={`transition-shadow ${
|
||||||
hasScrolled ? "shadow-sm" : "shadow-none"
|
hasScrolled ? "shadow-xs" : "shadow-none"
|
||||||
}`}
|
}`}
|
||||||
titleExtra={
|
titleExtra={
|
||||||
hasAvailableModels && (
|
hasAvailableModels && (
|
||||||
|
|
@ -154,7 +250,10 @@ export function ChatPage() {
|
||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UserMessage content={msg.content} />
|
<UserMessage
|
||||||
|
content={msg.content}
|
||||||
|
attachments={msg.attachments}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -163,12 +262,24 @@ export function ChatPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
<ChatComposer
|
<ChatComposer
|
||||||
input={input}
|
input={input}
|
||||||
|
attachments={attachments}
|
||||||
onInputChange={setInput}
|
onInputChange={setInput}
|
||||||
|
onAddImages={handleAddImages}
|
||||||
|
onRemoveAttachment={handleRemoveAttachment}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
isConnected={isChatConnected}
|
isConnected={isChatConnected}
|
||||||
hasDefaultModel={Boolean(defaultModelName)}
|
hasDefaultModel={Boolean(defaultModelName)}
|
||||||
|
canSend={canSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export function SessionHistoryMenu({
|
||||||
onClick={() => onSwitchSession(session.id)}
|
onClick={() => onSwitchSession(session.id)}
|
||||||
>
|
>
|
||||||
<span className="line-clamp-1 text-sm font-medium">
|
<span className="line-clamp-1 text-sm font-medium">
|
||||||
{session.title || session.preview}
|
{session.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{t("chat.messagesCount", {
|
{t("chat.messagesCount", {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,36 @@
|
||||||
|
import type { ChatAttachment } from "@/store/chat"
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
content: string
|
content: string
|
||||||
|
attachments?: ChatAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMessage({ content }: UserMessageProps) {
|
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||||
|
const hasText = content.trim().length > 0
|
||||||
|
const imageAttachments = attachments.filter(
|
||||||
|
(attachment) => attachment.type === "image",
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-end gap-1.5">
|
<div className="flex w-full flex-col items-end gap-1.5">
|
||||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed whitespace-pre-wrap text-white shadow-sm break-words">
|
{imageAttachments.length > 0 && (
|
||||||
{content}
|
<div className="flex max-w-[70%] flex-wrap justify-end gap-2">
|
||||||
</div>
|
{imageAttachments.map((attachment, index) => (
|
||||||
|
<img
|
||||||
|
key={`${attachment.url}-${index}`}
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.filename || "Uploaded image"}
|
||||||
|
className="max-h-72 max-w-full object-cover"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasText && (
|
||||||
|
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed wrap-break-word whitespace-pre-wrap text-white shadow-sm">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ import {
|
||||||
normalizeWsUrlForBrowser,
|
normalizeWsUrlForBrowser,
|
||||||
} from "@/features/chat/websocket"
|
} from "@/features/chat/websocket"
|
||||||
import i18n from "@/i18n"
|
import i18n from "@/i18n"
|
||||||
import { getChatState, updateChatStore } from "@/store/chat"
|
import {
|
||||||
|
type ChatAttachment,
|
||||||
|
getChatState,
|
||||||
|
updateChatStore,
|
||||||
|
} from "@/store/chat"
|
||||||
import { type GatewayState, gatewayAtom } from "@/store/gateway"
|
import { type GatewayState, gatewayAtom } from "@/store/gateway"
|
||||||
|
|
||||||
const store = getDefaultStore()
|
const store = getDefaultStore()
|
||||||
|
|
@ -324,19 +328,43 @@ export async function hydrateActiveSession() {
|
||||||
return hydratePromise
|
return hydratePromise
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendChatMessage(content: string) {
|
interface SendChatMessageInput {
|
||||||
|
content: string
|
||||||
|
attachments?: ChatAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendChatMessage({
|
||||||
|
content,
|
||||||
|
attachments = [],
|
||||||
|
}: SendChatMessageInput) {
|
||||||
if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
|
if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
|
||||||
console.warn("WebSocket not connected")
|
console.warn("WebSocket not connected")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedContent = content.trim()
|
||||||
|
const normalizedAttachments = attachments
|
||||||
|
.filter((attachment) => attachment.type === "image" && attachment.url)
|
||||||
|
.map((attachment) => ({ ...attachment }))
|
||||||
|
|
||||||
|
if (!normalizedContent && normalizedAttachments.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const socket = wsRef
|
const socket = wsRef
|
||||||
const id = `msg-${++msgIdCounter}-${Date.now()}`
|
const id = `msg-${++msgIdCounter}-${Date.now()}`
|
||||||
|
|
||||||
updateChatStore((prev) => ({
|
updateChatStore((prev) => ({
|
||||||
messages: [
|
messages: [
|
||||||
...prev.messages,
|
...prev.messages,
|
||||||
{ id, role: "user", content, timestamp: Date.now() },
|
{
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
content: normalizedContent,
|
||||||
|
attachments:
|
||||||
|
normalizedAttachments.length > 0 ? normalizedAttachments : undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
isTyping: true,
|
isTyping: true,
|
||||||
}))
|
}))
|
||||||
|
|
@ -346,7 +374,10 @@ export function sendChatMessage(content: string) {
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "message.send",
|
type: "message.send",
|
||||||
id,
|
id,
|
||||||
payload: { content },
|
payload: {
|
||||||
|
content: normalizedContent,
|
||||||
|
media: normalizedAttachments.map((attachment) => attachment.url),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
import { getSessionHistory } from "@/api/sessions"
|
import { getSessionHistory } from "@/api/sessions"
|
||||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||||
import type { ChatMessage } from "@/store/chat"
|
import type { ChatAttachment, ChatMessage } from "@/store/chat"
|
||||||
|
|
||||||
|
function toChatAttachments(media?: string[]): ChatAttachment[] | undefined {
|
||||||
|
if (!media || media.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = media
|
||||||
|
.filter((item) => item.startsWith("data:image/"))
|
||||||
|
.map((url) => ({ type: "image" as const, url }))
|
||||||
|
|
||||||
|
return attachments.length > 0 ? attachments : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSessionMessages(
|
export async function loadSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|
@ -12,6 +24,7 @@ export async function loadSessionMessages(
|
||||||
id: `hist-${index}-${Date.now()}`,
|
id: `hist-${index}-${Date.now()}`,
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
|
attachments: toChatAttachments(message.media),
|
||||||
timestamp: fallbackTime,
|
timestamp: fallbackTime,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -31,9 +44,13 @@ function normalizeMessageTimestamp(timestamp: number | string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageSignature(message: ChatMessage): string {
|
function messageSignature(message: ChatMessage): string {
|
||||||
|
const attachmentSignature = (message.attachments ?? [])
|
||||||
|
.map((attachment) => `${attachment.type}\u0001${attachment.url}`)
|
||||||
|
.join("\u0002")
|
||||||
|
|
||||||
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
)}`
|
)}\u0000${attachmentSignature}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparableTimestamp(timestamp: number | string): number {
|
function comparableTimestamp(timestamp: number | string): number {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||||
import { updateChatStore } from "@/store/chat"
|
import { updateChatStore } from "@/store/chat"
|
||||||
|
|
||||||
|
|
@ -67,10 +69,24 @@ export function handlePicoMessage(
|
||||||
updateChatStore({ isTyping: false })
|
updateChatStore({ isTyping: false })
|
||||||
break
|
break
|
||||||
|
|
||||||
case "error":
|
case "error": {
|
||||||
|
const requestId =
|
||||||
|
typeof payload.request_id === "string" ? payload.request_id : ""
|
||||||
|
const errorMessage =
|
||||||
|
typeof payload.message === "string" ? payload.message : ""
|
||||||
|
|
||||||
console.error("Pico error:", payload)
|
console.error("Pico error:", payload)
|
||||||
updateChatStore({ isTyping: false })
|
if (errorMessage) {
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
updateChatStore((prev) => ({
|
||||||
|
messages: requestId
|
||||||
|
? prev.messages.filter((msg) => msg.id !== requestId)
|
||||||
|
: prev.messages,
|
||||||
|
isTyping: false,
|
||||||
|
}))
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "pong":
|
case "pong":
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,14 @@ export function useSessionHistory({
|
||||||
const handleDeleteSession = useCallback(
|
const handleDeleteSession = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
const deletedLoadedSession = sessions.some(
|
||||||
|
(session) => session.id === id,
|
||||||
|
)
|
||||||
await deleteSession(id)
|
await deleteSession(id)
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== id))
|
setSessions((prev) => prev.filter((s) => s.id !== id))
|
||||||
|
if (deletedLoadedSession) {
|
||||||
|
setOffset((prev) => Math.max(prev - 1, 0))
|
||||||
|
}
|
||||||
if (id === activeSessionId) {
|
if (id === activeSessionId) {
|
||||||
onDeletedActiveSession()
|
onDeletedActiveSession()
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +103,7 @@ export function useSessionHistory({
|
||||||
console.error("Failed to delete session:", err)
|
console.error("Failed to delete session:", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionId, onDeletedActiveSession],
|
[activeSessionId, onDeletedActiveSession, sessions],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,12 @@
|
||||||
"deleteSession": "Delete session",
|
"deleteSession": "Delete session",
|
||||||
"messagesCount": "{{count}} messages",
|
"messagesCount": "{{count}} messages",
|
||||||
"noModel": "Select model",
|
"noModel": "Select model",
|
||||||
|
"attachImage": "Add images",
|
||||||
|
"removeImage": "Remove image",
|
||||||
|
"uploadedImage": "Uploaded image",
|
||||||
|
"invalidImage": "\"{{name}}\" is not a supported image file.",
|
||||||
|
"imageTooLarge": "\"{{name}}\" exceeds the {{size}} limit.",
|
||||||
|
"imageReadFailed": "Failed to read \"{{name}}\".",
|
||||||
"empty": {
|
"empty": {
|
||||||
"noConfiguredModel": "No Model Configured",
|
"noConfiguredModel": "No Model Configured",
|
||||||
"noConfiguredModelDescription": "You need to configure at least one AI model with an API key before you can start chatting.",
|
"noConfiguredModelDescription": "You need to configure at least one AI model with an API key before you can start chatting.",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,12 @@
|
||||||
"deleteSession": "删除会话",
|
"deleteSession": "删除会话",
|
||||||
"messagesCount": "{{count}} 条消息",
|
"messagesCount": "{{count}} 条消息",
|
||||||
"noModel": "选择模型",
|
"noModel": "选择模型",
|
||||||
|
"attachImage": "添加图片",
|
||||||
|
"removeImage": "移除图片",
|
||||||
|
"uploadedImage": "已上传图片",
|
||||||
|
"invalidImage": "“{{name}}”不是支持的图片文件。",
|
||||||
|
"imageTooLarge": "“{{name}}”超过了 {{size}} 限制。",
|
||||||
|
"imageReadFailed": "读取“{{name}}”失败。",
|
||||||
"empty": {
|
"empty": {
|
||||||
"noConfiguredModel": "尚未配置模型",
|
"noConfiguredModel": "尚未配置模型",
|
||||||
"noConfiguredModelDescription": "请先配置至少一个带有 API Key 的 AI 模型,才能开始对话。",
|
"noConfiguredModelDescription": "请先配置至少一个带有 API Key 的 AI 模型,才能开始对话。",
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,18 @@ import {
|
||||||
writeStoredSessionId,
|
writeStoredSessionId,
|
||||||
} from "@/features/chat/state"
|
} from "@/features/chat/state"
|
||||||
|
|
||||||
|
export interface ChatAttachment {
|
||||||
|
type: "image"
|
||||||
|
url: string
|
||||||
|
filename?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
timestamp: number | string
|
timestamp: number | string
|
||||||
|
attachments?: ChatAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionState =
|
export type ConnectionState =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue