add structed ui
This commit is contained in:
parent
71c877a67f
commit
f46fa17178
38 changed files with 5622 additions and 493 deletions
|
|
@ -111,6 +111,7 @@ const (
|
|||
sessionKeyAgentPrefix = "agent:"
|
||||
pendingTurnPrefix = "pending-"
|
||||
metadataKeyMessageKind = "message_kind"
|
||||
metadataKeyStructuredData = "structured_data"
|
||||
messageKindThought = "thought"
|
||||
metadataKeyAccountID = "account_id"
|
||||
metadataKeyGuildID = "guild_id"
|
||||
|
|
@ -520,13 +521,15 @@ func (al *AgentLoop) runAgentLoop(
|
|||
opts.Dispatch.SessionKey,
|
||||
opts.Dispatch.SessionScope,
|
||||
)
|
||||
outboundCtx := outboundContextFromInbound(
|
||||
opts.Dispatch.InboundContext,
|
||||
opts.Dispatch.Channel(),
|
||||
opts.Dispatch.ChatID(),
|
||||
opts.Dispatch.ReplyToMessageID(),
|
||||
)
|
||||
attachPlanTodoFallback(&outboundCtx, result.finalContent)
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
Context: outboundContextFromInbound(
|
||||
opts.Dispatch.InboundContext,
|
||||
opts.Dispatch.Channel(),
|
||||
opts.Dispatch.ChatID(),
|
||||
opts.Dispatch.ReplyToMessageID(),
|
||||
),
|
||||
Context: outboundCtx,
|
||||
AgentID: agentID,
|
||||
SessionKey: sessionKey,
|
||||
Scope: scope,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
|
|
@ -157,14 +158,22 @@ func registerSharedTools(
|
|||
|
||||
// Message tool
|
||||
if cfg.Tools.IsToolEnabled("message") {
|
||||
messageTool := tools.NewMessageTool()
|
||||
messageTool.SetSendCallback(func(
|
||||
sendStructuredMessage := func(
|
||||
ctx context.Context,
|
||||
channel, chatID, content, replyToMessageID string,
|
||||
structured any,
|
||||
) error {
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer pubCancel()
|
||||
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
|
||||
if structured != nil {
|
||||
if encoded, err := json.Marshal(structured); err == nil {
|
||||
if outboundCtx.Raw == nil {
|
||||
outboundCtx.Raw = make(map[string]string, 1)
|
||||
}
|
||||
outboundCtx.Raw[metadataKeyStructuredData] = string(encoded)
|
||||
}
|
||||
}
|
||||
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
|
||||
tools.ToolAgentID(ctx),
|
||||
tools.ToolSessionKey(ctx),
|
||||
|
|
@ -178,7 +187,10 @@ func registerSharedTools(
|
|||
Content: content,
|
||||
ReplyToMessageID: replyToMessageID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
messageTool := tools.NewMessageTool()
|
||||
messageTool.SetStructuredSendCallback(sendStructuredMessage)
|
||||
agent.Tools.Register(messageTool)
|
||||
}
|
||||
if cfg.Tools.IsToolEnabled("reaction") {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
|
|
@ -149,12 +151,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey)
|
||||
sessionKey := scopeKey
|
||||
|
||||
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
|
||||
resetter.ResetSentInRound(sessionKey)
|
||||
}
|
||||
}
|
||||
// Reset message-like tool state for this round so we don't skip publishing due to a previous round.
|
||||
resetSentTrackingTools(agent, sessionKey)
|
||||
|
||||
logger.InfoCF("agent", "Routed message",
|
||||
map[string]any{
|
||||
|
|
@ -182,7 +180,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: true,
|
||||
SendResponse: false,
|
||||
AllowInterimPicoPublish: true,
|
||||
AllowInterimPicoPublish: false,
|
||||
}
|
||||
if steering := modeSteeringMessages(msg.Context.Raw); len(steering) > 0 {
|
||||
opts.InitialSteeringMessages = append(opts.InitialSteeringMessages, steering...)
|
||||
}
|
||||
|
||||
// context-dependent commands check their own Runtime fields and report
|
||||
|
|
@ -203,6 +204,30 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
return al.runAgentLoop(ctx, agent, opts)
|
||||
}
|
||||
|
||||
func modeSteeringMessages(raw map[string]string) []providers.Message {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
mode := strings.ToLower(strings.TrimSpace(raw[pico.PayloadKeyMode]))
|
||||
if mode == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var instruction string
|
||||
switch mode {
|
||||
case pico.ChatModeAsk:
|
||||
instruction = "Chat mode is ASK. Prioritize answering the user's question directly and clearly. Prefer explanation, diagnosis, and guidance over taking actions. Avoid tool calls unless they are required to answer accurately or the user explicitly asks you to inspect something. Do not make code changes, execute tasks, or act on behalf of the user unless they clearly request that."
|
||||
case pico.ChatModePlan:
|
||||
instruction = "Chat mode is PLAN. Produce a concrete plan, design, or approach before execution. Do not make code changes, do not run tools that change state, and do not carry out the plan. Limit tool use to minimal read-only inspection only when necessary to produce a better plan. Emphasize steps, tradeoffs, assumptions, and risks. When the plan has discrete tasks, you must call the message tool exactly once with a structured todo payload so the Web UI can render a task list. Do not answer with plain markdown bullets or prose only when a task list is possible. Use structured payload shape {type:'todo', title:string, content?:string, items:[{title:string, status:'not-started'|'in-progress'|'completed', detail?:string}]}. Keep at most one item in-progress. Include a short plain-text content fallback in the same message tool call. If there are no discrete tasks, then a normal text answer is acceptable."
|
||||
case pico.ChatModeAgent:
|
||||
instruction = "Chat mode is AGENT. You may inspect the workspace, call tools, make code changes, and complete the task end-to-end when appropriate. Prefer execution over discussion when the user is asking for work to be done."
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return []providers.Message{{Role: "user", Content: instruction}}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
||||
registry := al.GetRegistry()
|
||||
inboundCtx := normalizedInboundContext(msg)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool {
|
||||
|
|
@ -37,6 +38,14 @@ func (al *AgentLoop) publishResponseOrError(
|
|||
}
|
||||
|
||||
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) {
|
||||
al.publishResponseWithContextIfNeeded(ctx, nil, channel, chatID, sessionKey, response)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) publishResponseWithContextIfNeeded(
|
||||
ctx context.Context,
|
||||
inboundCtx *bus.InboundContext,
|
||||
channel, chatID, sessionKey, response string,
|
||||
) {
|
||||
if response == "" {
|
||||
return
|
||||
}
|
||||
|
|
@ -44,11 +53,7 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
|||
alreadySentToSameChat := false
|
||||
defaultAgent := al.GetRegistry().GetDefaultAgent()
|
||||
if defaultAgent != nil {
|
||||
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||
alreadySentToSameChat = mt.HasSentTo(sessionKey, channel, chatID)
|
||||
}
|
||||
}
|
||||
alreadySentToSameChat = anySentTrackingToolSentTo(defaultAgent, sessionKey, channel, chatID)
|
||||
}
|
||||
|
||||
if alreadySentToSameChat {
|
||||
|
|
@ -60,8 +65,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
|||
return
|
||||
}
|
||||
|
||||
outboundCtx := bus.NewOutboundContext(channel, chatID, "")
|
||||
if inboundCtx != nil {
|
||||
outboundCtx = outboundContextFromInbound(inboundCtx, channel, chatID, "")
|
||||
}
|
||||
attachPlanTodoFallback(&outboundCtx, response)
|
||||
|
||||
msg := bus.OutboundMessage{
|
||||
Context: bus.NewOutboundContext(channel, chatID, ""),
|
||||
Context: outboundCtx,
|
||||
Content: response,
|
||||
}
|
||||
if sessionKey != "" {
|
||||
|
|
@ -70,9 +81,10 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
|||
al.bus.PublishOutbound(ctx, msg)
|
||||
logger.InfoCF("agent", "Published outbound response",
|
||||
map[string]any{
|
||||
"channel": channel,
|
||||
"chat_id": chatID,
|
||||
"content_len": len(response),
|
||||
"channel": channel,
|
||||
"chat_id": chatID,
|
||||
"content_len": len(response),
|
||||
"has_structured": strings.TrimSpace(outboundCtx.Raw[metadataKeyStructuredData]) != "",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +135,68 @@ func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent,
|
|||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) publishPicoStructured(ctx context.Context, chatID, content string, structured any) {
|
||||
if chatID == "" || structured == nil {
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rawStructured, err := json.Marshal(structured)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "Failed to encode pico structured payload", map[string]any{
|
||||
"channel": "pico",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer pubCancel()
|
||||
|
||||
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||
Context: bus.InboundContext{
|
||||
Channel: "pico",
|
||||
ChatID: chatID,
|
||||
Raw: map[string]string{
|
||||
metadataKeyStructuredData: string(rawStructured),
|
||||
},
|
||||
},
|
||||
Content: content,
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, bus.ErrBusClosed) {
|
||||
logger.DebugCF("agent", "Pico structured publish skipped (timeout/cancel)", map[string]any{
|
||||
"channel": "pico",
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
logger.WarnCF("agent", "Failed to publish pico structured payload", map[string]any{
|
||||
"channel": "pico",
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) publishPicoToolProgress(ctx context.Context, chatID, toolName, status, detail string) {
|
||||
if toolName == "" || chatID == "" {
|
||||
return
|
||||
}
|
||||
content := fmt.Sprintf("%s: %s", toolName, status)
|
||||
if detail != "" {
|
||||
content = fmt.Sprintf("%s\n%s", content, detail)
|
||||
}
|
||||
al.publishPicoStructured(ctx, chatID, content, map[string]any{
|
||||
"type": "progress",
|
||||
"kind": "agent/tool-exec",
|
||||
"title": toolName,
|
||||
"status": status,
|
||||
"content": detail,
|
||||
})
|
||||
}
|
||||
|
||||
func (al *AgentLoop) handleReasoning(
|
||||
ctx context.Context,
|
||||
reasoningContent, channelName, channelID string,
|
||||
|
|
|
|||
|
|
@ -10,15 +10,23 @@ import (
|
|||
)
|
||||
|
||||
func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) {
|
||||
msg = bus.NormalizeInboundMessage(msg)
|
||||
if al.channelManager != nil {
|
||||
defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
|
||||
}
|
||||
|
||||
response, err := al.processMessage(ctx, msg)
|
||||
al.publishResponseOrError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, response, err)
|
||||
if err != nil {
|
||||
if !al.maybePublishError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
al.publishResponseWithContextIfNeeded(ctx, &msg.Context, msg.Channel, msg.ChatID, msg.SessionKey, response)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) {
|
||||
initialMsg = bus.NormalizeInboundMessage(initialMsg)
|
||||
// Process the initial message
|
||||
response, err := al.processMessage(ctx, initialMsg)
|
||||
if err != nil {
|
||||
|
|
@ -77,7 +85,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
|||
|
||||
// Publish final response
|
||||
if finalResponse != "" {
|
||||
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
|
||||
al.publishResponseWithContextIfNeeded(ctx, &initialMsg.Context, target.Channel, target.ChatID, target.SessionKey, finalResponse)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,56 @@ func (r *recordingProvider) GetDefaultModel() string {
|
|||
return "mock-model"
|
||||
}
|
||||
|
||||
func TestProcessMessage_IncludesChatModeSteering(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Workspace = tmpDir
|
||||
cfg.Agents.Defaults.ModelName = "test-model"
|
||||
provider := &recordingProvider{}
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||
|
||||
_, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Context: bus.InboundContext{
|
||||
Channel: "pico",
|
||||
ChatID: "pico:test-session",
|
||||
ChatType: "direct",
|
||||
SenderID: "pico-user",
|
||||
Raw: map[string]string{
|
||||
"mode": "plan",
|
||||
},
|
||||
},
|
||||
Content: "帮我修一下这个功能",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if len(provider.lastMessages) < 3 {
|
||||
t.Fatalf("provider messages len = %d, want at least 3", len(provider.lastMessages))
|
||||
}
|
||||
var foundSteering bool
|
||||
var foundOriginal bool
|
||||
for _, message := range provider.lastMessages {
|
||||
if message.Role == "user" && strings.Contains(message.Content, "Chat mode is PLAN") {
|
||||
foundSteering = true
|
||||
if !strings.Contains(message.Content, "structured todo payload") {
|
||||
t.Fatalf("plan steering = %q, want structured todo guidance", message.Content)
|
||||
}
|
||||
if !strings.Contains(message.Content, "must call the message tool exactly once") {
|
||||
t.Fatalf("plan steering = %q, want mandatory message tool guidance", message.Content)
|
||||
}
|
||||
}
|
||||
if message.Role == "user" && message.Content == "帮我修一下这个功能" {
|
||||
foundOriginal = true
|
||||
}
|
||||
}
|
||||
if !foundSteering {
|
||||
t.Fatalf("provider messages = %#v, want plan steering instruction present", provider.lastMessages)
|
||||
}
|
||||
if !foundOriginal {
|
||||
t.Fatalf("provider messages = %#v, want original user message present", provider.lastMessages)
|
||||
}
|
||||
}
|
||||
|
||||
type modelRewriteHook struct {
|
||||
model string
|
||||
}
|
||||
|
|
@ -1634,6 +1684,36 @@ func (m *handledUserProvider) GetDefaultModel() string {
|
|||
return "handled-user-model"
|
||||
}
|
||||
|
||||
type toolCallPreferredProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *toolCallPreferredProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.calls++
|
||||
if m.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
Content: "Checking the workspace.",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_plain_user_result",
|
||||
Type: "function",
|
||||
Name: "plain_user_result_tool",
|
||||
Arguments: map[string]any{},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
return &providers.LLMResponse{Content: "Final answer from model after tool result."}, nil
|
||||
}
|
||||
|
||||
func (m *toolCallPreferredProvider) GetDefaultModel() string {
|
||||
return "tool-call-preferred-model"
|
||||
}
|
||||
|
||||
type messageToolProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
|
@ -1885,6 +1965,89 @@ func (m *handledUserTool) Execute(ctx context.Context, args map[string]any) *too
|
|||
return tools.UserResult("Handled user output from tool.").WithResponseHandled()
|
||||
}
|
||||
|
||||
type plainUserResultTool struct{}
|
||||
|
||||
func (m *plainUserResultTool) Name() string { return "plain_user_result_tool" }
|
||||
func (m *plainUserResultTool) Description() string {
|
||||
return "Returns a raw user-facing summary without explicit direct delivery"
|
||||
}
|
||||
|
||||
func (m *plainUserResultTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *plainUserResultTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
return tools.UserResult("Raw tool output that should stay inside the tool-result loop.")
|
||||
}
|
||||
|
||||
func TestRunAgentLoop_PlainUserResultDoesNotPublishDirectlyWhenSendResponseEnabled(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Workspace = tmpDir
|
||||
cfg.Agents.Defaults.ModelName = "test-model"
|
||||
cfg.Agents.Defaults.MaxTokens = 4096
|
||||
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &toolCallPreferredProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
|
||||
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, nil, "telegram", telegramChannel))
|
||||
al.RegisterTool(&plainUserResultTool{})
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
response, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{
|
||||
Dispatch: DispatchRequest{
|
||||
SessionKey: "session-plain-user-result",
|
||||
UserMessage: "inspect and answer",
|
||||
SessionScope: &session.SessionScope{
|
||||
Version: session.ScopeVersionV1,
|
||||
AgentID: defaultAgent.ID,
|
||||
Channel: "telegram",
|
||||
Dimensions: []string{"chat"},
|
||||
Values: map[string]string{
|
||||
"chat": "direct:chat1",
|
||||
},
|
||||
},
|
||||
InboundContext: &bus.InboundContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "chat1",
|
||||
ChatType: "direct",
|
||||
SenderID: "user1",
|
||||
},
|
||||
},
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: false,
|
||||
SendResponse: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runAgentLoop() error = %v", err)
|
||||
}
|
||||
if response != "Final answer from model after tool result." {
|
||||
t.Fatalf("response = %q, want final model answer", response)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for len(telegramChannel.sentMessages) == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if len(telegramChannel.sentMessages) != 1 {
|
||||
t.Fatalf("expected exactly 1 published final answer, got %+v", telegramChannel.sentMessages)
|
||||
}
|
||||
if telegramChannel.sentMessages[0].Content != "Final answer from model after tool result." {
|
||||
t.Fatalf("expected only final model answer to be published, got %+v", telegramChannel.sentMessages[0])
|
||||
}
|
||||
if provider.calls != 2 {
|
||||
t.Fatalf("expected 2 provider calls, got %d", provider.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type handledMediaWithSteeringProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
|
@ -3554,6 +3717,45 @@ func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPublishPicoToolProgress_PublishesStructuredPayload(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(config.DefaultConfig(), msgBus, &recordingProvider{})
|
||||
|
||||
al.publishPicoToolProgress(context.Background(), "pico:test-session", "search_files", "running", "Tool execution started.")
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
if outbound.Channel != "pico" || outbound.ChatID != "pico:test-session" {
|
||||
t.Fatalf("outbound route = %s/%s, want pico/pico:test-session", outbound.Channel, outbound.ChatID)
|
||||
}
|
||||
rawStructured := outbound.Context.Raw[metadataKeyStructuredData]
|
||||
if rawStructured == "" {
|
||||
t.Fatal("expected structured_data metadata on pico progress message")
|
||||
}
|
||||
var structured struct {
|
||||
Type string `json:"type"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rawStructured), &structured); err != nil {
|
||||
t.Fatalf("unmarshal structured_data: %v", err)
|
||||
}
|
||||
if structured.Type != "progress" || structured.Kind != "agent/tool-exec" {
|
||||
t.Fatalf("structured = %#v, want progress/agent-tool-exec", structured)
|
||||
}
|
||||
if structured.Title != "search_files" || structured.Status != "running" {
|
||||
t.Fatalf("structured = %#v, want title/status search_files/running", structured)
|
||||
}
|
||||
if structured.Content != "Tool execution started." {
|
||||
t.Fatalf("structured content = %q, want %q", structured.Content, "Tool execution started.")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("expected pico progress outbound message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
|
||||
|
|
@ -3719,7 +3921,7 @@ func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t *testing.T) {
|
||||
func TestRun_PicoPublishesProgressDuringToolCallsWithoutFinalDuplicate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg := &config.Config{
|
||||
|
|
@ -3760,22 +3962,42 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
|
|||
t.Fatalf("PublishInbound() error = %v", err)
|
||||
}
|
||||
|
||||
outputs := make([]string, 0, 2)
|
||||
type outboundSnapshot struct {
|
||||
content string
|
||||
structured string
|
||||
}
|
||||
outputs := make([]outboundSnapshot, 0, 4)
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(outputs) < 2 {
|
||||
for {
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
outputs = append(outputs, outbound.Content)
|
||||
outputs = append(outputs, outboundSnapshot{
|
||||
content: outbound.Content,
|
||||
structured: outbound.Context.Raw["structured_data"],
|
||||
})
|
||||
if outbound.Content == "final model text" {
|
||||
goto assertions
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
|
||||
}
|
||||
}
|
||||
|
||||
if outputs[0] != "intermediate model text" {
|
||||
t.Fatalf("first outbound content = %q, want %q", outputs[0], "intermediate model text")
|
||||
assertions:
|
||||
if len(outputs) < 2 {
|
||||
t.Fatalf("expected progress and final outputs, got %v", outputs)
|
||||
}
|
||||
if outputs[1] != "final model text" {
|
||||
t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text")
|
||||
if outputs[0].content != "tool_limit_test_tool: running\nTool execution started." {
|
||||
t.Fatalf("first outbound content = %q, want progress payload content", outputs[0].content)
|
||||
}
|
||||
if outputs[0].structured == "" {
|
||||
t.Fatalf("expected first outbound to include structured progress payload, got %+v", outputs[0])
|
||||
}
|
||||
if strings.Contains(outputs[0].structured, "intermediate model text") {
|
||||
t.Fatalf("unexpected interim assistant text in structured progress payload: %s", outputs[0].structured)
|
||||
}
|
||||
if outputs[len(outputs)-1].content != "final model text" {
|
||||
t.Fatalf("last outbound content = %q, want %q", outputs[len(outputs)-1].content, "final model text")
|
||||
}
|
||||
|
||||
runCancel()
|
||||
|
|
@ -3788,12 +4010,14 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
|
|||
t.Fatal("timed out waiting for Run() to exit")
|
||||
}
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
if outbound.Content == "final model text" {
|
||||
t.Fatalf("unexpected duplicate final pico output: %+v", outbound)
|
||||
finalCount := 0
|
||||
for _, outbound := range outputs {
|
||||
if outbound.content == "final model text" {
|
||||
finalCount++
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
if finalCount != 1 {
|
||||
t.Fatalf("expected exactly one final pico output, got %d from %+v", finalCount, outputs)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3839,10 +4063,25 @@ func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) {
|
|||
t.Fatalf("runAgentLoop() response = %q, want %q", response, "final model text")
|
||||
}
|
||||
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
t.Fatalf("unexpected outbound message when interim publish disabled: %+v", outbound)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
outputs := make([]bus.OutboundMessage, 0, 4)
|
||||
collectUntil := time.After(300 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
outputs = append(outputs, outbound)
|
||||
case <-collectUntil:
|
||||
goto verifyNoInterim
|
||||
}
|
||||
}
|
||||
|
||||
verifyNoInterim:
|
||||
if len(outputs) == 0 {
|
||||
t.Fatal("expected structured progress outbound even when interim publish is disabled")
|
||||
}
|
||||
for _, outbound := range outputs {
|
||||
if outbound.Content == "intermediate model text" {
|
||||
t.Fatalf("unexpected interim assistant text when disabled: %+v", outbound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
40
pkg/agent/message_tool_tracking.go
Normal file
40
pkg/agent/message_tool_tracking.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package agent
|
||||
|
||||
import "github.com/sipeed/picoclaw/pkg/tools"
|
||||
|
||||
func resetSentTrackingTools(agent *AgentInstance, sessionKey string) {
|
||||
if agent == nil {
|
||||
return
|
||||
}
|
||||
for _, name := range agent.Tools.List() {
|
||||
tool, ok := agent.Tools.Get(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
|
||||
resetter.ResetSentInRound(sessionKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func anySentTrackingToolSentTo(agent *AgentInstance, sessionKey, channel, chatID string) bool {
|
||||
if agent == nil {
|
||||
return false
|
||||
}
|
||||
for _, name := range agent.Tools.List() {
|
||||
tool, ok := agent.Tools.Get(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tracker, ok := tool.(interface {
|
||||
HasSentTo(sessionKey, channel, chatID string) bool
|
||||
}); ok {
|
||||
if tracker.HasSentTo(sessionKey, channel, chatID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var _ tools.Tool = (*tools.MessageTool)(nil)
|
||||
199
pkg/agent/plan_todo_fallback.go
Normal file
199
pkg/agent/plan_todo_fallback.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||
)
|
||||
|
||||
var (
|
||||
planHeadingRe = regexp.MustCompile(`^\s*(#{1,6})\s+(.+?)\s*$`)
|
||||
planCheckboxRe = regexp.MustCompile(`^\s*[-*+]\s*\[( |x|X)\]\s+(.+?)\s*$`)
|
||||
planBulletRe = regexp.MustCompile(`^\s*[-*+]\s+(.+?)\s*$`)
|
||||
planNumberRe = regexp.MustCompile(`^\s*\d+[\.)]\s+(.+?)\s*$`)
|
||||
)
|
||||
|
||||
func attachPlanTodoFallback(outboundCtx *bus.InboundContext, response string) {
|
||||
if outboundCtx == nil {
|
||||
return
|
||||
}
|
||||
structured := parsePlanTodoFallback(outboundCtx, response)
|
||||
if structured == nil {
|
||||
return
|
||||
}
|
||||
rawStructured, err := json.Marshal(structured)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if outboundCtx.Raw == nil {
|
||||
outboundCtx.Raw = make(map[string]string, 1)
|
||||
}
|
||||
outboundCtx.Raw[metadataKeyStructuredData] = string(rawStructured)
|
||||
}
|
||||
|
||||
func parsePlanTodoFallback(inboundCtx *bus.InboundContext, response string) map[string]any {
|
||||
if inboundCtx == nil || strings.TrimSpace(response) == "" {
|
||||
return nil
|
||||
}
|
||||
if inboundCtx.Channel != "pico" {
|
||||
return nil
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(inboundCtx.Raw[pico.PayloadKeyMode])) != pico.ChatModePlan {
|
||||
return nil
|
||||
}
|
||||
|
||||
title, content, items := extractPlanTodoItems(response)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "todo",
|
||||
"title": title,
|
||||
"items": items,
|
||||
}
|
||||
if content != "" {
|
||||
payload["content"] = content
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func extractPlanTodoItems(response string) (string, string, []map[string]any) {
|
||||
lines := strings.Split(strings.ReplaceAll(response, "\r\n", "\n"), "\n")
|
||||
title := "Plan"
|
||||
content := ""
|
||||
headingTasks := make([]map[string]any, 0, 8)
|
||||
listTasks := make([]map[string]any, 0, 12)
|
||||
firstHeadingSeen := false
|
||||
firstParagraphSeen := false
|
||||
|
||||
for _, rawLine := range lines {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || line == "---" {
|
||||
continue
|
||||
}
|
||||
|
||||
if matches := planHeadingRe.FindStringSubmatch(line); len(matches) == 3 {
|
||||
headingText := cleanPlanLine(matches[2])
|
||||
if headingText == "" {
|
||||
continue
|
||||
}
|
||||
if !firstHeadingSeen {
|
||||
title = headingText
|
||||
firstHeadingSeen = true
|
||||
continue
|
||||
}
|
||||
if isTaskHeading(headingText) {
|
||||
headingTasks = append(headingTasks, map[string]any{
|
||||
"title": headingText,
|
||||
"status": "not-started",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !firstParagraphSeen && !looksLikeListLine(line) {
|
||||
content = cleanPlanLine(line)
|
||||
firstParagraphSeen = content != ""
|
||||
}
|
||||
|
||||
if matches := planCheckboxRe.FindStringSubmatch(line); len(matches) == 3 {
|
||||
status := "not-started"
|
||||
if strings.EqualFold(matches[1], "x") {
|
||||
status = "completed"
|
||||
}
|
||||
if item := cleanPlanLine(matches[2]); item != "" {
|
||||
listTasks = append(listTasks, map[string]any{
|
||||
"title": item,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if matches := planBulletRe.FindStringSubmatch(line); len(matches) == 2 {
|
||||
if item := cleanPlanLine(matches[1]); item != "" {
|
||||
listTasks = append(listTasks, map[string]any{
|
||||
"title": item,
|
||||
"status": inferPlanStatus(item),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if matches := planNumberRe.FindStringSubmatch(line); len(matches) == 2 {
|
||||
if item := cleanPlanLine(matches[1]); item != "" {
|
||||
listTasks = append(listTasks, map[string]any{
|
||||
"title": item,
|
||||
"status": inferPlanStatus(item),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := headingTasks
|
||||
if len(items) == 0 {
|
||||
items = listTasks
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return title, content, nil
|
||||
}
|
||||
if len(items) > 8 {
|
||||
items = items[:8]
|
||||
}
|
||||
|
||||
hasExplicitProgress := false
|
||||
for _, item := range items {
|
||||
status, _ := item["status"].(string)
|
||||
if status == "in-progress" || status == "completed" {
|
||||
hasExplicitProgress = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasExplicitProgress && len(items) > 0 {
|
||||
items[0]["status"] = "in-progress"
|
||||
}
|
||||
|
||||
return title, content, items
|
||||
}
|
||||
|
||||
func isTaskHeading(text string) bool {
|
||||
lower := strings.ToLower(text)
|
||||
if strings.Contains(lower, "项目目标") || strings.Contains(lower, "任务拆解") || strings.Contains(lower, "core goal") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(lower, "阶段") ||
|
||||
strings.Contains(lower, "phase") ||
|
||||
strings.Contains(lower, "step") ||
|
||||
strings.Contains(lower, "milestone") ||
|
||||
strings.Contains(lower, "实现") ||
|
||||
strings.Contains(lower, "测试") ||
|
||||
strings.Contains(lower, "验证") ||
|
||||
strings.Contains(lower, "优化")
|
||||
}
|
||||
|
||||
func looksLikeListLine(line string) bool {
|
||||
return planCheckboxRe.MatchString(line) || planBulletRe.MatchString(line) || planNumberRe.MatchString(line)
|
||||
}
|
||||
|
||||
func inferPlanStatus(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
if strings.Contains(lower, "completed") || strings.Contains(lower, "done") || strings.Contains(lower, "已完成") {
|
||||
return "completed"
|
||||
}
|
||||
if strings.Contains(lower, "in-progress") || strings.Contains(lower, "running") || strings.Contains(lower, "进行中") {
|
||||
return "in-progress"
|
||||
}
|
||||
return "not-started"
|
||||
}
|
||||
|
||||
func cleanPlanLine(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.Trim(text, "*")
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.Trim(text, "`")
|
||||
text = strings.ReplaceAll(text, "**", "")
|
||||
text = strings.ReplaceAll(text, "__", "")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
91
pkg/agent/plan_todo_fallback_test.go
Normal file
91
pkg/agent/plan_todo_fallback_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||
)
|
||||
|
||||
func TestParsePlanTodoFallback_UsesPhaseHeadings(t *testing.T) {
|
||||
payload := parsePlanTodoFallback(&bus.InboundContext{
|
||||
Channel: "pico",
|
||||
Raw: map[string]string{
|
||||
pico.PayloadKeyMode: pico.ChatModePlan,
|
||||
},
|
||||
}, `# 项目规划
|
||||
|
||||
先给出实施方案。
|
||||
|
||||
## 任务拆解
|
||||
|
||||
### 阶段 1: 调研与设计
|
||||
- 分析现有实现
|
||||
- 明确目标体验
|
||||
|
||||
### 阶段 2: 后端实现
|
||||
- 增加 structured todo
|
||||
|
||||
### 阶段 3: 测试与验证
|
||||
- 补测试
|
||||
- 手工验证
|
||||
`)
|
||||
|
||||
if payload == nil {
|
||||
t.Fatal("expected structured payload")
|
||||
}
|
||||
if payload["type"] != "todo" {
|
||||
t.Fatalf("type = %v, want todo", payload["type"])
|
||||
}
|
||||
items, ok := payload["items"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("items = %#v, want []map[string]any", payload["items"])
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("len(items) = %d, want 3", len(items))
|
||||
}
|
||||
if items[0]["title"] != "阶段 1: 调研与设计" {
|
||||
t.Fatalf("first title = %v, want 阶段 1: 调研与设计", items[0]["title"])
|
||||
}
|
||||
if items[0]["status"] != "in-progress" {
|
||||
t.Fatalf("first status = %v, want in-progress", items[0]["status"])
|
||||
}
|
||||
if items[1]["status"] != "not-started" {
|
||||
t.Fatalf("second status = %v, want not-started", items[1]["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanTodoFallback_UsesBulletFallback(t *testing.T) {
|
||||
payload := parsePlanTodoFallback(&bus.InboundContext{
|
||||
Channel: "pico",
|
||||
Raw: map[string]string{
|
||||
pico.PayloadKeyMode: pico.ChatModePlan,
|
||||
},
|
||||
}, `Plan:
|
||||
- [x] Review current plan mode
|
||||
- Implement structured todo renderer
|
||||
- Validate in browser
|
||||
`)
|
||||
|
||||
if payload == nil {
|
||||
t.Fatal("expected structured payload")
|
||||
}
|
||||
items, ok := payload["items"].([]map[string]any)
|
||||
if !ok || len(items) != 3 {
|
||||
t.Fatalf("items = %#v, want 3 entries", payload["items"])
|
||||
}
|
||||
if items[0]["status"] != "completed" {
|
||||
t.Fatalf("first status = %v, want completed", items[0]["status"])
|
||||
}
|
||||
if items[1]["status"] != "not-started" {
|
||||
t.Fatalf("second status = %v, want not-started", items[1]["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachPlanTodoFallback_SkipsNonPlanMessages(t *testing.T) {
|
||||
ctx := bus.InboundContext{Channel: "pico", Raw: map[string]string{}}
|
||||
attachPlanTodoFallback(&ctx, "- one\n- two")
|
||||
if len(ctx.Raw) != 0 {
|
||||
t.Fatalf("raw = %#v, want unchanged", ctx.Raw)
|
||||
}
|
||||
}
|
||||
|
|
@ -385,11 +385,7 @@ func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID s
|
|||
return "", fmt.Errorf("no agent available for session %q", sessionKey)
|
||||
}
|
||||
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
|
||||
resetter.ResetSentInRound(sessionKey)
|
||||
}
|
||||
}
|
||||
resetSentTrackingTools(agent, sessionKey)
|
||||
|
||||
var scope *session.SessionScope
|
||||
if metaStore, ok := agent.Sessions.(session.MetadataAwareSessionStore); ok {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ type MessageEditor interface {
|
|||
EditMessage(ctx context.Context, chatID string, messageID string, content string) error
|
||||
}
|
||||
|
||||
// StructuredMessageEditor extends MessageEditor for channels that can update
|
||||
// an existing message while preserving a structured payload.
|
||||
type StructuredMessageEditor interface {
|
||||
EditStructuredMessage(ctx context.Context, chatID string, messageID string, content string, structured any) error
|
||||
}
|
||||
|
||||
// MessageDeleter — channels that can delete a message by ID.
|
||||
type MessageDeleter interface {
|
||||
DeleteMessage(ctx context.Context, chatID string, messageID string) error
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ package channels
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
|
@ -116,6 +117,19 @@ func outboundMediaChatID(msg bus.OutboundMediaMessage) string {
|
|||
return msg.ChatID
|
||||
}
|
||||
|
||||
func outboundStructuredPayload(msg bus.OutboundMessage) any {
|
||||
rawStructured := msg.Context.Raw["structured_data"]
|
||||
if rawStructured == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var structured any
|
||||
if err := json.Unmarshal([]byte(rawStructured), &structured); err != nil {
|
||||
return nil
|
||||
}
|
||||
return structured
|
||||
}
|
||||
|
||||
// RecordPlaceholder registers a placeholder message for later editing.
|
||||
// Implements PlaceholderRecorder.
|
||||
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
|
||||
|
|
@ -214,6 +228,13 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
|||
// 4. Try editing placeholder
|
||||
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
||||
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
||||
if structured := outboundStructuredPayload(msg); structured != nil {
|
||||
if editor, ok := ch.(StructuredMessageEditor); ok {
|
||||
if err := editor.EditStructuredMessage(ctx, chatID, entry.id, msg.Content, structured); err == nil {
|
||||
return []string{entry.id}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if editor, ok := ch.(MessageEditor); ok {
|
||||
if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil {
|
||||
return []string{entry.id}, true
|
||||
|
|
|
|||
|
|
@ -66,6 +66,32 @@ type mockMediaChannel struct {
|
|||
sentMediaMessages []bus.OutboundMediaMessage
|
||||
}
|
||||
|
||||
type mockStructuredEditorChannel struct {
|
||||
mockChannel
|
||||
structuredEditCalls int
|
||||
lastStructuredEdit struct {
|
||||
chatID string
|
||||
messageID string
|
||||
content string
|
||||
structured any
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStructuredEditorChannel) EditStructuredMessage(
|
||||
_ context.Context,
|
||||
chatID string,
|
||||
messageID string,
|
||||
content string,
|
||||
structured any,
|
||||
) error {
|
||||
m.structuredEditCalls++
|
||||
m.lastStructuredEdit.chatID = chatID
|
||||
m.lastStructuredEdit.messageID = messageID
|
||||
m.lastStructuredEdit.content = content
|
||||
m.lastStructuredEdit.structured = structured
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
||||
m.sentMediaMessages = append(m.sentMediaMessages, msg)
|
||||
if m.sendMediaFn != nil {
|
||||
|
|
@ -766,6 +792,44 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPreSend_PlaceholderStructuredEditSuccess(t *testing.T) {
|
||||
m := newTestManager()
|
||||
ch := &mockStructuredEditorChannel{}
|
||||
|
||||
m.RecordPlaceholder("test", "123", "456")
|
||||
|
||||
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "progress update"})
|
||||
if msg.Context.Raw == nil {
|
||||
msg.Context.Raw = make(map[string]string, 1)
|
||||
}
|
||||
msg.Context.Raw["structured_data"] = `{"type":"progress","kind":"agent/tool-exec","title":"read_file","status":"running"}`
|
||||
|
||||
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
||||
|
||||
if !edited {
|
||||
t.Fatal("expected preSend to return true for structured placeholder edit")
|
||||
}
|
||||
if ch.structuredEditCalls != 1 {
|
||||
t.Fatalf("expected EditStructuredMessage to be called once, got %d", ch.structuredEditCalls)
|
||||
}
|
||||
if ch.editedMessages != 0 {
|
||||
t.Fatal("expected plain EditMessage to NOT be called when structured editor exists")
|
||||
}
|
||||
if len(ch.sentMessages) != 0 {
|
||||
t.Fatal("expected Send to NOT be called when placeholder structured edit succeeds")
|
||||
}
|
||||
structured, ok := ch.lastStructuredEdit.structured.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("structured payload = %#v, want map[string]any", ch.lastStructuredEdit.structured)
|
||||
}
|
||||
if structured["type"] != "progress" || structured["status"] != "running" {
|
||||
t.Fatalf("structured payload = %#v, want progress/running", structured)
|
||||
}
|
||||
if ch.lastStructuredEdit.messageID != "456" {
|
||||
t.Fatalf("messageID = %q, want 456", ch.lastStructuredEdit.messageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
|
||||
m := newTestManager()
|
||||
|
||||
|
|
|
|||
|
|
@ -262,12 +262,23 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
|||
}
|
||||
isThought := outboundMessageIsThought(msg)
|
||||
|
||||
payload := map[string]any{
|
||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||
PayloadKeyContent: msg.Content,
|
||||
PayloadKeyThought: isThought,
|
||||
})
|
||||
if rawStructured := strings.TrimSpace(msg.Context.Raw["structured_data"]); rawStructured != "" {
|
||||
var structured any
|
||||
if err := json.Unmarshal([]byte(rawStructured), &structured); err == nil {
|
||||
outMsg.Payload[PayloadKeyStructured] = structured
|
||||
}
|
||||
}
|
||||
setContextUsagePayload(payload, msg.ContextUsage)
|
||||
outMsg := newMessage(TypeMessageCreate, payload)
|
||||
logger.InfoCF("pico", "Sending websocket message",
|
||||
map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"message_type": outMsg.Type,
|
||||
"has_thought": isThought,
|
||||
"has_structured": outMsg.Payload[PayloadKeyStructured] != nil,
|
||||
})
|
||||
|
||||
return nil, c.broadcastToSession(msg.ChatID, outMsg)
|
||||
}
|
||||
|
|
@ -281,6 +292,21 @@ func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID
|
|||
return c.broadcastToSession(chatID, outMsg)
|
||||
}
|
||||
|
||||
func (c *PicoChannel) EditStructuredMessage(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
messageID string,
|
||||
content string,
|
||||
structured any,
|
||||
) error {
|
||||
outMsg := newMessage(TypeMessageUpdate, map[string]any{
|
||||
"message_id": messageID,
|
||||
"content": content,
|
||||
"structured": structured,
|
||||
})
|
||||
return c.broadcastToSession(chatID, outMsg)
|
||||
}
|
||||
|
||||
// StartTyping implements channels.TypingCapable.
|
||||
func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||
startMsg := newMessage(TypeTypingStart, nil)
|
||||
|
|
@ -585,6 +611,9 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
|||
"session_id": sessionID,
|
||||
"conn_id": pc.id,
|
||||
}
|
||||
if mode, _ := msg.Payload[PayloadKeyMode].(string); strings.TrimSpace(mode) != "" {
|
||||
metadata[PayloadKeyMode] = strings.TrimSpace(mode)
|
||||
}
|
||||
|
||||
logger.DebugCF("pico", "Received message", map[string]any{
|
||||
"session_id": sessionID,
|
||||
|
|
@ -718,16 +747,3 @@ func validateInlineImageDataURL(mediaURL string) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setContextUsagePayload adds context window usage stats to a pico payload.
|
||||
func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
payload["context_usage"] = map[string]any{
|
||||
"used_tokens": u.UsedTokens,
|
||||
"total_tokens": u.TotalTokens,
|
||||
"compress_at_tokens": u.CompressAtTokens,
|
||||
"used_percent": u.UsedPercent,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,24 @@ func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewMessage_AssignsMessageIDForCreate(t *testing.T) {
|
||||
msg := newMessage(TypeMessageCreate, map[string]any{"content": "hello"})
|
||||
messageID, ok := msg.Payload["message_id"].(string)
|
||||
if !ok || messageID == "" {
|
||||
t.Fatalf("message_id = %#v, want non-empty string", msg.Payload["message_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMessage_PreservesExistingMessageIDForCreate(t *testing.T) {
|
||||
msg := newMessage(TypeMessageCreate, map[string]any{
|
||||
"content": "hello",
|
||||
"message_id": "custom-id",
|
||||
})
|
||||
if msg.Payload["message_id"] != "custom-id" {
|
||||
t.Fatalf("message_id = %#v, want custom-id", msg.Payload["message_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PicoChannel) addConnForTest(pc *picoConn) {
|
||||
c.connsMu.Lock()
|
||||
defer c.connsMu.Unlock()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package pico
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Protocol message types.
|
||||
const (
|
||||
|
|
@ -18,10 +22,18 @@ const (
|
|||
TypeError = "error"
|
||||
TypePong = "pong"
|
||||
|
||||
PayloadKeyContent = "content"
|
||||
PayloadKeyThought = "thought"
|
||||
PicoTokenPrefix = "pico-"
|
||||
|
||||
PayloadKeyContent = "content"
|
||||
PayloadKeyThought = "thought"
|
||||
PayloadKeyStructured = "structured"
|
||||
PayloadKeyMode = "mode"
|
||||
|
||||
MessageKindThought = "thought"
|
||||
|
||||
ChatModeAgent = "agent"
|
||||
ChatModeAsk = "ask"
|
||||
ChatModePlan = "plan"
|
||||
)
|
||||
|
||||
// PicoMessage is the wire format for all Pico Protocol messages.
|
||||
|
|
@ -35,6 +47,15 @@ type PicoMessage struct {
|
|||
|
||||
// newMessage creates a PicoMessage with the given type and payload.
|
||||
func newMessage(msgType string, payload map[string]any) PicoMessage {
|
||||
if msgType == TypeMessageCreate {
|
||||
if payload == nil {
|
||||
payload = make(map[string]any, 1)
|
||||
}
|
||||
if _, exists := payload["message_id"]; !exists {
|
||||
payload["message_id"] = uuid.NewString()
|
||||
}
|
||||
}
|
||||
|
||||
return PicoMessage{
|
||||
Type: msgType,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
|
|
|
|||
|
|
@ -3,28 +3,15 @@ package integrationtools
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
|
||||
|
||||
// sentTarget records the channel+chatID that the message tool sent to.
|
||||
type sentTarget struct {
|
||||
Channel string
|
||||
ChatID string
|
||||
}
|
||||
|
||||
type MessageTool struct {
|
||||
sendCallback SendCallbackWithContext
|
||||
mu sync.Mutex
|
||||
// sentTargets tracks targets sent to in the current round, keyed by session key
|
||||
// to support parallel turns for different sessions.
|
||||
sentTargets map[string][]sentTarget
|
||||
messageDispatchTool
|
||||
}
|
||||
|
||||
func NewMessageTool() *MessageTool {
|
||||
return &MessageTool{
|
||||
sentTargets: make(map[string][]sentTarget),
|
||||
messageDispatchTool: newMessageDispatchTool(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,16 +20,65 @@ func (t *MessageTool) Name() string {
|
|||
}
|
||||
|
||||
func (t *MessageTool) Description() string {
|
||||
return "Send a message to user on a chat channel. Use this when you want to communicate something."
|
||||
return "Send a structured or plain-text message to the user. Use for interactive UI elements (options, cards, forms, progress, todos, alerts) that need immediate rendering. Always include content as plain-text fallback."
|
||||
}
|
||||
|
||||
func (t *MessageTool) Parameters() map[string]any {
|
||||
optionItemSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"label": map[string]any{"type": "string"},
|
||||
"value": map[string]any{"type": "string"},
|
||||
"description": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"label", "value"},
|
||||
}
|
||||
actionItemSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"label": map[string]any{"type": "string"},
|
||||
"value": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"label"},
|
||||
}
|
||||
structuredPartSchema := map[string]any{
|
||||
"type": "object",
|
||||
"oneOf": []any{
|
||||
map[string]any{
|
||||
"description": "Interactive options list for the user to choose from.",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"type": "string", "const": "options"},
|
||||
"options": map[string]any{"type": "array", "items": optionItemSchema, "minItems": 1},
|
||||
"mode": map[string]any{"type": "string", "enum": []string{"single", "multiple"}, "default": "single"},
|
||||
},
|
||||
"required": []string{"type", "options"},
|
||||
},
|
||||
map[string]any{
|
||||
"description": "Rich card. Built-in semantic kinds: 'form', 'progress', 'todo', 'alert'. Custom kinds are also accepted and passed through to the frontend renderer.",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"type": "string", "const": "card"},
|
||||
"title": map[string]any{"type": "string"},
|
||||
"kind": map[string]any{"type": "string", "description": "Semantic subtype. Built-in: 'form'|'progress'|'todo'|'alert'. Custom values are forwarded to the frontend as-is."},
|
||||
"blocks": map[string]any{"type": "array"},
|
||||
"actions": map[string]any{"type": "array", "items": actionItemSchema},
|
||||
},
|
||||
"required": []string{"type"},
|
||||
},
|
||||
map[string]any{
|
||||
"description": "Custom part type. Any object with a 'type' string field is accepted and forwarded to the frontend renderer as-is.",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"type"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The message content to send",
|
||||
"description": "The visible text summary to send. Keep this even when using structured UI payloads so non-structured clients still have a readable fallback.",
|
||||
},
|
||||
"channel": map[string]any{
|
||||
"type": "string",
|
||||
|
|
@ -56,88 +92,73 @@ func (t *MessageTool) Parameters() map[string]any {
|
|||
"type": "string",
|
||||
"description": "Optional: reply target message ID for channels that support threaded replies",
|
||||
},
|
||||
"structured": map[string]any{
|
||||
"description": "Optional structured payload for rich UI rendering. Can be a single part or an array of parts.",
|
||||
"oneOf": []any{
|
||||
structuredPartSchema,
|
||||
map[string]any{
|
||||
"type": "array",
|
||||
"items": structuredPartSchema,
|
||||
"minItems": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
}
|
||||
}
|
||||
|
||||
// ResetSentInRound resets the per-round send tracker for the given session key.
|
||||
// Called by the agent loop at the start of each inbound message processing round.
|
||||
func (t *MessageTool) ResetSentInRound(sessionKey string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// Delete the key entirely to prevent unbounded map growth over time
|
||||
// with many unique sessions. Truncating the slice keeps the key alive.
|
||||
delete(t.sentTargets, sessionKey)
|
||||
}
|
||||
|
||||
// HasSentInRound returns true if the message tool sent a message during the current round.
|
||||
func (t *MessageTool) HasSentInRound(sessionKey string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.sentTargets[sessionKey]) > 0
|
||||
}
|
||||
|
||||
// HasSentTo returns true if the message tool sent to the specific channel+chatID
|
||||
// during the current round. Used by PublishResponseIfNeeded to avoid suppressing
|
||||
// the final response when the message tool only sent to a different conversation.
|
||||
func (t *MessageTool) HasSentTo(sessionKey, channel, chatID string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, st := range t.sentTargets[sessionKey] {
|
||||
if st.Channel == channel && st.ChatID == chatID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *MessageTool) SetSendCallback(callback SendCallbackWithContext) {
|
||||
t.sendCallback = callback
|
||||
}
|
||||
|
||||
func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
content, ok := args["content"].(string)
|
||||
if !ok {
|
||||
return &ToolResult{ForLLM: "content is required", IsError: true}
|
||||
}
|
||||
|
||||
channel, _ := args["channel"].(string)
|
||||
chatID, _ := args["chat_id"].(string)
|
||||
replyToMessageID, _ := args["reply_to_message_id"].(string)
|
||||
|
||||
if channel == "" {
|
||||
channel = ToolChannel(ctx)
|
||||
structured, err := parseMessageStructuredArgs(args)
|
||||
if err != nil {
|
||||
return &ToolResult{ForLLM: err.Error(), IsError: true}
|
||||
}
|
||||
if chatID == "" {
|
||||
chatID = ToolChatID(ctx)
|
||||
return t.executeSend(ctx, args, content, structured)
|
||||
}
|
||||
|
||||
func parseMessageStructuredArgs(args map[string]any) (any, error) {
|
||||
if _, exists := args["options"]; exists {
|
||||
return nil, fmt.Errorf("message does not accept top-level options; use structured.type='options'")
|
||||
}
|
||||
|
||||
if channel == "" || chatID == "" {
|
||||
return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
|
||||
if rawStructured, ok := args["structured"]; ok && rawStructured != nil {
|
||||
return normalizeStructuredPayload(rawStructured)
|
||||
}
|
||||
|
||||
if t.sendCallback == nil {
|
||||
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID); err != nil {
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||
IsError: true,
|
||||
Err: err,
|
||||
func normalizeStructuredPayload(rawStructured any) (any, error) {
|
||||
switch structured := rawStructured.(type) {
|
||||
case map[string]any:
|
||||
return normalizeStructuredEntry(structured, "structured")
|
||||
case []any:
|
||||
if len(structured) == 0 {
|
||||
return nil, fmt.Errorf("structured must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
sessionKey := ToolSessionKey(ctx)
|
||||
t.mu.Lock()
|
||||
t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID})
|
||||
t.mu.Unlock()
|
||||
|
||||
// Silent: user already received the message directly
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
|
||||
Silent: true,
|
||||
result := make([]any, 0, len(structured))
|
||||
for index, item := range structured {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("structured[%d] must be an object", index)
|
||||
}
|
||||
normalized, err := normalizeStructuredEntry(entry, fmt.Sprintf("structured[%d]", index))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, normalized)
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("structured must be an object or array")
|
||||
}
|
||||
}
|
||||
|
||||
// StructuredPart is implemented by every canonical and alias structured part type.
|
||||
// Parse validates and ingests raw LLM input; ToMap serializes back to the wire format.
|
||||
// This mirrors VS Code's ChatResponsePart design: each kind owns its own schema.
|
||||
|
|
|
|||
323
pkg/tools/integration/message_blocks.go
Normal file
323
pkg/tools/integration/message_blocks.go
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
package integrationtools
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CardBlock is implemented by every card block type.
|
||||
// Parse validates and ingests raw LLM input; ToMap serializes back to the wire format.
|
||||
type CardBlock interface {
|
||||
Parse(entry map[string]any, fieldPath string) error
|
||||
ToMap() map[string]any
|
||||
}
|
||||
|
||||
type cardBlockFactory func() CardBlock
|
||||
|
||||
// cardBlockRegistry maps each known block type to its factory.
|
||||
// "markdown" is an alias for "text". Custom block types are passed through by the dispatcher.
|
||||
var cardBlockRegistry = map[string]cardBlockFactory{
|
||||
"text": func() CardBlock { return &CardTextBlock{} },
|
||||
"markdown": func() CardBlock { return &CardTextBlock{} },
|
||||
"fields": func() CardBlock { return &CardFieldsBlock{} },
|
||||
"badge": func() CardBlock { return &CardBadgeBlock{} },
|
||||
"actions": func() CardBlock { return &CardActionsBlock{} },
|
||||
"list": func() CardBlock { return &CardListBlock{} },
|
||||
"table": func() CardBlock { return &CardTableBlock{} },
|
||||
"image": func() CardBlock { return &CardImageBlock{} },
|
||||
"divider": func() CardBlock { return &CardDividerBlock{} },
|
||||
"json": func() CardBlock { return &CardJSONBlock{} },
|
||||
}
|
||||
|
||||
func normalizeCardBlock(entry map[string]any, fieldPath string) (map[string]any, error) {
|
||||
blockType, _ := entry["type"].(string)
|
||||
if blockType == "" {
|
||||
return nil, fmt.Errorf("%s.type is required when structured.type='card'", fieldPath)
|
||||
}
|
||||
if factory, ok := cardBlockRegistry[blockType]; ok {
|
||||
block := factory()
|
||||
if err := block.Parse(cloneStructuredEntry(entry), fieldPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return block.ToMap(), nil
|
||||
}
|
||||
// Custom block types are passed through as long as they declare a type.
|
||||
return cloneStructuredEntry(entry), nil
|
||||
}
|
||||
|
||||
// CardTextBlock handles "text" and "markdown" block types.
|
||||
type CardTextBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardTextBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
blockType, _ := entry["type"].(string)
|
||||
text, _ := entry["text"].(string)
|
||||
if text == "" {
|
||||
return fmt.Errorf("%s.text is required for card block type '%s'", fieldPath, blockType)
|
||||
}
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardTextBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
// CardFieldsBlock handles the "fields" block type.
|
||||
type CardFieldsBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardFieldsBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
fields, err := normalizeCardFieldItems(entry["fields"], fieldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry["fields"] = fields
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardFieldsBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
// CardBadgeBlock handles the "badge" block type.
|
||||
type CardBadgeBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardBadgeBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
label, _ := entry["label"].(string)
|
||||
if label == "" {
|
||||
return fmt.Errorf("%s.label is required for card block type 'badge'", fieldPath)
|
||||
}
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardBadgeBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
// CardActionsBlock handles the "actions" block type.
|
||||
type CardActionsBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardActionsBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
actions, _, err := normalizeActionItems(entry["actions"], fieldPath+".actions", "card")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry["actions"] = actions
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardActionsBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
// CardListBlock handles the "list" block type.
|
||||
type CardListBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardListBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
items, err := normalizeCardListItems(entry["items"], fieldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry["items"] = items
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardListBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
// CardTableBlock handles the "table" block type.
|
||||
type CardTableBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardTableBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
rows, err := normalizeTableRows(entry["rows"], fieldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry["rows"] = rows
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardTableBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
// CardImageBlock handles the "image" block type.
|
||||
type CardImageBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardImageBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
url, _ := entry["url"].(string)
|
||||
if url == "" {
|
||||
return fmt.Errorf("%s.url is required for card block type 'image'", fieldPath)
|
||||
}
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardImageBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
// CardDividerBlock handles the "divider" block type (no required fields).
|
||||
type CardDividerBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardDividerBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardDividerBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
// CardJSONBlock handles the "json" block type.
|
||||
type CardJSONBlock struct{ raw map[string]any }
|
||||
|
||||
func (b *CardJSONBlock) Parse(entry map[string]any, fieldPath string) error {
|
||||
if _, ok := entry["data"]; !ok {
|
||||
return fmt.Errorf("%s.data is required for card block type 'json'", fieldPath)
|
||||
}
|
||||
b.raw = entry
|
||||
return nil
|
||||
}
|
||||
func (b *CardJSONBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
|
||||
|
||||
func normalizeCardFieldItems(raw any, fieldPath string) ([]any, error) {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.fields must be an array for card block type 'fields'", fieldPath)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("%s.fields must be a non-empty array for card block type 'fields'", fieldPath)
|
||||
}
|
||||
|
||||
normalized := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.fields[%d] must be an object for card block type 'fields'", fieldPath, index)
|
||||
}
|
||||
label, _ := entry["label"].(string)
|
||||
value, _ := entry["value"].(string)
|
||||
if label == "" || value == "" {
|
||||
return nil, fmt.Errorf("%s.fields[%d].label and %s.fields[%d].value are required for card block type 'fields'", fieldPath, index, fieldPath, index)
|
||||
}
|
||||
normalized = append(normalized, cloneStructuredEntry(entry))
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeCardListItems(raw any, fieldPath string) ([]any, error) {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.items must be an array for card block type 'list'", fieldPath)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("%s.items must be a non-empty array for card block type 'list'", fieldPath)
|
||||
}
|
||||
|
||||
normalized := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
if typed == "" {
|
||||
return nil, fmt.Errorf("%s.items[%d] must not be empty for card block type 'list'", fieldPath, index)
|
||||
}
|
||||
normalized = append(normalized, typed)
|
||||
case map[string]any:
|
||||
text, _ := typed["text"].(string)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("%s.items[%d].text is required for card block type 'list'", fieldPath, index)
|
||||
}
|
||||
normalized = append(normalized, cloneStructuredEntry(typed))
|
||||
default:
|
||||
return nil, fmt.Errorf("%s.items[%d] must be a string or object for card block type 'list'", fieldPath, index)
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTableRows(raw any, fieldPath string) ([]any, error) {
|
||||
rows, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.rows must be an array for card block type 'table'", fieldPath)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("%s.rows must be a non-empty array for card block type 'table'", fieldPath)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func normalizeFormFields(raw any, fieldPath string) ([]any, error) {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.fields must be an array when %s.type='form'", fieldPath, fieldPath)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("%s.fields must be a non-empty array when %s.type='form'", fieldPath, fieldPath)
|
||||
}
|
||||
|
||||
normalized := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.fields[%d] must be an object when %s.type='form'", fieldPath, index, fieldPath)
|
||||
}
|
||||
field := cloneStructuredEntry(entry)
|
||||
name, _ := field["name"].(string)
|
||||
label, _ := field["label"].(string)
|
||||
if name == "" || label == "" {
|
||||
return nil, fmt.Errorf("%s.fields[%d].name and %s.fields[%d].label are required when %s.type='form'", fieldPath, index, fieldPath, index, fieldPath)
|
||||
}
|
||||
normalized = append(normalized, field)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeProgressSteps(raw any, fieldPath string) ([]any, bool, error) {
|
||||
if raw == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("%s.steps must be an array when %s.type='progress'", fieldPath, fieldPath)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, false, fmt.Errorf("%s.steps must be a non-empty array when %s.type='progress'", fieldPath, fieldPath)
|
||||
}
|
||||
|
||||
normalized := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("%s.steps[%d] must be an object when %s.type='progress'", fieldPath, index, fieldPath)
|
||||
}
|
||||
step := cloneStructuredEntry(entry)
|
||||
assignFirstStringAlias(step, "detail", "detail", "description", "message", "content")
|
||||
label, _ := step["label"].(string)
|
||||
if label == "" {
|
||||
return nil, false, fmt.Errorf("%s.steps[%d].label is required when %s.type='progress'", fieldPath, index, fieldPath)
|
||||
}
|
||||
normalized = append(normalized, step)
|
||||
}
|
||||
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeTodoItems(raw any, fieldPath string) ([]any, error) {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.items must be an array when %s.type='todo'", fieldPath, fieldPath)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("%s.items must be a non-empty array when %s.type='todo'", fieldPath, fieldPath)
|
||||
}
|
||||
|
||||
normalized := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.items[%d] must be an object when %s.type='todo'", fieldPath, index, fieldPath)
|
||||
}
|
||||
todoItem := cloneStructuredEntry(entry)
|
||||
assignFirstStringAlias(todoItem, "title", "title", "label", "text", "step")
|
||||
assignFirstStringAlias(todoItem, "detail", "detail", "description", "message")
|
||||
title, _ := todoItem["title"].(string)
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("%s.items[%d].title is required when %s.type='todo'", fieldPath, index, fieldPath)
|
||||
}
|
||||
status, _ := todoItem["status"].(string)
|
||||
if status == "" {
|
||||
if done, ok := todoItem["done"].(bool); ok {
|
||||
if done {
|
||||
todoItem["status"] = "completed"
|
||||
} else {
|
||||
todoItem["status"] = "not-started"
|
||||
}
|
||||
} else {
|
||||
todoItem["status"] = "not-started"
|
||||
}
|
||||
} else if status != "not-started" && status != "in-progress" && status != "completed" {
|
||||
return nil, fmt.Errorf("%s.items[%d].status must be one of not-started, in-progress, completed when %s.type='todo'", fieldPath, index, fieldPath)
|
||||
}
|
||||
normalized = append(normalized, todoItem)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
123
pkg/tools/integration/message_dispatch.go
Normal file
123
pkg/tools/integration/message_dispatch.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package integrationtools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
|
||||
|
||||
// MessageOption is kept for external callers that reference it directly.
|
||||
// New code should use OptionItem in message.go instead.
|
||||
type MessageOption = OptionItem
|
||||
|
||||
type SendStructuredCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error
|
||||
|
||||
// sentTarget records the channel+chatID that a message-like tool sent to.
|
||||
type sentTarget struct {
|
||||
Channel string
|
||||
ChatID string
|
||||
}
|
||||
|
||||
type messageDispatchTool struct {
|
||||
sendCallback SendStructuredCallbackWithContext
|
||||
mu sync.Mutex
|
||||
// sentTargets tracks targets sent to in the current round, keyed by session key
|
||||
// to support parallel turns for different sessions.
|
||||
sentTargets map[string][]sentTarget
|
||||
}
|
||||
|
||||
func newMessageDispatchTool() messageDispatchTool {
|
||||
return messageDispatchTool{
|
||||
sentTargets: make(map[string][]sentTarget),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *messageDispatchTool) SetSendCallback(callback SendCallbackWithContext) {
|
||||
if callback == nil {
|
||||
t.sendCallback = nil
|
||||
return
|
||||
}
|
||||
t.sendCallback = func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
|
||||
return callback(ctx, channel, chatID, content, replyToMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *messageDispatchTool) SetStructuredSendCallback(callback SendStructuredCallbackWithContext) {
|
||||
t.sendCallback = callback
|
||||
}
|
||||
|
||||
// ResetSentInRound resets the per-round send tracker for the given session key.
|
||||
// Called by the agent loop at the start of each inbound message processing round.
|
||||
func (t *messageDispatchTool) ResetSentInRound(sessionKey string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// Delete the key entirely to prevent unbounded map growth over time
|
||||
// with many unique sessions. Truncating the slice keeps the key alive.
|
||||
delete(t.sentTargets, sessionKey)
|
||||
}
|
||||
|
||||
// HasSentInRound returns true if the tool sent a message during the current round.
|
||||
func (t *messageDispatchTool) HasSentInRound(sessionKey string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.sentTargets[sessionKey]) > 0
|
||||
}
|
||||
|
||||
// HasSentTo returns true if the tool sent to the specific channel+chatID during the current round.
|
||||
func (t *messageDispatchTool) HasSentTo(sessionKey, channel, chatID string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, st := range t.sentTargets[sessionKey] {
|
||||
if st.Channel == channel && st.ChatID == chatID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *messageDispatchTool) executeSend(
|
||||
ctx context.Context,
|
||||
args map[string]any,
|
||||
content string,
|
||||
structured any,
|
||||
) *ToolResult {
|
||||
channel, _ := args["channel"].(string)
|
||||
chatID, _ := args["chat_id"].(string)
|
||||
replyToMessageID, _ := args["reply_to_message_id"].(string)
|
||||
|
||||
if channel == "" {
|
||||
channel = ToolChannel(ctx)
|
||||
}
|
||||
if chatID == "" {
|
||||
chatID = ToolChatID(ctx)
|
||||
}
|
||||
|
||||
if channel == "" || chatID == "" {
|
||||
return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
|
||||
}
|
||||
|
||||
if t.sendCallback == nil {
|
||||
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
|
||||
}
|
||||
|
||||
if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID, structured); err != nil {
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||
IsError: true,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
sessionKey := ToolSessionKey(ctx)
|
||||
t.mu.Lock()
|
||||
t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID})
|
||||
t.mu.Unlock()
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
|
||||
Silent: true,
|
||||
}
|
||||
}
|
||||
380
pkg/tools/integration/message_structured.go
Normal file
380
pkg/tools/integration/message_structured.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
package integrationtools
|
||||
|
||||
import "fmt"
|
||||
|
||||
// StructuredPart is implemented by every canonical and alias structured part type.
|
||||
// Parse validates and ingests raw LLM input; ToMap serializes back to the wire format.
|
||||
// This mirrors VS Code's ChatResponsePart design: each kind owns its own schema.
|
||||
type StructuredPart interface {
|
||||
Parse(entry map[string]any, fieldPath string) error
|
||||
ToMap() map[string]any
|
||||
}
|
||||
|
||||
// structuredPartFactory creates a fresh zero-value StructuredPart for a given type name.
|
||||
// Using a factory (rather than storing instances) keeps each normalisation call isolated.
|
||||
type structuredPartFactory func() StructuredPart
|
||||
|
||||
// structuredPartRegistry maps each accepted type name to its factory.
|
||||
// Canonical types (options, card) produce their own parts.
|
||||
// Alias input types (form, progress, todo, alert) produce alias parts that
|
||||
// canonicalise to type:card + kind on ToMap.
|
||||
var structuredPartRegistry = map[string]structuredPartFactory{
|
||||
// Canonical types
|
||||
"options": func() StructuredPart { return &StructuredOptionsPart{} },
|
||||
"card": func() StructuredPart { return &StructuredCardPart{} },
|
||||
// Alias input types — accepted for backward compatibility, normalised to card+kind
|
||||
"form": func() StructuredPart { return &StructuredFormPart{} },
|
||||
"progress": func() StructuredPart { return &StructuredProgressPart{} },
|
||||
"todo": func() StructuredPart { return &StructuredTodoPart{} },
|
||||
"alert": func() StructuredPart { return &StructuredAlertPart{} },
|
||||
}
|
||||
|
||||
func normalizeStructuredEntry(entry map[string]any, fieldPath string) (map[string]any, error) {
|
||||
msgType, _ := entry["type"].(string)
|
||||
if msgType == "" {
|
||||
return nil, fmt.Errorf("%s.type is required", fieldPath)
|
||||
}
|
||||
|
||||
if factory, ok := structuredPartRegistry[msgType]; ok {
|
||||
part := factory()
|
||||
if err := part.Parse(cloneStructuredEntry(entry), fieldPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return part.ToMap(), nil
|
||||
}
|
||||
|
||||
// Unknown types are passed through to the frontend for custom rendering.
|
||||
// The frontend will render them via a registered custom renderer or fall back
|
||||
// to an "unknown" panel that displays the raw JSON.
|
||||
return cloneStructuredEntry(entry), nil
|
||||
}
|
||||
|
||||
// OptionItem is the canonical wire representation of a single selectable option.
|
||||
// Mirrors VS Code ChatResponseQuestionCarouselPart's item shape — each item owns
|
||||
// its label and a stable value that the reply handler receives.
|
||||
type OptionItem struct {
|
||||
Label string
|
||||
Value string
|
||||
Description string // optional
|
||||
}
|
||||
|
||||
func (o OptionItem) toMap() map[string]any {
|
||||
m := map[string]any{
|
||||
"label": o.Label,
|
||||
"value": o.Value,
|
||||
}
|
||||
if o.Description != "" {
|
||||
m["description"] = o.Description
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// StructuredOptionsPart is the canonical type for an options part.
|
||||
// Mirrors VS Code ChatResponseMarkdownPart / ChatResponseConfirmationPart — the struct
|
||||
// is the schema; Parse is the only place input validation lives.
|
||||
type StructuredOptionsPart struct {
|
||||
Options []OptionItem
|
||||
Mode string // "single" | "multiple"; defaults to "single"
|
||||
AllowCustom bool
|
||||
CustomPlaceholder string
|
||||
SubmitLabel string
|
||||
}
|
||||
|
||||
func (p *StructuredOptionsPart) Parse(entry map[string]any, fieldPath string) error {
|
||||
rawOptions, ok := entry["options"]
|
||||
if !ok || rawOptions == nil {
|
||||
return fmt.Errorf("%s.options must be an array when %s.type='options'", fieldPath, fieldPath)
|
||||
}
|
||||
items, ok := rawOptions.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.options must be an array when %s.type='options'", fieldPath, fieldPath)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("%s.options must not be empty", fieldPath)
|
||||
}
|
||||
|
||||
options := make([]OptionItem, 0, len(items))
|
||||
for i, item := range items {
|
||||
raw, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.options[%d] must be an object", fieldPath, i)
|
||||
}
|
||||
label, _ := raw["label"].(string)
|
||||
value, _ := raw["value"].(string)
|
||||
if label == "" || value == "" {
|
||||
return fmt.Errorf("%s.options[%d].label and .value are required", fieldPath, i)
|
||||
}
|
||||
description, _ := raw["description"].(string)
|
||||
options = append(options, OptionItem{Label: label, Value: value, Description: description})
|
||||
}
|
||||
|
||||
mode, _ := entry["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = "single"
|
||||
}
|
||||
|
||||
p.Options = options
|
||||
p.Mode = mode
|
||||
p.AllowCustom, _ = entry["allowCustom"].(bool)
|
||||
p.CustomPlaceholder, _ = entry["customPlaceholder"].(string)
|
||||
p.SubmitLabel, _ = entry["submitLabel"].(string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToMap serializes the canonical part back to a wire map sent to the frontend.
|
||||
func (p *StructuredOptionsPart) ToMap() map[string]any {
|
||||
items := make([]any, len(p.Options))
|
||||
for i, opt := range p.Options {
|
||||
items[i] = opt.toMap()
|
||||
}
|
||||
m := map[string]any{
|
||||
"type": "options",
|
||||
"options": items,
|
||||
"mode": p.Mode,
|
||||
}
|
||||
if p.AllowCustom {
|
||||
m["allowCustom"] = true
|
||||
}
|
||||
if p.CustomPlaceholder != "" {
|
||||
m["customPlaceholder"] = p.CustomPlaceholder
|
||||
}
|
||||
if p.SubmitLabel != "" {
|
||||
m["submitLabel"] = p.SubmitLabel
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// StructuredCardPart is the canonical type for a rich card.
|
||||
type StructuredCardPart struct {
|
||||
raw map[string]any // retains all fields after validation
|
||||
}
|
||||
|
||||
func (p *StructuredCardPart) Parse(entry map[string]any, fieldPath string) error {
|
||||
title, _ := entry["title"].(string)
|
||||
kind, _ := entry["kind"].(string)
|
||||
|
||||
blocks, hasBlocks, err := normalizeCardBlocks(entry["blocks"], fieldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actions, hasActions, err := normalizeActionItems(entry["actions"], fieldPath+".actions", "card")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if title == "" && kind == "" {
|
||||
return fmt.Errorf("%s.title is required when %s.type='card' unless %s.kind identifies a custom card", fieldPath, fieldPath, fieldPath)
|
||||
}
|
||||
if !hasBlocks && !hasActions {
|
||||
return fmt.Errorf("%s.blocks or %s.actions must be a non-empty array when %s.type='card'", fieldPath, fieldPath, fieldPath)
|
||||
}
|
||||
if hasBlocks {
|
||||
entry["blocks"] = blocks
|
||||
}
|
||||
if hasActions {
|
||||
entry["actions"] = actions
|
||||
}
|
||||
p.raw = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StructuredCardPart) ToMap() map[string]any {
|
||||
return cloneStructuredEntry(p.raw)
|
||||
}
|
||||
|
||||
// StructuredFormPart is an alias input type that normalises to type:card + kind:form.
|
||||
type StructuredFormPart struct {
|
||||
raw map[string]any
|
||||
}
|
||||
|
||||
func (p *StructuredFormPart) Parse(entry map[string]any, fieldPath string) error {
|
||||
assignFirstStringAlias(entry, "content", "content", "description", "message")
|
||||
title, _ := entry["title"].(string)
|
||||
content, _ := entry["content"].(string)
|
||||
if title == "" && content == "" {
|
||||
return fmt.Errorf("%s.title or %s.content is required when %s.type='form'", fieldPath, fieldPath, fieldPath)
|
||||
}
|
||||
|
||||
fields, err := normalizeFormFields(entry["fields"], fieldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry["fields"] = fields
|
||||
|
||||
actions, hasActions, err := normalizeActionItems(entry["actions"], fieldPath+".actions", "form")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasActions {
|
||||
entry["actions"] = actions
|
||||
}
|
||||
p.raw = canonicalizeStructuredAliasEntry(entry, "form")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StructuredFormPart) ToMap() map[string]any { return cloneStructuredEntry(p.raw) }
|
||||
|
||||
// StructuredProgressPart is an alias input type that normalises to type:card + kind:progress.
|
||||
type StructuredProgressPart struct {
|
||||
raw map[string]any
|
||||
}
|
||||
|
||||
func (p *StructuredProgressPart) Parse(entry map[string]any, fieldPath string) error {
|
||||
assignFirstStringAlias(entry, "content", "content", "description", "message", "detail")
|
||||
status, _ := entry["status"].(string)
|
||||
if status == "" {
|
||||
return fmt.Errorf("%s.status is required when %s.type='progress'", fieldPath, fieldPath)
|
||||
}
|
||||
|
||||
title, _ := entry["title"].(string)
|
||||
content, _ := entry["content"].(string)
|
||||
steps, hasSteps, err := normalizeProgressSteps(entry["steps"], fieldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if title == "" && content == "" && !hasSteps {
|
||||
return fmt.Errorf("%s.title, %s.content, or %s.steps must be provided when %s.type='progress'", fieldPath, fieldPath, fieldPath, fieldPath)
|
||||
}
|
||||
if hasSteps {
|
||||
entry["steps"] = steps
|
||||
}
|
||||
p.raw = canonicalizeStructuredAliasEntry(entry, "progress")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StructuredProgressPart) ToMap() map[string]any { return cloneStructuredEntry(p.raw) }
|
||||
|
||||
// StructuredTodoPart is an alias input type that normalises to type:card + kind:todo.
|
||||
type StructuredTodoPart struct {
|
||||
raw map[string]any
|
||||
}
|
||||
|
||||
func (p *StructuredTodoPart) Parse(entry map[string]any, fieldPath string) error {
|
||||
assignFirstStringAlias(entry, "content", "content", "description", "message")
|
||||
items, err := normalizeTodoItems(entry["items"], fieldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry["items"] = items
|
||||
p.raw = canonicalizeStructuredAliasEntry(entry, "todo")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StructuredTodoPart) ToMap() map[string]any { return cloneStructuredEntry(p.raw) }
|
||||
|
||||
// StructuredAlertPart is an alias input type that normalises to type:card + kind:alert.
|
||||
type StructuredAlertPart struct {
|
||||
raw map[string]any
|
||||
}
|
||||
|
||||
func (p *StructuredAlertPart) Parse(entry map[string]any, fieldPath string) error {
|
||||
assignFirstStringAlias(entry, "level", "level", "severity", "statusLevel")
|
||||
assignFirstStringAlias(entry, "content", "content", "description", "message", "detail")
|
||||
|
||||
level, _ := entry["level"].(string)
|
||||
if level == "" {
|
||||
return fmt.Errorf("%s.level is required when %s.type='alert'", fieldPath, fieldPath)
|
||||
}
|
||||
content, _ := entry["content"].(string)
|
||||
if content == "" {
|
||||
return fmt.Errorf("%s.content is required when %s.type='alert'", fieldPath, fieldPath)
|
||||
}
|
||||
|
||||
actions, hasActions, err := normalizeActionItems(entry["actions"], fieldPath+".actions", "alert")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasActions {
|
||||
entry["actions"] = actions
|
||||
}
|
||||
p.raw = canonicalizeStructuredAliasEntry(entry, "alert")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StructuredAlertPart) ToMap() map[string]any { return cloneStructuredEntry(p.raw) }
|
||||
|
||||
func canonicalizeStructuredAliasEntry(entry map[string]any, aliasType string) map[string]any {
|
||||
canonical := cloneStructuredEntry(entry)
|
||||
canonical["type"] = "card"
|
||||
if kind, _ := canonical["kind"].(string); kind == "" {
|
||||
canonical["kind"] = aliasType
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
func cloneStructuredEntry(entry map[string]any) map[string]any {
|
||||
cloned := make(map[string]any, len(entry))
|
||||
for key, value := range entry {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func assignFirstStringAlias(entry map[string]any, target string, aliases ...string) {
|
||||
for _, alias := range aliases {
|
||||
value, ok := entry[alias].(string)
|
||||
if ok && value != "" {
|
||||
entry[target] = value
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeActionItems(raw any, fieldPath string, parentType string) ([]any, bool, error) {
|
||||
if raw == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("%s must be an array when structured.type='%s'", fieldPath, parentType)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, false, fmt.Errorf("%s must be a non-empty array when structured.type='%s'", fieldPath, parentType)
|
||||
}
|
||||
|
||||
normalized := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("%s[%d] must be an object when structured.type='%s'", fieldPath, index, parentType)
|
||||
}
|
||||
label, _ := entry["label"].(string)
|
||||
if label == "" {
|
||||
return nil, false, fmt.Errorf("%s[%d].label is required when structured.type='%s'", fieldPath, index, parentType)
|
||||
}
|
||||
normalized = append(normalized, cloneStructuredEntry(entry))
|
||||
}
|
||||
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeCardBlocks(raw any, fieldPath string) ([]any, bool, error) {
|
||||
if raw == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("%s.blocks must be an array when %s.type='card'", fieldPath, fieldPath)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, false, fmt.Errorf("%s.blocks must be a non-empty array when %s.type='card'", fieldPath, fieldPath)
|
||||
}
|
||||
|
||||
normalized := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("%s.blocks[%d] must be an object when %s.type='card'", fieldPath, index, fieldPath)
|
||||
}
|
||||
block, err := normalizeCardBlock(entry, fmt.Sprintf("%s.blocks[%d]", fieldPath, index))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
normalized = append(normalized, block)
|
||||
}
|
||||
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
// CardBlock is implemented by every card block type.
|
||||
// Parse validates and ingests raw LLM input; ToMap serializes back to the wire format.
|
||||
|
|
@ -3,6 +3,7 @@ package integrationtools
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
|
|
@ -208,6 +209,15 @@ func TestMessageTool_Description(t *testing.T) {
|
|||
if desc == "" {
|
||||
t.Error("Description should not be empty")
|
||||
}
|
||||
|
||||
for _, snippet := range []string{
|
||||
"card",
|
||||
"options",
|
||||
} {
|
||||
if !strings.Contains(desc, snippet) {
|
||||
t.Fatalf("Description() missing snippet %q", snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Parameters(t *testing.T) {
|
||||
|
|
@ -266,6 +276,58 @@ func TestMessageTool_Parameters(t *testing.T) {
|
|||
if replyToProp["type"] != "string" {
|
||||
t.Error("Expected reply_to_message_id type to be 'string'")
|
||||
}
|
||||
|
||||
structuredProp, ok := props["structured"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected 'structured' property")
|
||||
}
|
||||
if _, ok := structuredProp["description"].(string); !ok {
|
||||
t.Fatal("Expected structured description")
|
||||
}
|
||||
// structured must use oneOf to enumerate part types as machine-readable JSON Schema
|
||||
oneOf, ok := structuredProp["oneOf"].([]any)
|
||||
if !ok || len(oneOf) == 0 {
|
||||
t.Fatal("Expected structured.oneOf")
|
||||
}
|
||||
// find the single-part schema (object with oneOf) and verify canonical types are present
|
||||
foundTypes := map[string]bool{}
|
||||
var checkSchema func(v any)
|
||||
checkSchema = func(v any) {
|
||||
switch vt := v.(type) {
|
||||
case map[string]any:
|
||||
if c, ok := vt["const"].(string); ok {
|
||||
foundTypes[c] = true
|
||||
}
|
||||
if e, ok := vt["enum"].([]string); ok {
|
||||
for _, s := range e {
|
||||
foundTypes[s] = true
|
||||
}
|
||||
}
|
||||
// also scan description strings for kind hints (e.g. "'form'|'progress'|...")
|
||||
if d, ok := vt["description"].(string); ok {
|
||||
for _, kw := range []string{"form", "progress", "todo", "alert", "options", "card"} {
|
||||
if strings.Contains(d, kw) {
|
||||
foundTypes[kw] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, val := range vt {
|
||||
checkSchema(val)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range vt {
|
||||
checkSchema(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range oneOf {
|
||||
checkSchema(item)
|
||||
}
|
||||
for _, kind := range []string{"options", "card", "form", "progress", "todo", "alert"} {
|
||||
if !foundTypes[kind] {
|
||||
t.Errorf("structured oneOf schema missing type/kind %q", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
|
||||
|
|
@ -329,3 +391,396 @@ func TestMessageTool_Execute_PropagatesTurnSessionMetadata(t *testing.T) {
|
|||
t.Fatalf("ToolSessionScope() = %+v, want chat scope", gotScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_RejectsLegacyOptionsArgs(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "Choose one:",
|
||||
"options": []any{
|
||||
map[string]any{"label": "A", "value": "alpha"},
|
||||
},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected options on message tool to fail")
|
||||
}
|
||||
if result.ForLLM != "message does not accept top-level options; use structured.type='options'" {
|
||||
t.Fatalf("ForLLM = %q, want message does not accept top-level options; use structured.type='options'", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_WithStructuredOptionsPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
var gotStructured any
|
||||
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
|
||||
gotStructured = structured
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "Choose one:",
|
||||
"structured": map[string]any{
|
||||
"type": "options",
|
||||
"mode": "multiple",
|
||||
"allowCustom": true,
|
||||
"submitLabel": "Confirm",
|
||||
"options": []any{
|
||||
map[string]any{"label": "A", "value": "alpha"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
structuredMap, ok := gotStructured.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected structured payload")
|
||||
}
|
||||
if structuredMap["type"] != "options" {
|
||||
t.Fatalf("structured type = %v, want options", structuredMap["type"])
|
||||
}
|
||||
items, ok := structuredMap["options"].([]any)
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("structured options = %+v, want 1 option", structuredMap["options"])
|
||||
}
|
||||
first, ok := items[0].(map[string]any)
|
||||
if !ok || first["label"] != "A" || first["value"] != "alpha" {
|
||||
t.Fatalf("first option = %+v, want normalized option", items[0])
|
||||
}
|
||||
if structuredMap["mode"] != "multiple" {
|
||||
t.Fatalf("structured mode = %v, want multiple", structuredMap["mode"])
|
||||
}
|
||||
if structuredMap["allowCustom"] != true {
|
||||
t.Fatalf("structured allowCustom = %v, want true", structuredMap["allowCustom"])
|
||||
}
|
||||
if structuredMap["submitLabel"] != "Confirm" {
|
||||
t.Fatalf("structured submitLabel = %v, want Confirm", structuredMap["submitLabel"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_InvalidStructuredOptionsPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "Choose one:",
|
||||
"structured": map[string]any{
|
||||
"type": "options",
|
||||
},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected invalid structured options payload to fail")
|
||||
}
|
||||
if result.ForLLM != "structured.options must be an array when structured.type='options'" {
|
||||
t.Fatalf("ForLLM = %q, want structured.options must be an array when structured.type='options'", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_WithStructuredCardPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
var gotStructured any
|
||||
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
|
||||
gotStructured = structured
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "审批卡片摘要",
|
||||
"structured": map[string]any{
|
||||
"type": "card",
|
||||
"kind": "custom/approval-card",
|
||||
"title": "待审批",
|
||||
"blocks": []any{
|
||||
map[string]any{"type": "text", "text": "张三提交了请假申请"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
structuredMap, ok := gotStructured.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected structured payload")
|
||||
}
|
||||
if structuredMap["type"] != "card" {
|
||||
t.Fatalf("structured type = %v, want card", structuredMap["type"])
|
||||
}
|
||||
if structuredMap["kind"] != "custom/approval-card" {
|
||||
t.Fatalf("structured kind = %v, want custom/approval-card", structuredMap["kind"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_WithStructuredListPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
var gotStructured any
|
||||
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
|
||||
gotStructured = structured
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "Mixed structured payload",
|
||||
"structured": []any{
|
||||
map[string]any{"type": "progress", "title": "Syncing", "status": "running"},
|
||||
map[string]any{"type": "alert", "level": "info", "content": "Waiting"},
|
||||
},
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
items, ok := gotStructured.([]any)
|
||||
if !ok || len(items) != 2 {
|
||||
t.Fatalf("structured payload = %#v, want 2-item list", gotStructured)
|
||||
}
|
||||
progress, ok := items[0].(map[string]any)
|
||||
if !ok || progress["type"] != "card" || progress["kind"] != "progress" {
|
||||
t.Fatalf("first structured item = %#v, want card kind progress", items[0])
|
||||
}
|
||||
alert, ok := items[1].(map[string]any)
|
||||
if !ok || alert["type"] != "card" || alert["kind"] != "alert" {
|
||||
t.Fatalf("second structured item = %#v, want card kind alert", items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_NormalizesStructuredAliases(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
var gotStructured any
|
||||
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
|
||||
gotStructured = structured
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "Mixed structured payload",
|
||||
"structured": []any{
|
||||
map[string]any{"type": "progress", "message": "Working", "status": "running"},
|
||||
map[string]any{"type": "todo", "items": []any{map[string]any{"label": "Review", "done": true}}},
|
||||
map[string]any{"type": "alert", "severity": "info", "message": "Heads up"},
|
||||
},
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
items, ok := gotStructured.([]any)
|
||||
if !ok || len(items) != 3 {
|
||||
t.Fatalf("structured payload = %#v, want 3-item list", gotStructured)
|
||||
}
|
||||
|
||||
progress, ok := items[0].(map[string]any)
|
||||
if !ok || progress["type"] != "card" || progress["kind"] != "progress" || progress["content"] != "Working" {
|
||||
t.Fatalf("progress = %#v, want canonical card progress payload", items[0])
|
||||
}
|
||||
todo, ok := items[1].(map[string]any)
|
||||
if !ok || todo["type"] != "card" || todo["kind"] != "todo" {
|
||||
t.Fatalf("todo = %#v, want canonical card todo payload", items[1])
|
||||
}
|
||||
todoItems, ok := todo["items"].([]any)
|
||||
if !ok || len(todoItems) != 1 {
|
||||
t.Fatalf("todo items = %#v, want 1 item", todo["items"])
|
||||
}
|
||||
firstTodo, ok := todoItems[0].(map[string]any)
|
||||
if !ok || firstTodo["title"] != "Review" || firstTodo["status"] != "completed" {
|
||||
t.Fatalf("first todo = %#v, want normalized title/status", todoItems[0])
|
||||
}
|
||||
alert, ok := items[2].(map[string]any)
|
||||
if !ok || alert["type"] != "card" || alert["kind"] != "alert" || alert["level"] != "info" || alert["content"] != "Heads up" {
|
||||
t.Fatalf("alert = %#v, want canonical card alert payload", items[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_CanonicalizesStructuredFormAlias(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
var gotStructured any
|
||||
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
|
||||
gotStructured = structured
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "填写反馈",
|
||||
"structured": map[string]any{
|
||||
"type": "form",
|
||||
"title": "反馈表",
|
||||
"content": "请补充你的意见",
|
||||
"fields": []any{
|
||||
map[string]any{"name": "feedback", "label": "你的反馈"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
structuredMap, ok := gotStructured.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected structured payload")
|
||||
}
|
||||
if structuredMap["type"] != "card" || structuredMap["kind"] != "form" {
|
||||
t.Fatalf("structured payload = %#v, want card kind form", structuredMap)
|
||||
}
|
||||
fields, ok := structuredMap["fields"].([]any)
|
||||
if !ok || len(fields) != 1 {
|
||||
t.Fatalf("structured fields = %#v, want 1 field", structuredMap["fields"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_InvalidStructuredCardPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "bad card",
|
||||
"structured": map[string]any{
|
||||
"type": "card",
|
||||
"title": "Card without body",
|
||||
},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected invalid structured card payload to fail")
|
||||
}
|
||||
if result.ForLLM != "structured.blocks or structured.actions must be a non-empty array when structured.type='card'" {
|
||||
t.Fatalf("ForLLM = %q, want repairable card error", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_InvalidStructuredFormPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "bad form",
|
||||
"structured": map[string]any{
|
||||
"type": "form",
|
||||
"title": "Missing fields",
|
||||
},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected invalid structured form payload to fail")
|
||||
}
|
||||
if result.ForLLM != "structured.fields must be an array when structured.type='form'" {
|
||||
t.Fatalf("ForLLM = %q, want repairable form error", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_InvalidStructuredProgressPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "bad progress",
|
||||
"structured": map[string]any{
|
||||
"type": "progress",
|
||||
"title": "Syncing",
|
||||
},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected invalid structured progress payload to fail")
|
||||
}
|
||||
if result.ForLLM != "structured.status is required when structured.type='progress'" {
|
||||
t.Fatalf("ForLLM = %q, want repairable progress error", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_InvalidStructuredTodoPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "bad todo",
|
||||
"structured": map[string]any{
|
||||
"type": "todo",
|
||||
"items": []any{map[string]any{"status": "waiting"}},
|
||||
},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected invalid structured todo payload to fail")
|
||||
}
|
||||
if result.ForLLM != "structured.items[0].title is required when structured.type='todo'" {
|
||||
t.Fatalf("ForLLM = %q, want repairable todo error", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_InvalidStructuredAlertPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "bad alert",
|
||||
"structured": map[string]any{
|
||||
"type": "alert",
|
||||
"severity": "info",
|
||||
},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected invalid structured alert payload to fail")
|
||||
}
|
||||
if result.ForLLM != "structured.content is required when structured.type='alert'" {
|
||||
t.Fatalf("ForLLM = %q, want repairable alert error", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_InvalidStructuredListPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "bad",
|
||||
"structured": []any{map[string]any{"title": "missing type"}},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected invalid structured list payload to fail")
|
||||
}
|
||||
if result.ForLLM != "structured[0].type is required" {
|
||||
t.Fatalf("ForLLM = %q, want structured[0].type is required", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_InvalidStructuredPayload(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "bad",
|
||||
"structured": map[string]any{},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected invalid structured payload to fail")
|
||||
}
|
||||
if result.ForLLM != "structured.type is required" {
|
||||
t.Fatalf("ForLLM = %q, want structured.type is required", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_UnknownTypePassesThrough(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
var gotStructured any
|
||||
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
|
||||
gotStructured = structured
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"content": "Custom component",
|
||||
"structured": map[string]any{
|
||||
"type": "approval",
|
||||
"title": "Deploy to prod",
|
||||
"approver": "alice",
|
||||
},
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("expected unknown type to pass through, got error: %s", result.ForLLM)
|
||||
}
|
||||
structuredMap, ok := gotStructured.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected structured payload")
|
||||
}
|
||||
if structuredMap["type"] != "approval" {
|
||||
t.Fatalf("structured type = %v, want approval", structuredMap["type"])
|
||||
}
|
||||
if structuredMap["approver"] != "alice" {
|
||||
t.Fatalf("structured approver = %v, want alice", structuredMap["approver"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,3 +266,30 @@ func TestToolResultContentForLLM_AppendsArtifactPaths(t *testing.T) {
|
|||
t.Fatalf("expected artifact guidance note in ContentForLLM, got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultShouldPublishDirectly_DefaultsToFalseForPlainUserResult(t *testing.T) {
|
||||
result := UserResult("user visible message")
|
||||
|
||||
if result.ShouldPublishDirectly(true) {
|
||||
t.Fatal("expected plain UserResult not to publish directly by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultShouldPublishDirectly_RequiresSendResponseForExplicitDirect(t *testing.T) {
|
||||
result := UserResult("user visible message").WithDirectUserResponse()
|
||||
|
||||
if result.ShouldPublishDirectly(false) {
|
||||
t.Fatal("expected direct user response to respect sendResponse=false")
|
||||
}
|
||||
if !result.ShouldPublishDirectly(true) {
|
||||
t.Fatal("expected direct user response to publish when sendResponse=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultShouldPublishDirectly_ResponseHandledAlwaysPublishes(t *testing.T) {
|
||||
result := UserResult("handled").WithResponseHandled()
|
||||
|
||||
if !result.ShouldPublishDirectly(false) {
|
||||
t.Fatal("expected handled response to publish even when sendResponse=false")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ type ToolResult struct {
|
|||
// user's request at the channel/output level, so the agent loop can stop
|
||||
// without a follow-up assistant response.
|
||||
ResponseHandled bool `json:"response_handled,omitempty"`
|
||||
|
||||
// DirectUserResponse explicitly allows the raw ForUser payload to be sent
|
||||
// straight to the chat channel before the model produces a final reply.
|
||||
// Keep this false by default so the primary interaction path is
|
||||
// tool-result -> model -> assistant reply.
|
||||
DirectUserResponse bool `json:"direct_user_response,omitempty"`
|
||||
}
|
||||
|
||||
// ContentForLLM returns the normalized textual content to append to the
|
||||
|
|
@ -93,6 +99,18 @@ func (tr *ToolResult) ContentForLLM() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// ShouldPublishDirectly reports whether the raw ForUser payload should be sent
|
||||
// straight to the user channel instead of only flowing back to the model.
|
||||
func (tr *ToolResult) ShouldPublishDirectly(sendResponse bool) bool {
|
||||
if tr == nil || tr.Silent || tr.ForUser == "" {
|
||||
return false
|
||||
}
|
||||
if tr.ResponseHandled {
|
||||
return true
|
||||
}
|
||||
return sendResponse && tr.DirectUserResponse
|
||||
}
|
||||
|
||||
// NewToolResult creates a basic ToolResult with content for the LLM.
|
||||
// Use this when you need a simple result with default behavior.
|
||||
//
|
||||
|
|
@ -221,3 +239,10 @@ func (tr *ToolResult) WithResponseHandled() *ToolResult {
|
|||
tr.ResponseHandled = true
|
||||
return tr
|
||||
}
|
||||
|
||||
// WithDirectUserResponse marks the result for immediate raw delivery to the
|
||||
// user channel in addition to feeding the result back to the model.
|
||||
func (tr *ToolResult) WithDirectUserResponse() *ToolResult {
|
||||
tr.DirectUserResponse = true
|
||||
return tr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,9 +46,11 @@ type sessionListItem struct {
|
|||
}
|
||||
|
||||
type sessionChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
Structured any `json:"structured,omitempty"`
|
||||
}
|
||||
|
||||
// legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL
|
||||
|
|
@ -460,9 +462,6 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "tool":
|
||||
continue
|
||||
|
||||
case "user":
|
||||
if sessionMessageVisible(msg) {
|
||||
transcript = append(transcript, sessionChatMessage{
|
||||
|
|
@ -473,10 +472,15 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
}
|
||||
|
||||
case "assistant":
|
||||
// Reasoning-only assistant messages are transient display artifacts and
|
||||
// should not be restored from session history.
|
||||
if assistantMessageTransientThought(msg) {
|
||||
continue
|
||||
if strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
transcript = append(transcript, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: msg.ReasoningContent,
|
||||
Kind: "thought",
|
||||
})
|
||||
if assistantMessageTransientThought(msg) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
|
||||
|
|
@ -489,6 +493,13 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
transcript = append(transcript, visibleToolMessages...)
|
||||
}
|
||||
|
||||
// Tool-call assistant content is usually a transient preamble such as
|
||||
// "I'll check that now." Keep the reconstructed tool summaries/cards and
|
||||
// hide the raw assistant content to match the live Copilot-style UI.
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -504,18 +515,7 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
}
|
||||
}
|
||||
|
||||
return filterSessionChatMessages(transcript)
|
||||
}
|
||||
|
||||
func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage {
|
||||
filtered := messages[:0]
|
||||
for _, msg := range messages {
|
||||
if msg.Role != "user" && msg.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, msg)
|
||||
}
|
||||
return filtered
|
||||
return transcript
|
||||
}
|
||||
|
||||
func assistantMessageTransientThought(msg providers.Message) bool {
|
||||
|
|
@ -542,16 +542,38 @@ func visibleAssistantToolSummaryMessages(
|
|||
|
||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
name, argsJSON := toolCallNameAndArguments(tc)
|
||||
name := tc.Name
|
||||
argsJSON := ""
|
||||
if tc.Function != nil {
|
||||
if name == "" {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
argsJSON = tc.Function.Arguments
|
||||
}
|
||||
|
||||
if strings.TrimSpace(name) == "" {
|
||||
continue
|
||||
}
|
||||
if name == "web_search" || name == "web_fetch" {
|
||||
|
||||
if name != "message" {
|
||||
content := visibleAssistantToolProgressContent(name, argsJSON, tc.Arguments, toolFeedbackMaxArgsLength)
|
||||
messages = append(messages, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
Structured: map[string]any{
|
||||
"type": "progress",
|
||||
"kind": "agent/tool-exec",
|
||||
"title": name,
|
||||
"status": "completed",
|
||||
"content": content,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if name == "message" {
|
||||
if _, ok := parseMessageToolContent(argsJSON); ok {
|
||||
continue
|
||||
|
||||
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
|
||||
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
|
||||
argsJSON = string(encodedArgs)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -569,6 +591,138 @@ func visibleAssistantToolSummaryMessages(
|
|||
return messages
|
||||
}
|
||||
|
||||
func visibleAssistantToolProgressContent(
|
||||
toolName string,
|
||||
argsJSON string,
|
||||
rawArgs map[string]any,
|
||||
maxArgsLength int,
|
||||
) string {
|
||||
if maxArgsLength <= 0 {
|
||||
maxArgsLength = defaultToolFeedbackMaxArgsLength()
|
||||
}
|
||||
|
||||
args := map[string]any{}
|
||||
if strings.TrimSpace(argsJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(argsJSON), &args)
|
||||
}
|
||||
for key, value := range rawArgs {
|
||||
if _, exists := args[key]; !exists {
|
||||
args[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if summary := summarizeToolArgs(toolName, args); summary != "" {
|
||||
return summary
|
||||
}
|
||||
|
||||
preview := strings.TrimSpace(argsJSON)
|
||||
if preview == "" && len(rawArgs) > 0 {
|
||||
if encoded, err := json.Marshal(rawArgs); err == nil {
|
||||
preview = string(encoded)
|
||||
}
|
||||
}
|
||||
if preview == "" {
|
||||
return "Tool executed during this turn."
|
||||
}
|
||||
|
||||
return utils.Truncate(preview, maxArgsLength)
|
||||
}
|
||||
|
||||
func summarizeToolArgs(toolName string, args map[string]any) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
path := firstNonEmptyString(args, "path", "filePath", "file_path", "dirPath", "dir_path", "resourcePath", "workspaceFolder")
|
||||
query := firstNonEmptyString(args, "query", "command", "goal", "target")
|
||||
url := firstNonEmptyString(args, "url")
|
||||
urls := stringSliceSummary(args["urls"])
|
||||
packages := stringSliceSummary(args["packageList"])
|
||||
|
||||
parts := make([]string, 0, 3)
|
||||
if path != "" {
|
||||
if toolName == "read_file" {
|
||||
if start, ok := numericArg(args, "startLine", "start_line"); ok {
|
||||
if end, ok := numericArg(args, "endLine", "end_line"); ok {
|
||||
parts = append(parts, path+":"+strconv.Itoa(start)+"-"+strconv.Itoa(end))
|
||||
} else {
|
||||
parts = append(parts, path+":"+strconv.Itoa(start))
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, path)
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, path)
|
||||
}
|
||||
}
|
||||
if query != "" && query != path {
|
||||
parts = append(parts, query)
|
||||
}
|
||||
if url != "" {
|
||||
parts = append(parts, url)
|
||||
} else if urls != "" {
|
||||
parts = append(parts, urls)
|
||||
}
|
||||
if packages != "" {
|
||||
parts = append(parts, packages)
|
||||
}
|
||||
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func firstNonEmptyString(args map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := args[key]; ok {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if trimmed := strings.TrimSpace(typed); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func numericArg(args map[string]any, keys ...string) (int, bool) {
|
||||
for _, key := range keys {
|
||||
value, ok := args[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int(typed), true
|
||||
case int:
|
||||
return typed, true
|
||||
case int64:
|
||||
return int(typed), true
|
||||
case json.Number:
|
||||
if parsed, err := typed.Int64(); err == nil {
|
||||
return int(parsed), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func stringSliceSummary(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return strings.Join(typed, ", ")
|
||||
case []any:
|
||||
items := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
|
||||
items = append(items, strings.TrimSpace(text))
|
||||
}
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
|
|
@ -576,51 +730,144 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
|
|||
|
||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
name, argsJSON := toolCallNameAndArguments(tc)
|
||||
if name != "message" {
|
||||
continue
|
||||
name := tc.Name
|
||||
argsJSON := ""
|
||||
if tc.Function != nil {
|
||||
if name == "" {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
argsJSON = tc.Function.Arguments
|
||||
}
|
||||
content, ok := parseMessageToolContent(argsJSON)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
switch name {
|
||||
case "message":
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
continue
|
||||
}
|
||||
content, _ := args["content"].(string)
|
||||
structured := visibleStructuredMessageArg(args)
|
||||
if strings.TrimSpace(content) == "" && structured == nil {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Structured: structured,
|
||||
})
|
||||
}
|
||||
messages = append(messages, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func toolCallNameAndArguments(tc providers.ToolCall) (string, string) {
|
||||
name := tc.Name
|
||||
argsJSON := ""
|
||||
if tc.Function != nil {
|
||||
if name == "" {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
argsJSON = tc.Function.Arguments
|
||||
}
|
||||
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
|
||||
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
|
||||
argsJSON = string(encodedArgs)
|
||||
func visibleStructuredMessageArg(args map[string]any) any {
|
||||
if rawStructured, ok := args["structured"]; ok && rawStructured != nil {
|
||||
switch structured := rawStructured.(type) {
|
||||
case map[string]any:
|
||||
msgType, _ := structured["type"].(string)
|
||||
if strings.TrimSpace(msgType) != "" {
|
||||
return structured
|
||||
}
|
||||
case []any:
|
||||
parts := make([]any, 0, len(structured))
|
||||
for _, item := range structured {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msgType, _ := entry["type"].(string)
|
||||
if strings.TrimSpace(msgType) == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, entry)
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return parts
|
||||
}
|
||||
}
|
||||
}
|
||||
return name, argsJSON
|
||||
}
|
||||
|
||||
func parseMessageToolContent(argsJSON string) (string, bool) {
|
||||
var args struct {
|
||||
Content string `json:"content"`
|
||||
var rawOptions []any
|
||||
switch optionsValue := args["options"].(type) {
|
||||
case []any:
|
||||
rawOptions = optionsValue
|
||||
case map[string]any:
|
||||
items, ok := optionsValue["options"].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
rawOptions = items
|
||||
result := map[string]any{
|
||||
"type": "options",
|
||||
"options": []map[string]any{},
|
||||
}
|
||||
for _, key := range []string{"mode", "selectionMode", "selection_mode", "multiple", "multi", "multiSelect", "multi_select", "allowCustom", "allow_custom", "customPlaceholder", "custom_placeholder", "submitLabel", "submit_label"} {
|
||||
if value, exists := optionsValue[key]; exists {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
options := make([]map[string]any, 0, len(rawOptions))
|
||||
for _, item := range rawOptions {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label, _ := entry["label"].(string)
|
||||
value, _ := entry["value"].(string)
|
||||
if strings.TrimSpace(label) == "" || strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
option := map[string]any{
|
||||
"label": label,
|
||||
"value": value,
|
||||
}
|
||||
if description, _ := entry["description"].(string); strings.TrimSpace(description) != "" {
|
||||
option["description"] = description
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
result["options"] = options
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", false
|
||||
if len(rawOptions) == 0 {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(args.Content) == "" {
|
||||
return "", false
|
||||
|
||||
options := make([]map[string]any, 0, len(rawOptions))
|
||||
for _, item := range rawOptions {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label, _ := entry["label"].(string)
|
||||
value, _ := entry["value"].(string)
|
||||
if strings.TrimSpace(label) == "" || strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
option := map[string]any{
|
||||
"label": label,
|
||||
"value": value,
|
||||
}
|
||||
if description, _ := entry["description"].(string); strings.TrimSpace(description) != "" {
|
||||
option["description"] = description
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": "options",
|
||||
"options": options,
|
||||
}
|
||||
return args.Content, true
|
||||
}
|
||||
|
||||
// sessionsDir resolves the path to the gateway's session storage directory.
|
||||
|
|
|
|||
|
|
@ -328,21 +328,25 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
|
|||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Structured map[string]any `json:"structured"`
|
||||
} `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 len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" {
|
||||
t.Fatalf("first message = %#v, want user/hello", resp.Messages[0])
|
||||
}
|
||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" {
|
||||
t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1])
|
||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "internal chain of thought" {
|
||||
t.Fatalf("second message = %#v, want assistant/internal chain of thought", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" {
|
||||
t.Fatalf("third message = %#v, want assistant/final visible answer", resp.Messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -395,8 +399,9 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSu
|
|||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Structured map[string]any `json:"structured"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
|
|
@ -466,8 +471,9 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
|
|||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Structured map[string]any `json:"structured"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
|
|
@ -487,6 +493,286 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_HidesAssistantPreludeWhenToolCallsArePresent(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-tool-prelude-hidden"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "check workspace"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "我来再次执行这个任务。",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "list_dir",
|
||||
Arguments: `{}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{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-tool-prelude-hidden", 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"`
|
||||
Structured map[string]any `json:"structured"`
|
||||
} `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].Structured["type"] != "progress" {
|
||||
t.Fatalf("tool summary message = %#v, want structured progress card", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Content != "final assistant reply" {
|
||||
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
|
||||
}
|
||||
for _, msg := range resp.Messages {
|
||||
if msg.Content == "我来再次执行这个任务。" {
|
||||
t.Fatalf("unexpected tool-call prelude content in visible transcript: %#v", resp.Messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsVisibleMessageToolOptions(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-options"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "pick"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "message",
|
||||
Arguments: `{"content":"Choose one:","options":[{"label":"A","value":"alpha"},{"label":"B","value":"beta"}]}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
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-options", 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"`
|
||||
Structured struct {
|
||||
Type string `json:"type"`
|
||||
Options []struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
} `json:"options"`
|
||||
} `json:"structured"`
|
||||
} `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 !strings.Contains(resp.Messages[1].Content, "`message`") {
|
||||
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Structured.Type != "options" || len(resp.Messages[2].Structured.Options) != 2 {
|
||||
t.Fatalf("structured = %#v, want options payload", resp.Messages[2].Structured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsCustomCardStructuredPayload(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-custom-card"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "show card"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "message",
|
||||
Arguments: `{"content":"审批摘要","structured":{"type":"card","kind":"custom/approval-card","title":"待审批","blocks":[{"type":"text","text":"张三提交了请假申请"},{"type":"actions","actions":[{"label":"批准","value":"approve"},{"label":"拒绝","value":"reject"}]}]}}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
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-custom-card", 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 {
|
||||
Content string `json:"content"`
|
||||
Structured struct {
|
||||
Type string `json:"type"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Blocks []struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"blocks"`
|
||||
} `json:"structured"`
|
||||
} `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[2].Structured.Type != "card" {
|
||||
t.Fatalf("structured type = %q, want card", resp.Messages[2].Structured.Type)
|
||||
}
|
||||
if resp.Messages[2].Structured.Kind != "custom/approval-card" {
|
||||
t.Fatalf("structured kind = %q, want custom/approval-card", resp.Messages[2].Structured.Kind)
|
||||
}
|
||||
if len(resp.Messages[2].Structured.Blocks) != 2 {
|
||||
t.Fatalf("blocks = %#v, want 2 blocks", resp.Messages[2].Structured.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsStructuredPayloadList(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-structured-list"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "show list"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "message",
|
||||
Arguments: `{"content":"组合消息","structured":[{"type":"progress","title":"同步中","status":"running"},{"type":"alert","level":"info","content":"等待确认"}]}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
} {
|
||||
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-structured-list", 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 {
|
||||
Structured []struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"structured"`
|
||||
} `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 len(resp.Messages[2].Structured) != 2 {
|
||||
t.Fatalf("structured = %#v, want 2 items", resp.Messages[2].Structured)
|
||||
}
|
||||
if resp.Messages[2].Structured[0].Type != "progress" || resp.Messages[2].Structured[1].Type != "alert" {
|
||||
t.Fatalf("structured types = %#v, want progress/alert", resp.Messages[2].Structured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
|
@ -545,7 +831,7 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) {
|
||||
func TestHandleGetSession_HidesAssistantContentWhenToolSummaryExists(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
|
|
@ -593,8 +879,91 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T)
|
|||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Structured map[string]any `json:"structured"`
|
||||
} `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[0].Role != "user" || resp.Messages[0].Content != "check file" {
|
||||
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
|
||||
}
|
||||
if resp.Messages[1].Structured["type"] != "progress" || resp.Messages[1].Structured["title"] != "read_file" {
|
||||
t.Fatalf("tool summary message = %#v, want structured read_file progress", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[1].Structured["status"] != "completed" {
|
||||
t.Fatalf("tool summary status = %#v, want completed", resp.Messages[1].Structured)
|
||||
}
|
||||
if got := resp.Messages[1].Structured["content"]; got != "README.md:1-10" {
|
||||
t.Fatalf("tool summary content = %#v, want README.md:1-10", got)
|
||||
}
|
||||
for _, msg := range resp.Messages {
|
||||
if msg.Content == "model final reply" {
|
||||
t.Fatalf("unexpected assistant tool-call content in visible transcript: %#v", resp.Messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ToolSummaryUsesArgumentPreview(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-tool-summary-args"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "inspect workspace"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "list_dir",
|
||||
Arguments: `{"path":"/tmp/workspace"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "call_2",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "fetch_webpage",
|
||||
Arguments: `{"urls":["https://example.com"],"query":"release notes"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
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-tool-summary-args", 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 {
|
||||
Structured map[string]any `json:"structured"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
|
|
@ -603,14 +972,11 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T)
|
|||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
|
||||
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
|
||||
if got := resp.Messages[1].Structured["content"]; got != "/tmp/workspace" {
|
||||
t.Fatalf("list_dir summary content = %#v, want /tmp/workspace", got)
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" {
|
||||
t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2])
|
||||
if got := resp.Messages[2].Structured["content"]; got != "release notes | https://example.com" {
|
||||
t.Fatalf("fetch_webpage summary content = %#v, want release notes | https://example.com", got)
|
||||
}
|
||||
for _, msg := range resp.Messages {
|
||||
if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") {
|
||||
|
|
@ -639,7 +1005,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
|
|||
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||
}
|
||||
|
||||
argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
|
||||
argsJSON := `{"content":"abcdefghijklmnopqrstuvwxyz0123456789"}`
|
||||
sessionKey := picoSessionPrefix + "detail-tool-summary-max-args"
|
||||
err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"})
|
||||
if err != nil {
|
||||
|
|
@ -651,7 +1017,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
|
|||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "read_file",
|
||||
Name: "message",
|
||||
Arguments: argsJSON,
|
||||
},
|
||||
}},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ export interface SessionDetail {
|
|||
messages: {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
kind?: "thought"
|
||||
media?: string[]
|
||||
structured?: Record<string, unknown> | Record<string, unknown>[]
|
||||
}[]
|
||||
summary: string
|
||||
created: string
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,11 @@
|
|||
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
|
||||
import { IconArrowUp, IconChecklist, IconMessageCircleQuestion, IconPhotoPlus, IconSparkles, IconX } from "@tabler/icons-react"
|
||||
import type { KeyboardEvent } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import TextareaAutosize from "react-textarea-autosize"
|
||||
|
||||
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ChatAttachment, ContextUsage } from "@/store/chat"
|
||||
import type { ChatAttachment, ChatInteractionMode } from "@/store/chat"
|
||||
|
||||
export type ChatInputDisabledReason =
|
||||
| "gatewayUnknown"
|
||||
|
|
@ -28,27 +22,27 @@ export type ChatInputDisabledReason =
|
|||
interface ChatComposerProps {
|
||||
input: string
|
||||
attachments: ChatAttachment[]
|
||||
mode: ChatInteractionMode
|
||||
onInputChange: (value: string) => void
|
||||
onModeChange: (mode: ChatInteractionMode) => void
|
||||
onAddImages: () => void
|
||||
onRemoveAttachment: (index: number) => void
|
||||
onSend: () => void
|
||||
onContextDetail?: () => void
|
||||
inputDisabledReason: ChatInputDisabledReason | null
|
||||
canSend: boolean
|
||||
contextUsage?: ContextUsage
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
input,
|
||||
attachments,
|
||||
mode,
|
||||
onInputChange,
|
||||
onModeChange,
|
||||
onAddImages,
|
||||
onRemoveAttachment,
|
||||
onSend,
|
||||
onContextDetail,
|
||||
inputDisabledReason,
|
||||
canSend,
|
||||
contextUsage,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation()
|
||||
const canInput = inputDisabledReason === null
|
||||
|
|
@ -66,15 +60,52 @@ export function ChatComposer({
|
|||
}
|
||||
}
|
||||
|
||||
const modeOptions: Array<{
|
||||
value: ChatInteractionMode
|
||||
label: string
|
||||
icon: typeof IconSparkles
|
||||
}> = [
|
||||
{ value: "agent", label: t("chat.mode.agent"), icon: IconSparkles },
|
||||
{ value: "ask", label: t("chat.mode.ask"), icon: IconMessageCircleQuestion },
|
||||
{ value: "plan", label: t("chat.mode.plan"), icon: IconChecklist },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
|
||||
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm">
|
||||
<div className="shrink-0 px-3 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] md:px-5 md:pb-5">
|
||||
<div className="bg-background mx-auto flex max-w-[880px] flex-col gap-3 rounded-xl border border-border/70 px-3 py-3 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1">
|
||||
{modeOptions.map((option) => {
|
||||
const Icon = option.icon
|
||||
const active = mode === option.value
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-2 rounded-md px-3 text-xs",
|
||||
active && "bg-foreground text-background hover:bg-foreground/90 hover:text-background",
|
||||
)}
|
||||
onClick={() => onModeChange(option.value)}
|
||||
disabled={!canInput}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span>{option.label}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<span className="text-muted-foreground ml-auto hidden text-xs md:inline">
|
||||
{t(`chat.modeHint.${mode}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2 px-2">
|
||||
<div className="flex flex-wrap gap-2 px-1">
|
||||
{attachments.map((attachment, index) => (
|
||||
<div
|
||||
key={`${attachment.url}-${index}`}
|
||||
className="bg-background relative h-20 w-20 overflow-hidden rounded-xl border"
|
||||
className="bg-muted/30 relative h-20 w-20 overflow-hidden rounded-lg border border-border/70"
|
||||
>
|
||||
<img
|
||||
src={attachment.url}
|
||||
|
|
@ -84,7 +115,7 @@ export function ChatComposer({
|
|||
<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"
|
||||
className="bg-background/90 text-foreground absolute top-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/70 shadow-sm transition hover:bg-accent"
|
||||
aria-label={t("chat.removeImage")}
|
||||
title={t("chat.removeImage")}
|
||||
>
|
||||
|
|
@ -103,20 +134,25 @@ export function ChatComposer({
|
|||
disabled={!canInput}
|
||||
title={disabledMessage || undefined}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] 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/70 max-h-[220px] min-h-[72px] resize-none border-0 bg-transparent px-1 py-1 text-[14px] leading-6 shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
||||
!canInput && "cursor-not-allowed",
|
||||
)}
|
||||
minRows={1}
|
||||
maxRows={8}
|
||||
/>
|
||||
{!canInput && disabledMessage && (
|
||||
<div className="text-muted-foreground px-1 py-1 text-xs">
|
||||
{disabledMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center justify-between border-t border-border/60 pt-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full"
|
||||
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-md"
|
||||
onClick={onAddImages}
|
||||
disabled={!canInput}
|
||||
aria-label={t("chat.attachImage")}
|
||||
|
|
@ -124,37 +160,23 @@ export function ChatComposer({
|
|||
>
|
||||
<IconPhotoPlus className="size-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground hidden text-xs md:inline">
|
||||
Enter to send, Shift+Enter for newline
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{contextUsage && (
|
||||
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
|
||||
)}
|
||||
{canInput ? (
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={!canSend ? 0 : undefined}>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
aria-label={t("chat.sendMessage")}
|
||||
>
|
||||
<IconArrowUp className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
|
||||
arrowClassName="bg-muted fill-muted"
|
||||
>
|
||||
{t("chat.sendHint")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{canInput ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 gap-2 rounded-md bg-foreground px-3 text-background transition-colors hover:bg-foreground/85"
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
>
|
||||
<IconArrowUp className="size-4" />
|
||||
<span>{t("chat.send")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { IconPlus } from "@tabler/icons-react"
|
||||
import { type ChangeEvent, useEffect, useRef, useState } from "react"
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
@ -19,8 +19,15 @@ import { useChatModels } from "@/hooks/use-chat-models"
|
|||
import { useGateway } from "@/hooks/use-gateway"
|
||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||
import type { ConnectionState } from "@/store/chat"
|
||||
import type { ChatAttachment } from "@/store/chat"
|
||||
import type {
|
||||
ChatAttachment,
|
||||
ChatInteractionMode,
|
||||
ChatMessage,
|
||||
ChatStructuredContent,
|
||||
ChatStructuredProgress,
|
||||
ChatStructuredValue,
|
||||
ConnectionState,
|
||||
} from "@/store/chat"
|
||||
import type { GatewayState } from "@/store/gateway"
|
||||
|
||||
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
||||
|
|
@ -33,6 +40,92 @@ const ALLOWED_IMAGE_TYPES = new Set([
|
|||
"image/bmp",
|
||||
])
|
||||
|
||||
function flattenStructuredParts(
|
||||
structured: ChatStructuredValue | undefined,
|
||||
): ChatStructuredContent[] {
|
||||
if (!structured) {
|
||||
return []
|
||||
}
|
||||
return Array.isArray(structured) ? structured : [structured]
|
||||
}
|
||||
|
||||
function isToolProgressPart(
|
||||
part: ChatStructuredContent,
|
||||
): part is ChatStructuredProgress {
|
||||
return part.type === "progress" && part.kind === "agent/tool-exec"
|
||||
}
|
||||
|
||||
function isProgressOnlyAssistantMessage(message: ChatMessage): boolean {
|
||||
if (message.role !== "assistant" || message.kind === "thought") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.content.trim() || (message.attachments?.length ?? 0) > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = flattenStructuredParts(message.structured)
|
||||
return parts.length > 0 && parts.every(isToolProgressPart)
|
||||
}
|
||||
|
||||
function isMergeableAssistantResultMessage(message: ChatMessage): boolean {
|
||||
return message.role === "assistant" && message.kind !== "thought"
|
||||
}
|
||||
|
||||
function mergeStructuredValues(
|
||||
left: ChatStructuredValue | undefined,
|
||||
right: ChatStructuredValue | undefined,
|
||||
): ChatStructuredValue | undefined {
|
||||
const merged = [...flattenStructuredParts(left), ...flattenStructuredParts(right)]
|
||||
if (merged.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function aggregateRenderableMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
const aggregated: ChatMessage[] = []
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index]
|
||||
|
||||
if (!isProgressOnlyAssistantMessage(message)) {
|
||||
aggregated.push(message)
|
||||
continue
|
||||
}
|
||||
|
||||
let mergedMessage = message
|
||||
let cursor = index + 1
|
||||
|
||||
while (cursor < messages.length && isProgressOnlyAssistantMessage(messages[cursor])) {
|
||||
const progressMessage = messages[cursor]
|
||||
mergedMessage = {
|
||||
...mergedMessage,
|
||||
id: `${mergedMessage.id}__${progressMessage.id}`,
|
||||
structured: mergeStructuredValues(mergedMessage.structured, progressMessage.structured),
|
||||
timestamp: progressMessage.timestamp,
|
||||
}
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
const nextMessage = messages[cursor]
|
||||
if (nextMessage && isMergeableAssistantResultMessage(nextMessage)) {
|
||||
mergedMessage = {
|
||||
...nextMessage,
|
||||
id: `${mergedMessage.id}__${nextMessage.id}`,
|
||||
structured: mergeStructuredValues(mergedMessage.structured, nextMessage.structured),
|
||||
}
|
||||
index = cursor
|
||||
} else {
|
||||
index = cursor - 1
|
||||
}
|
||||
|
||||
aggregated.push(mergedMessage)
|
||||
}
|
||||
|
||||
return aggregated
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
|
@ -109,13 +202,13 @@ export function ChatPage() {
|
|||
const [hasScrolled, setHasScrolled] = useState(false)
|
||||
const [input, setInput] = useState("")
|
||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||
const [mode, setMode] = useState<ChatInteractionMode>("agent")
|
||||
|
||||
const {
|
||||
messages,
|
||||
connectionState,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
contextUsage,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
newChat,
|
||||
|
|
@ -154,7 +247,7 @@ export function ChatPage() {
|
|||
})
|
||||
|
||||
const syncScrollState = (element: HTMLDivElement) => {
|
||||
const { clientHeight, scrollHeight, scrollTop } = element
|
||||
const { scrollTop, scrollHeight, clientHeight } = element
|
||||
setHasScrolled(scrollTop > 0)
|
||||
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
|
||||
}
|
||||
|
|
@ -178,6 +271,7 @@ export function ChatPage() {
|
|||
sendMessage({
|
||||
content: input,
|
||||
attachments,
|
||||
mode,
|
||||
})
|
||||
) {
|
||||
setInput("")
|
||||
|
|
@ -185,6 +279,11 @@ export function ChatPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleSelectOption = (value: string) => {
|
||||
if (!canInput) return
|
||||
sendMessage({ content: value, mode })
|
||||
}
|
||||
|
||||
const handleAddImages = () => {
|
||||
if (!canInput) return
|
||||
fileInputRef.current?.click()
|
||||
|
|
@ -245,13 +344,17 @@ export function ChatPage() {
|
|||
|
||||
const canSubmit =
|
||||
canInput && (Boolean(input.trim()) || attachments.length > 0)
|
||||
const renderMessages = useMemo(
|
||||
() => aggregateRenderableMessages(messages),
|
||||
[messages],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-background/95 flex h-full flex-col">
|
||||
<div className="bg-muted/35 flex h-full min-h-0 flex-col">
|
||||
<PageHeader
|
||||
title={t("navigation.chat")}
|
||||
className={`transition-shadow ${
|
||||
hasScrolled ? "shadow-xs" : "shadow-none"
|
||||
hasScrolled ? "border-border/60 bg-background/90 shadow-xs backdrop-blur" : "border-transparent bg-transparent shadow-none"
|
||||
}`}
|
||||
titleExtra={
|
||||
hasAvailableModels && (
|
||||
|
|
@ -292,13 +395,15 @@ export function ChatPage() {
|
|||
/>
|
||||
</PageHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
|
||||
{messages.length === 0 && !isTyping && (
|
||||
<div className="min-h-0 flex-1 px-2 pb-2 md:px-4 md:pb-4">
|
||||
<div className="bg-background/92 ring-border/60 mx-auto flex h-full w-full max-w-[1380px] min-h-0 flex-col overflow-hidden rounded-2xl border shadow-sm ring-1">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[880px] flex-col gap-5 pb-12">
|
||||
{renderMessages.length === 0 && !isTyping && (
|
||||
<ChatEmptyState
|
||||
hasAvailableModels={hasAvailableModels}
|
||||
defaultModelName={defaultModelName}
|
||||
|
|
@ -306,24 +411,47 @@ export function ChatPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="flex w-full">
|
||||
{renderMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="flex w-full"
|
||||
>
|
||||
{msg.role === "assistant" ? (
|
||||
<AssistantMessage
|
||||
content={msg.content}
|
||||
isThought={msg.kind === "thought"}
|
||||
timestamp={msg.timestamp}
|
||||
structured={msg.structured}
|
||||
onSelectOption={handleSelectOption}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage
|
||||
content={msg.content}
|
||||
attachments={msg.attachments}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && <TypingIndicator />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border/70 bg-background/96 border-t">
|
||||
<ChatComposer
|
||||
input={input}
|
||||
attachments={attachments}
|
||||
mode={mode}
|
||||
onInputChange={setInput}
|
||||
onModeChange={setMode}
|
||||
onAddImages={handleAddImages}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
onSend={handleSend}
|
||||
inputDisabledReason={inputDisabledReason}
|
||||
canSend={canSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -334,23 +462,6 @@ export function ChatPage() {
|
|||
className="hidden"
|
||||
onChange={handleImageSelection}
|
||||
/>
|
||||
|
||||
<ChatComposer
|
||||
input={input}
|
||||
attachments={attachments}
|
||||
onInputChange={setInput}
|
||||
onAddImages={handleAddImages}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
onSend={handleSend}
|
||||
onContextDetail={() => {
|
||||
if (sendMessage({ content: "/context", attachments: [] })) {
|
||||
setInput("")
|
||||
}
|
||||
}}
|
||||
inputDisabledReason={inputDisabledReason}
|
||||
canSend={canSubmit}
|
||||
contextUsage={contextUsage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,24 +20,35 @@ export function TypingIndicator() {
|
|||
}, [thinkingSteps.length])
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="bg-card border-border/50 inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70" />
|
||||
<div className="flex w-full max-w-[820px] gap-3">
|
||||
<div className="bg-muted text-muted-foreground mt-5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/70 text-[11px] font-semibold uppercase">
|
||||
AI
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-[11px] uppercase tracking-[0.14em]">
|
||||
<span>PicoClaw</span>
|
||||
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] tracking-normal normal-case">
|
||||
Thinking
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-card inline-flex w-fit min-w-56 max-w-md flex-col gap-3 rounded-xl border border-border/70 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 animate-bounce rounded-full bg-foreground/55 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-foreground/55 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-foreground/55" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted relative h-1 w-36 overflow-hidden rounded-full">
|
||||
<div className="absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-violet-500/60 via-violet-400/80 to-violet-500/60 bg-[length:200%_100%]" />
|
||||
<div className="bg-muted relative h-1 w-40 overflow-hidden rounded-full">
|
||||
<div className="absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-foreground/35 via-foreground/65 to-foreground/35 bg-[length:200%_100%]" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
key={stepIndex}
|
||||
className="text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs leading-5"
|
||||
>
|
||||
{thinkingSteps[stepIndex]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
key={stepIndex}
|
||||
className="text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs"
|
||||
>
|
||||
{thinkingSteps[stepIndex]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,54 +1,57 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
import type { ChatAttachment } from "@/store/chat"
|
||||
|
||||
interface UserMessageProps {
|
||||
content: string
|
||||
attachments?: ChatAttachment[]
|
||||
timestamp?: string | number
|
||||
}
|
||||
|
||||
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||
export function UserMessage({
|
||||
content,
|
||||
attachments = [],
|
||||
timestamp,
|
||||
}: UserMessageProps) {
|
||||
const hasText = content.trim().length > 0
|
||||
const isCommand = content.trim().startsWith("/")
|
||||
const imageAttachments = attachments.filter(
|
||||
(attachment) => attachment.type === "image",
|
||||
)
|
||||
const formattedTimestamp = timestamp ? formatMessageTime(timestamp) : ""
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-end gap-1.5">
|
||||
<div className="ml-auto flex w-full max-w-[820px] justify-end gap-3">
|
||||
<div className="flex min-w-0 max-w-[80%] flex-col items-end gap-2">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-[11px] uppercase tracking-[0.14em]">
|
||||
<span>You</span>
|
||||
{formattedTimestamp ? <span className="opacity-60">{formattedTimestamp}</span> : null}
|
||||
</div>
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="flex max-w-[70%] flex-wrap justify-end gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
<img
|
||||
<div
|
||||
key={`${attachment.url}-${index}`}
|
||||
src={attachment.url}
|
||||
alt={attachment.filename || "Uploaded image"}
|
||||
className="max-h-72 max-w-full object-cover"
|
||||
/>
|
||||
className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm"
|
||||
>
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={attachment.filename || "Uploaded image"}
|
||||
className="max-h-72 max-w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasText && (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[70%] wrap-break-word whitespace-pre-wrap",
|
||||
isCommand
|
||||
? "rounded-xl border border-zinc-200 bg-transparent px-4 py-3 font-mono text-[14px] text-zinc-800 dark:border-zinc-800/60 dark:bg-[#121212] dark:text-zinc-200 dark:shadow-sm"
|
||||
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
|
||||
)}
|
||||
>
|
||||
{isCommand ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
|
||||
❯
|
||||
</span>
|
||||
<span className="mt-[1px]">{content}</span>
|
||||
</div>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
<div className="w-full rounded-xl border border-blue-200/70 bg-blue-50/70 px-4 py-3 text-[14px] leading-6 wrap-break-word whitespace-pre-wrap text-slate-900 shadow-sm dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-slate-100">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted text-muted-foreground mt-5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/70 text-[11px] font-semibold uppercase">
|
||||
You
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { invalidateSocket, isCurrentSocket } from "@/features/chat/websocket"
|
||||
import i18n from "@/i18n"
|
||||
import {
|
||||
type ChatInteractionMode,
|
||||
type ChatAttachment,
|
||||
getChatState,
|
||||
updateChatStore,
|
||||
|
|
@ -33,6 +34,8 @@ let connectionGeneration = 0
|
|||
let reconnectTimer: number | null = null
|
||||
let reconnectAttempts = 0
|
||||
let shouldMaintainConnection = false
|
||||
let refreshFromHistoryPromise: Promise<void> | null = null
|
||||
let refreshFromHistoryTimer: number | null = null
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer !== null) {
|
||||
|
|
@ -41,6 +44,21 @@ function clearReconnectTimer() {
|
|||
}
|
||||
}
|
||||
|
||||
function clearRefreshFromHistoryTimer() {
|
||||
if (refreshFromHistoryTimer !== null) {
|
||||
window.clearTimeout(refreshFromHistoryTimer)
|
||||
refreshFromHistoryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefreshFromHistory(delayMs: number) {
|
||||
clearRefreshFromHistoryTimer()
|
||||
refreshFromHistoryTimer = window.setTimeout(() => {
|
||||
refreshFromHistoryTimer = null
|
||||
void refreshActiveSessionFromHistory()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
function shouldReconnectFor(generation: number, sessionId: string): boolean {
|
||||
return (
|
||||
shouldMaintainConnection &&
|
||||
|
|
@ -89,6 +107,7 @@ function disconnectChatInternal({
|
|||
}) {
|
||||
connectionGeneration += 1
|
||||
clearReconnectTimer()
|
||||
clearRefreshFromHistoryTimer()
|
||||
|
||||
if (clearDesiredConnection) {
|
||||
shouldMaintainConnection = false
|
||||
|
|
@ -182,7 +201,11 @@ export async function connectChat() {
|
|||
|
||||
try {
|
||||
const message = JSON.parse(event.data) as PicoMessage
|
||||
handlePicoMessage(message, sessionId)
|
||||
const result = handlePicoMessage(message, sessionId)
|
||||
if (result.shouldRefreshHistory) {
|
||||
const delayMs = message.type === "typing.stop" ? 250 : 1200
|
||||
scheduleRefreshFromHistory(delayMs)
|
||||
}
|
||||
} catch {
|
||||
console.warn("Non-JSON message from pico:", event.data)
|
||||
}
|
||||
|
|
@ -318,11 +341,13 @@ export async function hydrateActiveSession() {
|
|||
interface SendChatMessageInput {
|
||||
content: string
|
||||
attachments?: ChatAttachment[]
|
||||
mode?: ChatInteractionMode
|
||||
}
|
||||
|
||||
export function sendChatMessage({
|
||||
content,
|
||||
attachments = [],
|
||||
mode = "agent",
|
||||
}: SendChatMessageInput) {
|
||||
if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
|
||||
console.warn("WebSocket not connected")
|
||||
|
|
@ -364,6 +389,7 @@ export function sendChatMessage({
|
|||
payload: {
|
||||
content: normalizedContent,
|
||||
media: normalizedAttachments.map((attachment) => attachment.url),
|
||||
mode,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
@ -378,6 +404,34 @@ export function sendChatMessage({
|
|||
}
|
||||
}
|
||||
|
||||
async function refreshActiveSessionFromHistory() {
|
||||
if (refreshFromHistoryPromise) {
|
||||
return refreshFromHistoryPromise
|
||||
}
|
||||
|
||||
const sessionId = activeSessionIdRef
|
||||
refreshFromHistoryPromise = loadSessionMessages(sessionId)
|
||||
.then((historyMessages) => {
|
||||
const currentState = getChatState()
|
||||
if (currentState.activeSessionId !== sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateChatStore({
|
||||
messages: historyMessages,
|
||||
hasHydratedActiveSession: true,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to refresh active session history:", error)
|
||||
})
|
||||
.finally(() => {
|
||||
refreshFromHistoryPromise = null
|
||||
})
|
||||
|
||||
return refreshFromHistoryPromise
|
||||
}
|
||||
|
||||
export async function switchChatSession(sessionId: string) {
|
||||
if (sessionId === activeSessionIdRef) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getSessionHistory } from "@/api/sessions"
|
||||
import { parseStructuredContent } from "@/features/chat/structured"
|
||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||
import type { ChatAttachment, ChatMessage } from "@/store/chat"
|
||||
|
||||
|
|
@ -24,8 +25,14 @@ export async function loadSessionMessages(
|
|||
id: `hist-${index}-${Date.now()}`,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
kind: message.role === "assistant" ? "normal" : undefined,
|
||||
kind:
|
||||
message.role === "assistant"
|
||||
? message.kind === "thought"
|
||||
? "thought"
|
||||
: "normal"
|
||||
: undefined,
|
||||
attachments: toChatAttachments(message.media),
|
||||
structured: parseStructuredContent(message.structured),
|
||||
timestamp: fallbackTime,
|
||||
}))
|
||||
}
|
||||
|
|
@ -48,10 +55,13 @@ function messageSignature(message: ChatMessage): string {
|
|||
const attachmentSignature = (message.attachments ?? [])
|
||||
.map((attachment) => `${attachment.type}\u0001${attachment.url}`)
|
||||
.join("\u0002")
|
||||
const structuredSignature = message.structured
|
||||
? JSON.stringify(message.structured)
|
||||
: ""
|
||||
|
||||
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
||||
message.timestamp,
|
||||
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}`
|
||||
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}\u0000${structuredSignature}`
|
||||
}
|
||||
|
||||
function comparableTimestamp(timestamp: number | string): number {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
inferStructuredContentFromText,
|
||||
parseStructuredContent,
|
||||
} from "@/features/chat/structured"
|
||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||
import {
|
||||
getChatState,
|
||||
type AssistantMessageKind,
|
||||
type ContextUsage,
|
||||
updateChatStore,
|
||||
|
|
@ -15,6 +20,10 @@ export interface PicoMessage {
|
|||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PicoMessageHandleResult {
|
||||
shouldRefreshHistory: boolean
|
||||
}
|
||||
|
||||
function parseAssistantMessageKind(
|
||||
payload: Record<string, unknown>,
|
||||
): AssistantMessageKind {
|
||||
|
|
@ -46,9 +55,9 @@ function parseContextUsage(
|
|||
export function handlePicoMessage(
|
||||
message: PicoMessage,
|
||||
expectedSessionId: string,
|
||||
) {
|
||||
): PicoMessageHandleResult {
|
||||
if (message.session_id && message.session_id !== expectedSessionId) {
|
||||
return
|
||||
return { shouldRefreshHistory: false }
|
||||
}
|
||||
|
||||
const payload = message.payload || {}
|
||||
|
|
@ -59,6 +68,10 @@ export function handlePicoMessage(
|
|||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||
const kind = parseAssistantMessageKind(payload)
|
||||
const contextUsage = parseContextUsage(payload)
|
||||
const wasTyping = getChatState().isTyping
|
||||
const structured =
|
||||
parseStructuredContent(payload.structured) ??
|
||||
(kind === "normal" ? inferStructuredContentFromText(content) : undefined)
|
||||
const timestamp =
|
||||
message.timestamp !== undefined &&
|
||||
Number.isFinite(Number(message.timestamp))
|
||||
|
|
@ -73,20 +86,34 @@ export function handlePicoMessage(
|
|||
role: "assistant",
|
||||
content,
|
||||
kind,
|
||||
structured,
|
||||
timestamp,
|
||||
},
|
||||
],
|
||||
isTyping: false,
|
||||
...(contextUsage ? { contextUsage } : {}),
|
||||
}))
|
||||
break
|
||||
|
||||
return {
|
||||
shouldRefreshHistory:
|
||||
kind === "normal" &&
|
||||
!structured &&
|
||||
content.trim().length > 0 &&
|
||||
wasTyping,
|
||||
}
|
||||
}
|
||||
|
||||
case "message.update": {
|
||||
const content = (payload.content as string) || ""
|
||||
const hasContent = typeof payload.content === "string"
|
||||
const content = hasContent ? ((payload.content as string) || "") : ""
|
||||
const messageId = payload.message_id as string
|
||||
const hasKind = hasAssistantKindPayload(payload)
|
||||
const kind = parseAssistantMessageKind(payload)
|
||||
const structured =
|
||||
parseStructuredContent(payload.structured) ??
|
||||
(hasContent && kind === "normal"
|
||||
? inferStructuredContentFromText(content)
|
||||
: undefined)
|
||||
if (!messageId) {
|
||||
break
|
||||
}
|
||||
|
|
@ -96,22 +123,23 @@ export function handlePicoMessage(
|
|||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
content,
|
||||
...(hasContent ? { content } : {}),
|
||||
...(hasKind ? { kind } : {}),
|
||||
...(structured ? { structured } : {}),
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
}))
|
||||
break
|
||||
return { shouldRefreshHistory: false }
|
||||
}
|
||||
|
||||
case "typing.start":
|
||||
updateChatStore({ isTyping: true })
|
||||
break
|
||||
return { shouldRefreshHistory: false }
|
||||
|
||||
case "typing.stop":
|
||||
updateChatStore({ isTyping: false })
|
||||
break
|
||||
return { shouldRefreshHistory: true }
|
||||
|
||||
case "error": {
|
||||
const requestId =
|
||||
|
|
@ -129,13 +157,16 @@ export function handlePicoMessage(
|
|||
: prev.messages,
|
||||
isTyping: false,
|
||||
}))
|
||||
break
|
||||
return { shouldRefreshHistory: false }
|
||||
}
|
||||
|
||||
case "pong":
|
||||
break
|
||||
return { shouldRefreshHistory: false }
|
||||
|
||||
default:
|
||||
console.log("Unknown pico message type:", message.type)
|
||||
return { shouldRefreshHistory: false }
|
||||
}
|
||||
|
||||
return { shouldRefreshHistory: false }
|
||||
}
|
||||
|
|
|
|||
592
web/frontend/src/features/chat/structured.ts
Normal file
592
web/frontend/src/features/chat/structured.ts
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
import type {
|
||||
ChatActionItem,
|
||||
ChatCardBlock,
|
||||
ChatFieldItem,
|
||||
ChatFormField,
|
||||
ChatListItem,
|
||||
ChatProgressStep,
|
||||
ChatTodoItem,
|
||||
ChatStructuredContent,
|
||||
ChatStructuredValue,
|
||||
} from "@/store/chat"
|
||||
|
||||
const planHeadingRe = /^\s*#{1,6}\s+(.+?)\s*$/
|
||||
const planCheckboxRe = /^\s*(?:[-*+]|\d+[.)])\s*\[\s*(x|X)?\s*\]\s*(.+?)\s*$/
|
||||
const planBulletRe = /^\s*[-*+]\s+(.+?)\s*$/
|
||||
const planNumberRe = /^\s*\d+[.)]\s+(.+?)\s*$/
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
function firstString(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = asString(record[key])
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inferStructuredType(record: Record<string, unknown>): string | undefined {
|
||||
const explicitType = asString(record.type)
|
||||
if (explicitType) {
|
||||
return explicitType
|
||||
}
|
||||
if (Array.isArray(record.options)) {
|
||||
return "options"
|
||||
}
|
||||
if (Array.isArray(record.items)) {
|
||||
return "todo"
|
||||
}
|
||||
if (Array.isArray(record.steps) || record.progress !== undefined) {
|
||||
return "progress"
|
||||
}
|
||||
if (firstString(record, ["level", "severity", "statusLevel"])) {
|
||||
return "alert"
|
||||
}
|
||||
if (Array.isArray(record.fields)) {
|
||||
return "form"
|
||||
}
|
||||
if (Array.isArray(record.blocks) || Array.isArray(record.actions)) {
|
||||
return "card"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inferOptionsMode(record: Record<string, unknown>): "single" | "multiple" {
|
||||
const explicitMode = firstString(record, [
|
||||
"mode",
|
||||
"selectionMode",
|
||||
"selection_mode",
|
||||
])
|
||||
if (explicitMode === "multiple") {
|
||||
return "multiple"
|
||||
}
|
||||
if (
|
||||
asBoolean(record.multiple) === true ||
|
||||
asBoolean(record.multi) === true ||
|
||||
asBoolean(record.multiSelect) === true ||
|
||||
asBoolean(record.multi_select) === true
|
||||
) {
|
||||
return "multiple"
|
||||
}
|
||||
return "single"
|
||||
}
|
||||
|
||||
function parseActionItem(value: unknown): ChatActionItem | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = asString(record.label)
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
|
||||
const variant = asString(record.variant)
|
||||
return {
|
||||
label,
|
||||
type: asString(record.type),
|
||||
action: asString(record.action),
|
||||
value: asString(record.value),
|
||||
url: asString(record.url),
|
||||
variant:
|
||||
variant === "default" ||
|
||||
variant === "outline" ||
|
||||
variant === "secondary" ||
|
||||
variant === "ghost"
|
||||
? variant
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseFieldItem(value: unknown): ChatFieldItem | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const label = asString(record.label)
|
||||
const fieldValue = asString(record.value)
|
||||
if (!label || !fieldValue) {
|
||||
return null
|
||||
}
|
||||
return { label, value: fieldValue }
|
||||
}
|
||||
|
||||
function parseListItem(value: unknown): ChatListItem | null {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return { text: value }
|
||||
}
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const text = asString(record.text)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
return { text, label: asString(record.label) }
|
||||
}
|
||||
|
||||
function parseProgressStep(value: unknown): ChatProgressStep | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const label = asString(record.label)
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label,
|
||||
status: asString(record.status),
|
||||
detail: asString(record.detail),
|
||||
}
|
||||
}
|
||||
|
||||
function parseTodoItem(value: unknown): ChatTodoItem | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const title = firstString(record, ["title", "label", "text", "step"])
|
||||
if (!title) {
|
||||
return null
|
||||
}
|
||||
|
||||
const status = asString(record.status)
|
||||
return {
|
||||
id: asString(record.id),
|
||||
title,
|
||||
detail: firstString(record, ["detail", "description", "message"]),
|
||||
status:
|
||||
status === "not-started" ||
|
||||
status === "in-progress" ||
|
||||
status === "completed"
|
||||
? status
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseFormField(value: unknown): ChatFormField | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = asString(record.name)
|
||||
const label = asString(record.label)
|
||||
if (!name || !label) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
fieldType: asString(record.fieldType) ?? asString(record.type),
|
||||
value: asString(record.value),
|
||||
placeholder: asString(record.placeholder),
|
||||
required: record.required === true,
|
||||
}
|
||||
}
|
||||
|
||||
function parseCardBlock(value: unknown): ChatCardBlock | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = asString(record.type)
|
||||
if (!type) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
case "markdown": {
|
||||
const text = asString(record.text)
|
||||
return text ? { type, text } : null
|
||||
}
|
||||
case "fields": {
|
||||
const items = Array.isArray(record.fields)
|
||||
? record.fields
|
||||
.map((item) => parseFieldItem(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: []
|
||||
return items.length > 0 ? { type, fields: items } : null
|
||||
}
|
||||
case "badge": {
|
||||
const label = asString(record.label)
|
||||
return label ? { type, label, status: asString(record.status) } : null
|
||||
}
|
||||
case "actions": {
|
||||
const actions = Array.isArray(record.actions)
|
||||
? record.actions
|
||||
.map((item) => parseActionItem(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: []
|
||||
return actions.length > 0 ? { type, actions } : null
|
||||
}
|
||||
case "list": {
|
||||
const items = Array.isArray(record.items)
|
||||
? record.items
|
||||
.map((item) => parseListItem(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: []
|
||||
return items.length > 0 ? { type, items } : null
|
||||
}
|
||||
case "table": {
|
||||
const rows = Array.isArray(record.rows)
|
||||
? record.rows
|
||||
.map((row) =>
|
||||
Array.isArray(row)
|
||||
? row
|
||||
.map((cell) => (typeof cell === "string" ? cell : String(cell)))
|
||||
.filter((cell) => cell.length > 0)
|
||||
: [],
|
||||
)
|
||||
.filter((row) => row.length > 0)
|
||||
: []
|
||||
if (rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
const headers = Array.isArray(record.headers)
|
||||
? record.headers.map((item) => String(item))
|
||||
: undefined
|
||||
return { type, headers, rows }
|
||||
}
|
||||
case "image": {
|
||||
const url = asString(record.url)
|
||||
return url ? { type, url, alt: asString(record.alt) } : null
|
||||
}
|
||||
case "divider":
|
||||
return { type }
|
||||
case "json":
|
||||
return { type, data: record.data }
|
||||
default:
|
||||
return { type: "unknown", blockType: type, raw: record }
|
||||
}
|
||||
}
|
||||
|
||||
function parseSingleStructuredContent(
|
||||
structured: unknown,
|
||||
): ChatStructuredContent | undefined {
|
||||
const record = asRecord(structured)
|
||||
if (!record) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const type = inferStructuredType(record)
|
||||
if (!type) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "options": {
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options
|
||||
.map((item) => {
|
||||
const entry = asRecord(item)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
const label = asString(entry.label)
|
||||
const value = asString(entry.value)
|
||||
if (!label || !value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
description: asString(entry.description),
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: []
|
||||
return options.length > 0
|
||||
? {
|
||||
type,
|
||||
options,
|
||||
mode: inferOptionsMode(record),
|
||||
allowCustom:
|
||||
asBoolean(record.allowCustom) ??
|
||||
asBoolean(record.allow_custom) ??
|
||||
asBoolean(record.customInputEnabled) ??
|
||||
asBoolean(record.custom_input_enabled) ??
|
||||
false,
|
||||
customPlaceholder:
|
||||
firstString(record, [
|
||||
"customPlaceholder",
|
||||
"custom_placeholder",
|
||||
"inputPlaceholder",
|
||||
"input_placeholder",
|
||||
]),
|
||||
submitLabel:
|
||||
firstString(record, [
|
||||
"submitLabel",
|
||||
"submit_label",
|
||||
"buttonLabel",
|
||||
"button_label",
|
||||
]),
|
||||
raw: record,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
case "card": {
|
||||
const blocks = Array.isArray(record.blocks)
|
||||
? record.blocks
|
||||
.map((item) => parseCardBlock(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: undefined
|
||||
const actions = Array.isArray(record.actions)
|
||||
? record.actions
|
||||
.map((item) => parseActionItem(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: undefined
|
||||
return {
|
||||
type,
|
||||
kind: asString(record.kind),
|
||||
version: asString(record.version),
|
||||
title: asString(record.title),
|
||||
blocks,
|
||||
actions,
|
||||
raw: record,
|
||||
}
|
||||
}
|
||||
case "form": {
|
||||
const fields = Array.isArray(record.fields)
|
||||
? record.fields
|
||||
.map((item) => parseFormField(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: undefined
|
||||
const actions = Array.isArray(record.actions)
|
||||
? record.actions
|
||||
.map((item) => parseActionItem(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: undefined
|
||||
return {
|
||||
type,
|
||||
kind: asString(record.kind),
|
||||
version: asString(record.version),
|
||||
title: asString(record.title),
|
||||
content: firstString(record, ["content", "description", "message"]),
|
||||
fields,
|
||||
actions,
|
||||
raw: record,
|
||||
}
|
||||
}
|
||||
case "progress": {
|
||||
const steps = Array.isArray(record.steps)
|
||||
? record.steps
|
||||
.map((item) => parseProgressStep(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: undefined
|
||||
return {
|
||||
type,
|
||||
kind: asString(record.kind),
|
||||
version: asString(record.version),
|
||||
title: asString(record.title),
|
||||
content: firstString(record, ["content", "description", "message", "detail"]),
|
||||
status: asString(record.status),
|
||||
steps,
|
||||
raw: record,
|
||||
}
|
||||
}
|
||||
case "todo": {
|
||||
const items = Array.isArray(record.items)
|
||||
? record.items
|
||||
.map((item) => parseTodoItem(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: undefined
|
||||
return {
|
||||
type,
|
||||
kind: asString(record.kind),
|
||||
version: asString(record.version),
|
||||
title: asString(record.title),
|
||||
content: firstString(record, ["content", "description", "message"]),
|
||||
items,
|
||||
raw: record,
|
||||
}
|
||||
}
|
||||
case "alert": {
|
||||
const actions = Array.isArray(record.actions)
|
||||
? record.actions
|
||||
.map((item) => parseActionItem(item))
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
: undefined
|
||||
return {
|
||||
type,
|
||||
kind: asString(record.kind),
|
||||
version: asString(record.version),
|
||||
title: asString(record.title),
|
||||
level: firstString(record, ["level", "severity", "statusLevel"]),
|
||||
content: firstString(record, ["content", "description", "message", "detail"]),
|
||||
actions,
|
||||
raw: record,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
type: "unknown",
|
||||
kind: asString(record.kind) ?? type,
|
||||
raw: record,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStructuredContent(
|
||||
structured: unknown,
|
||||
): ChatStructuredValue | undefined {
|
||||
if (Array.isArray(structured)) {
|
||||
const parts = structured
|
||||
.map((item) => parseSingleStructuredContent(item))
|
||||
.filter((item): item is ChatStructuredContent => item !== undefined)
|
||||
return parts.length > 0 ? parts : undefined
|
||||
}
|
||||
|
||||
return parseSingleStructuredContent(structured)
|
||||
}
|
||||
|
||||
function cleanPlanText(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^[:\-\s]+/, "")
|
||||
.replace(/[*_`#]/g, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function looksLikePlanLine(line: string): boolean {
|
||||
return (
|
||||
planCheckboxRe.test(line) ||
|
||||
planBulletRe.test(line) ||
|
||||
planNumberRe.test(line)
|
||||
)
|
||||
}
|
||||
|
||||
function isPlanHeading(text: string): boolean {
|
||||
return /规划|计划|plan|阶段|任务|step|phase|milestone|测试|验证|优化/i.test(text)
|
||||
}
|
||||
|
||||
function inferPlanStatus(text: string): ChatTodoItem["status"] {
|
||||
if (/completed|done|已完成/i.test(text)) {
|
||||
return "completed"
|
||||
}
|
||||
if (/in-progress|running|进行中/i.test(text)) {
|
||||
return "in-progress"
|
||||
}
|
||||
return "not-started"
|
||||
}
|
||||
|
||||
export function inferStructuredContentFromText(
|
||||
content: string,
|
||||
): ChatStructuredValue | undefined {
|
||||
const normalized = content
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/[\u00A0\u3000]/g, " ")
|
||||
.trim()
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lines = normalized.split("\n")
|
||||
let title: string | undefined
|
||||
let summary: string | undefined
|
||||
let hasChecklist = false
|
||||
const headingItems: ChatTodoItem[] = []
|
||||
const listItems: ChatTodoItem[] = []
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line === "---") {
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = line.match(planHeadingRe)
|
||||
if (headingMatch) {
|
||||
const headingText = cleanPlanText(headingMatch[1] ?? "")
|
||||
if (!headingText) {
|
||||
continue
|
||||
}
|
||||
if (!title) {
|
||||
title = headingText
|
||||
continue
|
||||
}
|
||||
if (isPlanHeading(headingText)) {
|
||||
headingItems.push({
|
||||
title: headingText,
|
||||
status: "not-started",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!summary && !looksLikePlanLine(line)) {
|
||||
const candidate = cleanPlanText(line)
|
||||
if (candidate) {
|
||||
summary = candidate
|
||||
}
|
||||
}
|
||||
|
||||
const checkboxMatch = line.match(planCheckboxRe)
|
||||
if (checkboxMatch) {
|
||||
hasChecklist = true
|
||||
listItems.push({
|
||||
title: cleanPlanText(checkboxMatch[2] ?? ""),
|
||||
status: checkboxMatch[1]?.toLowerCase() === "x" ? "completed" : "not-started",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const bulletMatch = line.match(planBulletRe)
|
||||
if (bulletMatch) {
|
||||
const itemText = cleanPlanText(bulletMatch[1] ?? "")
|
||||
if (itemText) {
|
||||
listItems.push({ title: itemText, status: inferPlanStatus(itemText) })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const numberMatch = line.match(planNumberRe)
|
||||
if (numberMatch) {
|
||||
const itemText = cleanPlanText(numberMatch[1] ?? "")
|
||||
if (itemText) {
|
||||
listItems.push({ title: itemText, status: inferPlanStatus(itemText) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = headingItems.length > 0 ? headingItems : listItems
|
||||
if (items.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const signalText = `${title ?? ""}\n${normalized}`
|
||||
if (!hasChecklist && !/规划|计划|plan|阶段|任务|todo|step|phase/i.test(signalText)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!items.some((item) => item.status === "completed" || item.status === "in-progress")) {
|
||||
items[0] = { ...items[0], status: "in-progress" }
|
||||
}
|
||||
|
||||
return {
|
||||
type: "todo",
|
||||
title: title ?? "Plan",
|
||||
content: summary,
|
||||
items: items.slice(0, 8),
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"chat": {
|
||||
"welcome": "How can I help you today?",
|
||||
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
|
||||
"placeholder": "Start a new message...",
|
||||
"placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line",
|
||||
"disabledPlaceholder": {
|
||||
"gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.",
|
||||
"gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.",
|
||||
|
|
@ -60,7 +60,16 @@
|
|||
"step4": "Almost there..."
|
||||
},
|
||||
"reasoningLabel": "Reasoning",
|
||||
"toolLabel": "Tool",
|
||||
"mode": {
|
||||
"agent": "Agent",
|
||||
"ask": "Ask",
|
||||
"plan": "Plan"
|
||||
},
|
||||
"modeHint": {
|
||||
"agent": "Execution-first. Tools and edits are allowed to complete the task.",
|
||||
"ask": "Answer-first. Prefer explanation and guidance over taking actions.",
|
||||
"plan": "Planning-first. Produce a concrete plan before any execution."
|
||||
},
|
||||
"history": "History",
|
||||
"noHistory": "No chat history yet",
|
||||
"historyLoadFailed": "Failed to load chat history",
|
||||
|
|
@ -73,10 +82,6 @@
|
|||
"notConnected": "Gateway is not running. Start it to chat.",
|
||||
"noModel": "No default model configured. Go to Models page to set one."
|
||||
},
|
||||
"sendMessage": "Send message",
|
||||
"sendHint": "Press Enter to send\nShift + Enter for a new line",
|
||||
"contextTitle": "Context",
|
||||
"contextDetail": "View Details",
|
||||
"attachImage": "Add images",
|
||||
"removeImage": "Remove image",
|
||||
"uploadedImage": "Uploaded image",
|
||||
|
|
@ -359,15 +364,11 @@
|
|||
"placeholderText": "Placeholder Text",
|
||||
"groupTriggerMentionOnly": "Group Mention Only",
|
||||
"groupTriggerPrefixes": "Group Trigger Prefixes",
|
||||
"groupTriggerPrefixesPlaceholder": "e.g. /, !, ?",
|
||||
"randomReactionEmoji": "Random Reaction Emoji",
|
||||
"randomReactionEmojiPlaceholder": "e.g. THUMBSUP, HEART, SMILE",
|
||||
"isLark": "Lark (International)",
|
||||
"allowFrom": "Allow From",
|
||||
"allowFromPlaceholder": "e.g. 123456, 789012",
|
||||
"allowOrigins": "Allow Origins",
|
||||
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
|
||||
"removeListItem": "Remove {{value}}",
|
||||
"secretPlaceholder": "Enter secret",
|
||||
"secretHintSet": "A value is already set. Leave blank to keep it unchanged."
|
||||
},
|
||||
|
|
@ -395,11 +396,10 @@
|
|||
"typingEnabled": "Display typing status while the assistant is generating a response.",
|
||||
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
|
||||
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.",
|
||||
"groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.",
|
||||
"randomReactionEmoji": "PicoClaw adds emoji reactions to user messages to confirm receipt. Example: \"THUMBSUP\", \"HEART\", \"SMILE\". Leave empty to use the default \"Pin\" emoji.",
|
||||
"groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.",
|
||||
"isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
|
||||
"allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.",
|
||||
"allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.",
|
||||
"allowFrom": "Allowed user or group IDs, separated by commas.",
|
||||
"allowOrigins": "Allowed origin domains, separated by commas.",
|
||||
"wsUrl": "WebSocket service URL.",
|
||||
"reconnectInterval": "Reconnect interval after disconnection (seconds).",
|
||||
"bridgeUrl": "Bridge service URL.",
|
||||
|
|
@ -663,16 +663,10 @@
|
|||
"autostart_load_error": "Failed to load launch-at-login status.",
|
||||
"server_port": "Service Port",
|
||||
"server_port_hint": "HTTP port used by PicoClaw Web.",
|
||||
"launcher_section_hint": "Changes in this section take effect after the launcher restarts.",
|
||||
"dashboard_password": "Login Password",
|
||||
"dashboard_password_hint": "Set a new login password.",
|
||||
"dashboard_password_placeholder": "At least 8 characters",
|
||||
"dashboard_password_confirm": "Confirm New Password",
|
||||
"dashboard_password_confirm_hint": "Enter the new login password again.",
|
||||
"dashboard_password_confirm_placeholder": "Repeat password",
|
||||
"dashboard_password_required": "Enter and confirm the new login password.",
|
||||
"dashboard_password_mismatch": "The login passwords do not match.",
|
||||
"dashboard_password_min_length": "Login password must be at least 8 characters.",
|
||||
"launcher_token": "Login Token",
|
||||
"launcher_token_section_hint": "Changes in this section take effect after the launcher restarts.",
|
||||
"launcher_token_hint": "Used to sign in on the launcher login page.",
|
||||
"launcher_token_placeholder": "Enter login token",
|
||||
"lan_access": "Enable LAN Access",
|
||||
"lan_access_hint": "Allow access from other devices on your local network.",
|
||||
"allowed_cidrs": "Allowed Network CIDRs",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"chat": {
|
||||
"welcome": "今天我能为您做些什么?",
|
||||
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
|
||||
"placeholder": "输入新消息...",
|
||||
"placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行",
|
||||
"disabledPlaceholder": {
|
||||
"gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。",
|
||||
"gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。",
|
||||
|
|
@ -60,7 +60,16 @@
|
|||
"step4": "马上就好..."
|
||||
},
|
||||
"reasoningLabel": "思考",
|
||||
"toolLabel": "工具",
|
||||
"mode": {
|
||||
"agent": "Agent",
|
||||
"ask": "Ask",
|
||||
"plan": "Plan"
|
||||
},
|
||||
"modeHint": {
|
||||
"agent": "执行优先,可调用工具并直接完成任务。",
|
||||
"ask": "问答优先,偏解释与建议,减少主动执行。",
|
||||
"plan": "规划优先,先给方案,不直接落地执行。"
|
||||
},
|
||||
"history": "历史记录",
|
||||
"noHistory": "暂无对话历史",
|
||||
"historyLoadFailed": "加载历史记录失败",
|
||||
|
|
@ -73,10 +82,6 @@
|
|||
"notConnected": "服务未运行,请先启动以进行对话。",
|
||||
"noModel": "未设置默认模型,请前往模型页面进行配置。"
|
||||
},
|
||||
"sendMessage": "发送消息",
|
||||
"sendHint": "按 Enter 发送\nShift + Enter 换行",
|
||||
"contextTitle": "上下文",
|
||||
"contextDetail": "查看详情",
|
||||
"attachImage": "添加图片",
|
||||
"removeImage": "移除图片",
|
||||
"uploadedImage": "已上传图片",
|
||||
|
|
@ -359,15 +364,11 @@
|
|||
"placeholderText": "占位文案",
|
||||
"groupTriggerMentionOnly": "群聊仅提及时响应",
|
||||
"groupTriggerPrefixes": "群聊触发前缀",
|
||||
"groupTriggerPrefixesPlaceholder": "例如 /, !, ?",
|
||||
"randomReactionEmoji": "随机表情回应",
|
||||
"randomReactionEmojiPlaceholder": "例如 THUMBSUP, HEART, SMILE",
|
||||
"isLark": "Lark(国际版)",
|
||||
"allowFrom": "允许来源",
|
||||
"allowFromPlaceholder": "例如 123456, 789012",
|
||||
"allowOrigins": "允许来源域名",
|
||||
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
||||
"removeListItem": "删除 {{value}}",
|
||||
"secretPlaceholder": "输入密钥",
|
||||
"secretHintSet": "配置已保存,留空表示不修改"
|
||||
},
|
||||
|
|
@ -395,11 +396,10 @@
|
|||
"typingEnabled": "在生成回复时显示“正在输入”状态",
|
||||
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
|
||||
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
|
||||
"groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。",
|
||||
"randomReactionEmoji": "PicoClaw 会对用户消息添加表情回复以确认已收到。例如:\"THUMBSUP\", \"HEART\", \"SMILE\"。留空则使用默认的 \"Pin\" 表情。",
|
||||
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
|
||||
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)",
|
||||
"allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。",
|
||||
"allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。",
|
||||
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔",
|
||||
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔",
|
||||
"wsUrl": "WebSocket 服务地址",
|
||||
"reconnectInterval": "断线后的重连间隔(秒)",
|
||||
"bridgeUrl": "桥接服务地址",
|
||||
|
|
@ -663,16 +663,10 @@
|
|||
"autostart_load_error": "加载开机自启状态失败",
|
||||
"server_port": "服务端口",
|
||||
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
|
||||
"launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效",
|
||||
"dashboard_password": "登录密码",
|
||||
"dashboard_password_hint": "设置新的登录密码",
|
||||
"dashboard_password_placeholder": "至少 8 个字符",
|
||||
"dashboard_password_confirm": "确认新密码",
|
||||
"dashboard_password_confirm_hint": "再次输入新的登录密码",
|
||||
"dashboard_password_confirm_placeholder": "再次输入密码",
|
||||
"dashboard_password_required": "请输入并确认新的登录密码",
|
||||
"dashboard_password_mismatch": "两次输入的登录密码不一致",
|
||||
"dashboard_password_min_length": "登录密码至少需要 8 个字符",
|
||||
"launcher_token": "登录令牌",
|
||||
"launcher_token_section_hint": "此分组中的改动需要在重启 launcher 后生效",
|
||||
"launcher_token_hint": "用于在 launcher 登录页进行登录",
|
||||
"launcher_token_placeholder": "输入登录令牌",
|
||||
"lan_access": "启用局域网访问",
|
||||
"lan_access_hint": "允许局域网中的其他设备访问当前服务",
|
||||
"allowed_cidrs": "允许访问网段",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,200 @@ export interface ChatAttachment {
|
|||
filename?: string
|
||||
}
|
||||
|
||||
export interface ChatOptionItem {
|
||||
label: string
|
||||
value: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ChatActionItem {
|
||||
type?: string
|
||||
action?: string
|
||||
label: string
|
||||
value?: string
|
||||
url?: string
|
||||
variant?: "default" | "outline" | "secondary" | "ghost"
|
||||
}
|
||||
|
||||
export interface ChatFieldItem {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ChatListItem {
|
||||
label?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ChatTableBlock {
|
||||
type: "table"
|
||||
headers?: string[]
|
||||
rows: string[][]
|
||||
}
|
||||
|
||||
export interface ChatTextBlock {
|
||||
type: "text" | "markdown"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ChatFieldsBlock {
|
||||
type: "fields"
|
||||
fields: ChatFieldItem[]
|
||||
}
|
||||
|
||||
export interface ChatBadgeBlock {
|
||||
type: "badge"
|
||||
label: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface ChatActionsBlock {
|
||||
type: "actions"
|
||||
actions: ChatActionItem[]
|
||||
}
|
||||
|
||||
export interface ChatListBlock {
|
||||
type: "list"
|
||||
items: ChatListItem[]
|
||||
}
|
||||
|
||||
export interface ChatImageBlock {
|
||||
type: "image"
|
||||
url: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export interface ChatDividerBlock {
|
||||
type: "divider"
|
||||
}
|
||||
|
||||
export interface ChatJsonBlock {
|
||||
type: "json"
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export interface ChatUnknownBlock {
|
||||
type: "unknown"
|
||||
blockType: string
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ChatCardBlock =
|
||||
| ChatTextBlock
|
||||
| ChatFieldsBlock
|
||||
| ChatBadgeBlock
|
||||
| ChatActionsBlock
|
||||
| ChatListBlock
|
||||
| ChatTableBlock
|
||||
| ChatImageBlock
|
||||
| ChatDividerBlock
|
||||
| ChatJsonBlock
|
||||
| ChatUnknownBlock
|
||||
|
||||
export interface ChatStructuredOptions {
|
||||
type: "options"
|
||||
options: ChatOptionItem[]
|
||||
mode?: "single" | "multiple"
|
||||
allowCustom?: boolean
|
||||
customPlaceholder?: string
|
||||
submitLabel?: string
|
||||
raw?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ChatStructuredCard {
|
||||
type: "card"
|
||||
kind?: string
|
||||
version?: string
|
||||
title?: string
|
||||
blocks?: ChatCardBlock[]
|
||||
actions?: ChatActionItem[]
|
||||
raw?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ChatFormField {
|
||||
name: string
|
||||
label: string
|
||||
fieldType?: string
|
||||
value?: string
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export interface ChatStructuredForm {
|
||||
type: "form"
|
||||
kind?: string
|
||||
version?: string
|
||||
title?: string
|
||||
content?: string
|
||||
fields?: ChatFormField[]
|
||||
actions?: ChatActionItem[]
|
||||
raw?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ChatProgressStep {
|
||||
label: string
|
||||
status?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface ChatStructuredProgress {
|
||||
type: "progress"
|
||||
kind?: string
|
||||
version?: string
|
||||
title?: string
|
||||
content?: string
|
||||
status?: string
|
||||
steps?: ChatProgressStep[]
|
||||
raw?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ChatStructuredAlert {
|
||||
type: "alert"
|
||||
kind?: string
|
||||
version?: string
|
||||
title?: string
|
||||
level?: string
|
||||
content?: string
|
||||
actions?: ChatActionItem[]
|
||||
raw?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ChatTodoStatus = "not-started" | "in-progress" | "completed"
|
||||
|
||||
export interface ChatTodoItem {
|
||||
id?: string
|
||||
title: string
|
||||
status?: ChatTodoStatus
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface ChatStructuredTodo {
|
||||
type: "todo"
|
||||
kind?: string
|
||||
version?: string
|
||||
title?: string
|
||||
content?: string
|
||||
items?: ChatTodoItem[]
|
||||
raw?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ChatStructuredUnknown {
|
||||
type: "unknown"
|
||||
kind?: string
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ChatStructuredContent =
|
||||
| ChatStructuredOptions
|
||||
| ChatStructuredCard
|
||||
| ChatStructuredForm
|
||||
| ChatStructuredProgress
|
||||
| ChatStructuredAlert
|
||||
| ChatStructuredTodo
|
||||
| ChatStructuredUnknown
|
||||
|
||||
export type ChatStructuredValue = ChatStructuredContent | ChatStructuredContent[]
|
||||
|
||||
export type AssistantMessageKind = "normal" | "thought"
|
||||
|
||||
export interface ChatMessage {
|
||||
|
|
@ -20,6 +214,7 @@ export interface ChatMessage {
|
|||
timestamp: number | string
|
||||
kind?: AssistantMessageKind
|
||||
attachments?: ChatAttachment[]
|
||||
structured?: ChatStructuredValue
|
||||
}
|
||||
|
||||
export interface ContextUsage {
|
||||
|
|
@ -35,6 +230,8 @@ export type ConnectionState =
|
|||
| "connected"
|
||||
| "error"
|
||||
|
||||
export type ChatInteractionMode = "agent" | "ask" | "plan"
|
||||
|
||||
export interface ChatStoreState {
|
||||
messages: ChatMessage[]
|
||||
connectionState: ConnectionState
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue