fix(pico): separate thought and normal messages
This commit is contained in:
parent
748ac58dd1
commit
c8bac699fe
15 changed files with 300 additions and 24 deletions
|
|
@ -105,6 +105,8 @@ const (
|
||||||
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
|
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
|
||||||
handledToolResponseSummary = "Requested output delivered via tool attachment."
|
handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||||
sessionKeyAgentPrefix = "agent:"
|
sessionKeyAgentPrefix = "agent:"
|
||||||
|
metadataKeyMessageKind = "message_kind"
|
||||||
|
messageKindThought = "thought"
|
||||||
metadataKeyAccountID = "account_id"
|
metadataKeyAccountID = "account_id"
|
||||||
metadataKeyGuildID = "guild_id"
|
metadataKeyGuildID = "guild_id"
|
||||||
metadataKeyTeamID = "team_id"
|
metadataKeyTeamID = "team_id"
|
||||||
|
|
@ -1622,6 +1624,41 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) {
|
||||||
|
if reasoningContent == "" || chatID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
|
||||||
|
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Channel: "pico",
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: reasoningContent,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
metadataKeyMessageKind: messageKindThought,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||||
|
errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{
|
||||||
|
"channel": "pico",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{
|
||||||
|
"channel": "pico",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) handleReasoning(
|
func (al *AgentLoop) handleReasoning(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
reasoningContent, channelName, channelID string,
|
reasoningContent, channelName, channelID string,
|
||||||
|
|
@ -2223,12 +2260,16 @@ turnLoop:
|
||||||
if reasoningContent == "" {
|
if reasoningContent == "" {
|
||||||
reasoningContent = response.ReasoningContent
|
reasoningContent = response.ReasoningContent
|
||||||
}
|
}
|
||||||
go al.handleReasoning(
|
if ts.channel == "pico" {
|
||||||
turnCtx,
|
al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
||||||
reasoningContent,
|
} else {
|
||||||
ts.channel,
|
go al.handleReasoning(
|
||||||
al.targetReasoningChannelID(ts.channel),
|
turnCtx,
|
||||||
)
|
reasoningContent,
|
||||||
|
ts.channel,
|
||||||
|
al.targetReasoningChannelID(ts.channel),
|
||||||
|
)
|
||||||
|
}
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
EventKindLLMResponse,
|
EventKindLLMResponse,
|
||||||
ts.eventMeta("runTurn", "turn.llm.response"),
|
ts.eventMeta("runTurn", "turn.llm.response"),
|
||||||
|
|
@ -2277,7 +2318,7 @@ turnLoop:
|
||||||
|
|
||||||
if len(response.ToolCalls) == 0 || gracefulTerminal {
|
if len(response.ToolCalls) == 0 || gracefulTerminal {
|
||||||
responseContent := response.Content
|
responseContent := response.Content
|
||||||
if responseContent == "" && response.ReasoningContent != "" {
|
if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" {
|
||||||
responseContent = response.ReasoningContent
|
responseContent = response.ReasoningContent
|
||||||
}
|
}
|
||||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -2660,6 +2660,62 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &reasoningContentProvider{
|
||||||
|
response: "final answer",
|
||||||
|
reasoningContent: "thinking trace",
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "pico",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "pico:test-session",
|
||||||
|
Content: "hello",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "final answer" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "final answer")
|
||||||
|
}
|
||||||
|
|
||||||
|
var thoughtMsg *bus.OutboundMessage
|
||||||
|
deadline := time.After(3 * time.Second)
|
||||||
|
|
||||||
|
for thoughtMsg == nil {
|
||||||
|
select {
|
||||||
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
msg := outbound
|
||||||
|
if msg.Content == "thinking trace" {
|
||||||
|
thoughtMsg = &msg
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("expected thought outbound message for pico")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if thoughtMsg.Channel != "pico" || thoughtMsg.ChatID != "pico:test-session" {
|
||||||
|
t.Fatalf("thought message route = %s/%s, want pico/pico:test-session", thoughtMsg.Channel, thoughtMsg.ChatID)
|
||||||
|
}
|
||||||
|
if thoughtMsg.Metadata[metadataKeyMessageKind] != messageKindThought {
|
||||||
|
t.Fatalf("thought metadata kind = %q, want %q", thoughtMsg.Metadata[metadataKeyMessageKind], messageKindThought)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
|
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
|
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,11 @@ func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
|
func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
|
||||||
content, _ := msg.Payload["content"].(string)
|
if isThoughtPayload(msg.Payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := msg.Payload[PayloadKeyContent].(string)
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +289,7 @@ func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) (
|
||||||
}
|
}
|
||||||
|
|
||||||
outMsg := newMessage(TypeMessageSend, map[string]any{
|
outMsg := newMessage(TypeMessageSend, map[string]any{
|
||||||
"content": msg.Content,
|
PayloadKeyContent: msg.Content,
|
||||||
})
|
})
|
||||||
outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
|
outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
|
||||||
return nil, pc.writeJSON(outMsg)
|
return nil, pc.writeJSON(outMsg)
|
||||||
|
|
|
||||||
|
|
@ -316,3 +316,67 @@ func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) {
|
||||||
t.Fatal("timed out waiting for inbound media message")
|
t.Fatal("timed out waiting for inbound media message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsThoughtPayload(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload map[string]any
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "explicit thought bool",
|
||||||
|
payload: map[string]any{PayloadKeyThought: true},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "thought false",
|
||||||
|
payload: map[string]any{PayloadKeyThought: false},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "thought string ignored",
|
||||||
|
payload: map[string]any{PayloadKeyThought: "true"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default normal",
|
||||||
|
payload: map[string]any{PayloadKeyContent: "hello"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := isThoughtPayload(tt.payload); got != tt.want {
|
||||||
|
t.Fatalf("isThoughtPayload() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) {
|
||||||
|
mb := bus.NewMessageBus()
|
||||||
|
ch, err := NewPicoClientChannel(config.PicoClientConfig{
|
||||||
|
URL: "ws://localhost:8080/ws",
|
||||||
|
}, mb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPicoClientChannel() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.ctx = context.Background()
|
||||||
|
pc := &picoConn{sessionID: "sess-thought"}
|
||||||
|
|
||||||
|
ch.handleServerMessage(pc, PicoMessage{
|
||||||
|
Type: TypeMessageCreate,
|
||||||
|
Payload: map[string]any{
|
||||||
|
PayloadKeyContent: "internal reasoning",
|
||||||
|
PayloadKeyThought: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-mb.InboundChan():
|
||||||
|
t.Fatalf("expected no inbound publish for thought payload, got %+v", msg)
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ var allowedInlineImageMIMETypes = map[string]struct{}{
|
||||||
"image/bmp": {},
|
"image/bmp": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageIsThought(metadata map[string]string) bool {
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(metadata["message_kind"]), MessageKindThought)
|
||||||
|
}
|
||||||
|
|
||||||
// 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() {
|
||||||
|
|
@ -247,9 +254,11 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return nil, channels.ErrNotRunning
|
return nil, channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
isThought := outboundMessageIsThought(msg.Metadata)
|
||||||
|
|
||||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||||
"content": msg.Content,
|
PayloadKeyContent: msg.Content,
|
||||||
|
PayloadKeyThought: isThought,
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil, c.broadcastToSession(msg.ChatID, outMsg)
|
return nil, c.broadcastToSession(msg.ChatID, outMsg)
|
||||||
|
|
@ -288,8 +297,9 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin
|
||||||
|
|
||||||
msgID := uuid.New().String()
|
msgID := uuid.New().String()
|
||||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||||
"content": text,
|
PayloadKeyContent: text,
|
||||||
"message_id": msgID,
|
PayloadKeyThought: false,
|
||||||
|
"message_id": msgID,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := c.broadcastToSession(chatID, outMsg); err != nil {
|
if err := c.broadcastToSession(chatID, outMsg); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ const (
|
||||||
TypePong = "pong"
|
TypePong = "pong"
|
||||||
|
|
||||||
PicoTokenPrefix = "pico-"
|
PicoTokenPrefix = "pico-"
|
||||||
|
|
||||||
|
PayloadKeyContent = "content"
|
||||||
|
PayloadKeyThought = "thought"
|
||||||
|
|
||||||
|
MessageKindThought = "thought"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PicoMessage is the wire format for all Pico Protocol messages.
|
// PicoMessage is the wire format for all Pico Protocol messages.
|
||||||
|
|
@ -39,6 +44,11 @@ func newMessage(msgType string, payload map[string]any) PicoMessage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isThoughtPayload(payload map[string]any) bool {
|
||||||
|
thought, _ := payload[PayloadKeyThought].(bool)
|
||||||
|
return thought
|
||||||
|
}
|
||||||
|
|
||||||
func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage {
|
func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"code": code,
|
"code": code,
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,7 @@ type antigravityJSONResponse struct {
|
||||||
Content struct {
|
Content struct {
|
||||||
Parts []struct {
|
Parts []struct {
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
|
Thought bool `json:"thought,omitempty"`
|
||||||
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||||
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
|
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
|
||||||
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
|
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
|
||||||
|
|
@ -406,6 +407,7 @@ type antigravityJSONResponse struct {
|
||||||
|
|
||||||
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
|
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
|
||||||
var contentParts []string
|
var contentParts []string
|
||||||
|
var reasoningParts []string
|
||||||
var toolCalls []ToolCall
|
var toolCalls []ToolCall
|
||||||
var usage *UsageInfo
|
var usage *UsageInfo
|
||||||
var finishReason string
|
var finishReason string
|
||||||
|
|
@ -433,7 +435,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
|
||||||
for _, candidate := range resp.Candidates {
|
for _, candidate := range resp.Candidates {
|
||||||
for _, part := range candidate.Content.Parts {
|
for _, part := range candidate.Content.Parts {
|
||||||
if part.Text != "" {
|
if part.Text != "" {
|
||||||
contentParts = append(contentParts, part.Text)
|
if part.Thought {
|
||||||
|
reasoningParts = append(reasoningParts, part.Text)
|
||||||
|
} else {
|
||||||
|
contentParts = append(contentParts, part.Text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if part.FunctionCall != nil {
|
if part.FunctionCall != nil {
|
||||||
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||||
|
|
@ -475,10 +481,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LLMResponse{
|
return &LLMResponse{
|
||||||
Content: strings.Join(contentParts, ""),
|
Content: strings.Join(contentParts, ""),
|
||||||
ToolCalls: toolCalls,
|
ReasoningContent: strings.Join(reasoningParts, ""),
|
||||||
FinishReason: mappedFinish,
|
ToolCalls: toolCalls,
|
||||||
Usage: usage,
|
FinishReason: mappedFinish,
|
||||||
|
Usage: usage,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,27 @@ func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
|
||||||
t.Fatalf("expected inferred tool name search_docs, got %q", got)
|
t.Fatalf("expected inferred tool name search_docs, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) {
|
||||||
|
p := &AntigravityProvider{}
|
||||||
|
body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" +
|
||||||
|
"data: [DONE]\n"
|
||||||
|
|
||||||
|
resp, err := p.parseSSEResponse(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSSEResponse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Content != "visible answer" {
|
||||||
|
t.Fatalf("Content = %q, want %q", resp.Content, "visible answer")
|
||||||
|
}
|
||||||
|
if resp.ReasoningContent != "hidden reasoning" {
|
||||||
|
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden reasoning")
|
||||||
|
}
|
||||||
|
if resp.FinishReason != "stop" {
|
||||||
|
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||||
|
}
|
||||||
|
if resp.Usage == nil || resp.Usage.TotalTokens != 216 {
|
||||||
|
t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import rehypeRaw from "rehype-raw"
|
import rehypeRaw from "rehype-raw"
|
||||||
import rehypeSanitize from "rehype-sanitize"
|
import rehypeSanitize from "rehype-sanitize"
|
||||||
|
|
@ -7,16 +8,20 @@ import remarkGfm from "remark-gfm"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface AssistantMessageProps {
|
interface AssistantMessageProps {
|
||||||
content: string
|
content: string
|
||||||
|
isThought?: boolean
|
||||||
timestamp?: string | number
|
timestamp?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantMessage({
|
export function AssistantMessage({
|
||||||
content,
|
content,
|
||||||
|
isThought = false,
|
||||||
timestamp = "",
|
timestamp = "",
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
const formattedTimestamp =
|
const formattedTimestamp =
|
||||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||||
|
|
@ -33,6 +38,12 @@ export function AssistantMessage({
|
||||||
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
|
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>PicoClaw</span>
|
<span>PicoClaw</span>
|
||||||
|
{isThought && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300/80 bg-amber-100/80 px-2 py-0.5 text-[11px] font-medium text-amber-800">
|
||||||
|
<IconBrain className="size-3" />
|
||||||
|
<span>{t("chat.reasoningLabel")}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{formattedTimestamp && (
|
{formattedTimestamp && (
|
||||||
<>
|
<>
|
||||||
<span className="opacity-50">•</span>
|
<span className="opacity-50">•</span>
|
||||||
|
|
@ -42,8 +53,22 @@ export function AssistantMessage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
|
<div
|
||||||
<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">
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-xl border",
|
||||||
|
isThought
|
||||||
|
? "border-amber-200/90 bg-amber-50/70 text-amber-950"
|
||||||
|
: "bg-card text-card-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"prose dark:prose-invert 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 [overflow-wrap:anywhere] break-words",
|
||||||
|
isThought
|
||||||
|
? "prose-p:my-1.5 p-3 text-[13px] leading-relaxed opacity-90"
|
||||||
|
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||||
|
|
@ -54,7 +79,12 @@ export function AssistantMessage({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
|
className={cn(
|
||||||
|
"absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
|
||||||
|
isThought
|
||||||
|
? "bg-amber-100/70 hover:bg-amber-200/80"
|
||||||
|
: "bg-background/50 hover:bg-background/80",
|
||||||
|
)}
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,7 @@ export function ChatPage() {
|
||||||
{msg.role === "assistant" ? (
|
{msg.role === "assistant" ? (
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
|
isThought={msg.kind === "thought"}
|
||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -24,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,
|
||||||
|
kind: message.role === "assistant" ? "normal" : undefined,
|
||||||
attachments: toChatAttachments(message.media),
|
attachments: toChatAttachments(message.media),
|
||||||
timestamp: fallbackTime,
|
timestamp: fallbackTime,
|
||||||
}))
|
}))
|
||||||
|
|
@ -50,7 +51,7 @@ function messageSignature(message: ChatMessage): string {
|
||||||
|
|
||||||
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
)}\u0000${attachmentSignature}`
|
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparableTimestamp(timestamp: number | string): number {
|
function comparableTimestamp(timestamp: number | string): number {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||||
import { updateChatStore } from "@/store/chat"
|
import {
|
||||||
|
type AssistantMessageKind,
|
||||||
|
updateChatStore,
|
||||||
|
} from "@/store/chat"
|
||||||
|
|
||||||
export interface PicoMessage {
|
export interface PicoMessage {
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -11,6 +14,16 @@ export interface PicoMessage {
|
||||||
payload?: Record<string, unknown>
|
payload?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAssistantMessageKind(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): AssistantMessageKind {
|
||||||
|
return payload.thought === true ? "thought" : "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
|
||||||
|
return typeof payload.thought === "boolean"
|
||||||
|
}
|
||||||
|
|
||||||
export function handlePicoMessage(
|
export function handlePicoMessage(
|
||||||
message: PicoMessage,
|
message: PicoMessage,
|
||||||
expectedSessionId: string,
|
expectedSessionId: string,
|
||||||
|
|
@ -25,6 +38,7 @@ export function handlePicoMessage(
|
||||||
case "message.create": {
|
case "message.create": {
|
||||||
const content = (payload.content as string) || ""
|
const content = (payload.content as string) || ""
|
||||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||||
|
const kind = parseAssistantMessageKind(payload)
|
||||||
const timestamp =
|
const timestamp =
|
||||||
message.timestamp !== undefined &&
|
message.timestamp !== undefined &&
|
||||||
Number.isFinite(Number(message.timestamp))
|
Number.isFinite(Number(message.timestamp))
|
||||||
|
|
@ -38,6 +52,7 @@ export function handlePicoMessage(
|
||||||
id: messageId,
|
id: messageId,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content,
|
content,
|
||||||
|
kind,
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -49,13 +64,21 @@ export function handlePicoMessage(
|
||||||
case "message.update": {
|
case "message.update": {
|
||||||
const content = (payload.content as string) || ""
|
const content = (payload.content as string) || ""
|
||||||
const messageId = payload.message_id as string
|
const messageId = payload.message_id as string
|
||||||
|
const hasKind = hasAssistantKindPayload(payload)
|
||||||
|
const kind = parseAssistantMessageKind(payload)
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
updateChatStore((prev) => ({
|
updateChatStore((prev) => ({
|
||||||
messages: prev.messages.map((msg) =>
|
messages: prev.messages.map((msg) =>
|
||||||
msg.id === messageId ? { ...msg, content } : msg,
|
msg.id === messageId
|
||||||
|
? {
|
||||||
|
...msg,
|
||||||
|
content,
|
||||||
|
...(hasKind ? { kind } : {}),
|
||||||
|
}
|
||||||
|
: msg,
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"step3": "Preparing response...",
|
"step3": "Preparing response...",
|
||||||
"step4": "Almost there..."
|
"step4": "Almost there..."
|
||||||
},
|
},
|
||||||
|
"reasoningLabel": "Reasoning",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"noHistory": "No chat history yet",
|
"noHistory": "No chat history yet",
|
||||||
"historyLoadFailed": "Failed to load chat history",
|
"historyLoadFailed": "Failed to load chat history",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"step3": "准备回复...",
|
"step3": "准备回复...",
|
||||||
"step4": "马上就好..."
|
"step4": "马上就好..."
|
||||||
},
|
},
|
||||||
|
"reasoningLabel": "思考",
|
||||||
"history": "历史记录",
|
"history": "历史记录",
|
||||||
"noHistory": "暂无对话历史",
|
"noHistory": "暂无对话历史",
|
||||||
"historyLoadFailed": "加载历史记录失败",
|
"historyLoadFailed": "加载历史记录失败",
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,14 @@ export interface ChatAttachment {
|
||||||
filename?: string
|
filename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AssistantMessageKind = "normal" | "thought"
|
||||||
|
|
||||||
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
|
||||||
|
kind?: AssistantMessageKind
|
||||||
attachments?: ChatAttachment[]
|
attachments?: ChatAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue