From c8bac699fef02e509a367b627dde61aed5a4a666 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:23:12 +0800 Subject: [PATCH 01/16] fix(pico): separate thought and normal messages --- pkg/agent/loop.go | 55 ++++++++++++++-- pkg/agent/loop_test.go | 56 ++++++++++++++++ pkg/channels/pico/client.go | 8 ++- pkg/channels/pico/client_test.go | 64 +++++++++++++++++++ pkg/channels/pico/pico.go | 16 ++++- pkg/channels/pico/protocol.go | 10 +++ pkg/providers/antigravity_provider.go | 17 +++-- pkg/providers/antigravity_provider_test.go | 24 +++++++ .../src/components/chat/assistant-message.tsx | 38 +++++++++-- .../src/components/chat/chat-page.tsx | 1 + web/frontend/src/features/chat/history.ts | 3 +- web/frontend/src/features/chat/protocol.ts | 27 +++++++- web/frontend/src/i18n/locales/en.json | 1 + web/frontend/src/i18n/locales/zh.json | 1 + web/frontend/src/store/chat.ts | 3 + 15 files changed, 300 insertions(+), 24 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ac230aa86..03fdfec82 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -105,6 +105,8 @@ const ( toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." handledToolResponseSummary = "Requested output delivered via tool attachment." sessionKeyAgentPrefix = "agent:" + metadataKeyMessageKind = "message_kind" + messageKindThought = "thought" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" @@ -1622,6 +1624,41 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string return "" } +func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) { + if reasoningContent == "" || chatID == "" { + return + } + + if ctx.Err() != nil { + return + } + + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: "pico", + ChatID: chatID, + Content: reasoningContent, + Metadata: map[string]string{ + metadataKeyMessageKind: messageKindThought, + }, + }); err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { + logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{ + "channel": "pico", + "error": err.Error(), + }) + } else { + logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{ + "channel": "pico", + "error": err.Error(), + }) + } + } +} + func (al *AgentLoop) handleReasoning( ctx context.Context, reasoningContent, channelName, channelID string, @@ -2223,12 +2260,16 @@ turnLoop: if reasoningContent == "" { reasoningContent = response.ReasoningContent } - go al.handleReasoning( - turnCtx, - reasoningContent, - ts.channel, - al.targetReasoningChannelID(ts.channel), - ) + if ts.channel == "pico" { + al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) + } else { + go al.handleReasoning( + turnCtx, + reasoningContent, + ts.channel, + al.targetReasoningChannelID(ts.channel), + ) + } al.emitEvent( EventKindLLMResponse, ts.eventMeta("runTurn", "turn.llm.response"), @@ -2277,7 +2318,7 @@ turnLoop: if len(response.ToolCalls) == 0 || gracefulTerminal { responseContent := response.Content - if responseContent == "" && response.ReasoningContent != "" { + if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" { responseContent = response.ReasoningContent } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index a67c8d040..56ea000c8 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -2660,6 +2660,62 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T } } +func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &reasoningContentProvider{ + response: "final answer", + reasoningContent: "thinking trace", + } + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user1", + ChatID: "pico:test-session", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "final answer" { + t.Fatalf("processMessage() response = %q, want %q", response, "final answer") + } + + var thoughtMsg *bus.OutboundMessage + deadline := time.After(3 * time.Second) + + for thoughtMsg == nil { + select { + case outbound := <-msgBus.OutboundChan(): + msg := outbound + if msg.Content == "thinking trace" { + thoughtMsg = &msg + } + case <-deadline: + t.Fatal("expected thought outbound message for pico") + } + } + + if thoughtMsg.Channel != "pico" || thoughtMsg.ChatID != "pico:test-session" { + t.Fatalf("thought message route = %s/%s, want pico/pico:test-session", thoughtMsg.Channel, thoughtMsg.ChatID) + } + if thoughtMsg.Metadata[metadataKeyMessageKind] != messageKindThought { + t.Fatalf("thought metadata kind = %q, want %q", thoughtMsg.Metadata[metadataKeyMessageKind], messageKindThought) + } +} + func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { tmpDir := t.TempDir() heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt") diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go index b4bfd09e5..bf3e38cf4 100644 --- a/pkg/channels/pico/client.go +++ b/pkg/channels/pico/client.go @@ -242,7 +242,11 @@ func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) { } func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { - content, _ := msg.Payload["content"].(string) + if isThoughtPayload(msg.Payload) { + return + } + + content, _ := msg.Payload[PayloadKeyContent].(string) if strings.TrimSpace(content) == "" { return } @@ -285,7 +289,7 @@ func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) ( } outMsg := newMessage(TypeMessageSend, map[string]any{ - "content": msg.Content, + PayloadKeyContent: msg.Content, }) outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") return nil, pc.writeJSON(outMsg) diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index b40606647..732589432 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -316,3 +316,67 @@ func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) { t.Fatal("timed out waiting for inbound media message") } } + +func TestIsThoughtPayload(t *testing.T) { + tests := []struct { + name string + payload map[string]any + want bool + }{ + { + name: "explicit thought bool", + payload: map[string]any{PayloadKeyThought: true}, + want: true, + }, + { + name: "thought false", + payload: map[string]any{PayloadKeyThought: false}, + want: false, + }, + { + name: "thought string ignored", + payload: map[string]any{PayloadKeyThought: "true"}, + want: false, + }, + { + name: "default normal", + payload: map[string]any{PayloadKeyContent: "hello"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isThoughtPayload(tt.payload); got != tt.want { + t.Fatalf("isThoughtPayload() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewPicoClientChannel(config.PicoClientConfig{ + URL: "ws://localhost:8080/ws", + }, mb) + if err != nil { + t.Fatalf("NewPicoClientChannel() error = %v", err) + } + + ch.ctx = context.Background() + pc := &picoConn{sessionID: "sess-thought"} + + ch.handleServerMessage(pc, PicoMessage{ + Type: TypeMessageCreate, + Payload: map[string]any{ + PayloadKeyContent: "internal reasoning", + PayloadKeyThought: true, + }, + }) + + select { + case msg := <-mb.InboundChan(): + t.Fatalf("expected no inbound publish for thought payload, got %+v", msg) + case <-time.After(150 * time.Millisecond): + } +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index e22da1ba1..6525c2d4a 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -39,6 +39,13 @@ var allowedInlineImageMIMETypes = map[string]struct{}{ "image/bmp": {}, } +func outboundMessageIsThought(metadata map[string]string) bool { + if len(metadata) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(metadata["message_kind"]), MessageKindThought) +} + // writeJSON sends a JSON message to the connection with write locking. func (pc *picoConn) writeJSON(v any) error { if pc.closed.Load() { @@ -247,9 +254,11 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri if !c.IsRunning() { return nil, channels.ErrNotRunning } + isThought := outboundMessageIsThought(msg.Metadata) outMsg := newMessage(TypeMessageCreate, map[string]any{ - "content": msg.Content, + PayloadKeyContent: msg.Content, + PayloadKeyThought: isThought, }) return nil, c.broadcastToSession(msg.ChatID, outMsg) @@ -288,8 +297,9 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin msgID := uuid.New().String() outMsg := newMessage(TypeMessageCreate, map[string]any{ - "content": text, - "message_id": msgID, + PayloadKeyContent: text, + PayloadKeyThought: false, + "message_id": msgID, }) if err := c.broadcastToSession(chatID, outMsg); err != nil { diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 3f8ba8643..ecdc2d140 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -19,6 +19,11 @@ const ( TypePong = "pong" PicoTokenPrefix = "pico-" + + PayloadKeyContent = "content" + PayloadKeyThought = "thought" + + MessageKindThought = "thought" ) // PicoMessage is the wire format for all Pico Protocol messages. @@ -39,6 +44,11 @@ func newMessage(msgType string, payload map[string]any) PicoMessage { } } +func isThoughtPayload(payload map[string]any) bool { + thought, _ := payload[PayloadKeyThought].(bool) + return thought +} + func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage { payload := map[string]any{ "code": code, diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go index 8a1890212..b5ab847d5 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/antigravity_provider.go @@ -389,6 +389,7 @@ type antigravityJSONResponse struct { Content struct { Parts []struct { Text string `json:"text,omitempty"` + Thought bool `json:"thought,omitempty"` ThoughtSignature string `json:"thoughtSignature,omitempty"` ThoughtSignatureSnake string `json:"thought_signature,omitempty"` FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"` @@ -406,6 +407,7 @@ type antigravityJSONResponse struct { func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) { var contentParts []string + var reasoningParts []string var toolCalls []ToolCall var usage *UsageInfo var finishReason string @@ -433,7 +435,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error for _, candidate := range resp.Candidates { for _, part := range candidate.Content.Parts { if part.Text != "" { - contentParts = append(contentParts, part.Text) + if part.Thought { + reasoningParts = append(reasoningParts, part.Text) + } else { + contentParts = append(contentParts, part.Text) + } } if part.FunctionCall != nil { argumentsJSON, _ := json.Marshal(part.FunctionCall.Args) @@ -475,10 +481,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error } return &LLMResponse{ - Content: strings.Join(contentParts, ""), - ToolCalls: toolCalls, - FinishReason: mappedFinish, - Usage: usage, + Content: strings.Join(contentParts, ""), + ReasoningContent: strings.Join(reasoningParts, ""), + ToolCalls: toolCalls, + FinishReason: mappedFinish, + Usage: usage, }, nil } diff --git a/pkg/providers/antigravity_provider_test.go b/pkg/providers/antigravity_provider_test.go index 238765321..9155e2d56 100644 --- a/pkg/providers/antigravity_provider_test.go +++ b/pkg/providers/antigravity_provider_test.go @@ -54,3 +54,27 @@ func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) { t.Fatalf("expected inferred tool name search_docs, got %q", got) } } + +func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) { + p := &AntigravityProvider{} + body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" + + "data: [DONE]\n" + + resp, err := p.parseSSEResponse(body) + if err != nil { + t.Fatalf("parseSSEResponse() error = %v", err) + } + + if resp.Content != "visible answer" { + t.Fatalf("Content = %q, want %q", resp.Content, "visible answer") + } + if resp.ReasoningContent != "hidden reasoning" { + t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden reasoning") + } + if resp.FinishReason != "stop" { + t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 216 { + t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216) + } +} diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 9966226b2..418516172 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -1,5 +1,6 @@ -import { IconCheck, IconCopy } from "@tabler/icons-react" +import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react" import { useState } from "react" +import { useTranslation } from "react-i18next" import ReactMarkdown from "react-markdown" import rehypeRaw from "rehype-raw" import rehypeSanitize from "rehype-sanitize" @@ -7,16 +8,20 @@ import remarkGfm from "remark-gfm" import { Button } from "@/components/ui/button" import { formatMessageTime } from "@/hooks/use-pico-chat" +import { cn } from "@/lib/utils" interface AssistantMessageProps { content: string + isThought?: boolean timestamp?: string | number } export function AssistantMessage({ content, + isThought = false, timestamp = "", }: AssistantMessageProps) { + const { t } = useTranslation() const [isCopied, setIsCopied] = useState(false) const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" @@ -33,6 +38,12 @@ export function AssistantMessage({
PicoClaw + {isThought && ( + + + {t("chat.reasoningLabel")} + + )} {formattedTimestamp && ( <> @@ -42,8 +53,22 @@ export function AssistantMessage({
-
-
+
+
{isCopied ? ( diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 38a0fc6b1..e8e07a801 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -247,6 +247,7 @@ export function ChatPage() { {msg.role === "assistant" ? ( ) : ( diff --git a/web/frontend/src/features/chat/history.ts b/web/frontend/src/features/chat/history.ts index 850b3319e..92beb06b7 100644 --- a/web/frontend/src/features/chat/history.ts +++ b/web/frontend/src/features/chat/history.ts @@ -24,6 +24,7 @@ export async function loadSessionMessages( id: `hist-${index}-${Date.now()}`, role: message.role, content: message.content, + kind: message.role === "assistant" ? "normal" : undefined, attachments: toChatAttachments(message.media), timestamp: fallbackTime, })) @@ -50,7 +51,7 @@ function messageSignature(message: ChatMessage): string { return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp( message.timestamp, - )}\u0000${attachmentSignature}` + )}\u0000${message.kind ?? ""}\u0000${attachmentSignature}` } function comparableTimestamp(timestamp: number | string): number { diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts index 7429aef01..a7edfc21b 100644 --- a/web/frontend/src/features/chat/protocol.ts +++ b/web/frontend/src/features/chat/protocol.ts @@ -1,7 +1,10 @@ import { toast } from "sonner" import { normalizeUnixTimestamp } from "@/features/chat/state" -import { updateChatStore } from "@/store/chat" +import { + type AssistantMessageKind, + updateChatStore, +} from "@/store/chat" export interface PicoMessage { type: string @@ -11,6 +14,16 @@ export interface PicoMessage { payload?: Record } +function parseAssistantMessageKind( + payload: Record, +): AssistantMessageKind { + return payload.thought === true ? "thought" : "normal" +} + +function hasAssistantKindPayload(payload: Record): boolean { + return typeof payload.thought === "boolean" +} + export function handlePicoMessage( message: PicoMessage, expectedSessionId: string, @@ -25,6 +38,7 @@ export function handlePicoMessage( case "message.create": { const content = (payload.content as string) || "" const messageId = (payload.message_id as string) || `pico-${Date.now()}` + const kind = parseAssistantMessageKind(payload) const timestamp = message.timestamp !== undefined && Number.isFinite(Number(message.timestamp)) @@ -38,6 +52,7 @@ export function handlePicoMessage( id: messageId, role: "assistant", content, + kind, timestamp, }, ], @@ -49,13 +64,21 @@ export function handlePicoMessage( case "message.update": { const content = (payload.content as string) || "" const messageId = payload.message_id as string + const hasKind = hasAssistantKindPayload(payload) + const kind = parseAssistantMessageKind(payload) if (!messageId) { break } updateChatStore((prev) => ({ messages: prev.messages.map((msg) => - msg.id === messageId ? { ...msg, content } : msg, + msg.id === messageId + ? { + ...msg, + content, + ...(hasKind ? { kind } : {}), + } + : msg, ), })) break diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index b53abeb76..2434d4576 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -47,6 +47,7 @@ "step3": "Preparing response...", "step4": "Almost there..." }, + "reasoningLabel": "Reasoning", "history": "History", "noHistory": "No chat history yet", "historyLoadFailed": "Failed to load chat history", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index e2e8eae04..c03d4181d 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -47,6 +47,7 @@ "step3": "准备回复...", "step4": "马上就好..." }, + "reasoningLabel": "思考", "history": "历史记录", "noHistory": "暂无对话历史", "historyLoadFailed": "加载历史记录失败", diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts index 21eb5edff..2c6f70610 100644 --- a/web/frontend/src/store/chat.ts +++ b/web/frontend/src/store/chat.ts @@ -11,11 +11,14 @@ export interface ChatAttachment { filename?: string } +export type AssistantMessageKind = "normal" | "thought" + export interface ChatMessage { id: string role: "user" | "assistant" content: string timestamp: number | string + kind?: AssistantMessageKind attachments?: ChatAttachment[] } From 459e78c076fb7659ce20193a2b8f42b10f57e830 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:50:24 +0800 Subject: [PATCH 02/16] fix(gemini): harden dedicated provider compatibility --- pkg/providers/factory_provider.go | 22 +- pkg/providers/factory_provider_test.go | 56 ++ pkg/providers/gemini_provider.go | 758 ++++++++++++++++++++++++ pkg/providers/gemini_provider_test.go | 440 ++++++++++++++ pkg/providers/openai_compat/provider.go | 5 +- web/backend/api/session.go | 13 + web/backend/api/session_test.go | 53 ++ 7 files changed, 1342 insertions(+), 5 deletions(-) create mode 100644 pkg/providers/gemini_provider.go create mode 100644 pkg/providers/gemini_provider_test.go diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index f13dc646c..ab68b326a 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -114,7 +114,7 @@ func ResolveAPIBase(cfg *config.ModelConfig) string { // CreateProviderFromConfig creates a provider based on the ModelConfig. // It uses the protocol prefix in the Model field to determine which provider to create. -// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini), +// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq), // Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims. // See the switch on protocol in this function for the authoritative list. // Returns the provider, the model ID (without protocol prefix), and any error. @@ -218,7 +218,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return provider, modelID, nil - case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice", + case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", @@ -242,6 +242,24 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.CustomHeaders, ), modelID, nil + case "gemini": + if cfg.APIKey() == "" && cfg.APIBase == "" { + return nil, "", fmt.Errorf("api_key or api_base is required for gemini protocol (model: %s)", cfg.Model) + } + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + return NewGeminiProvider( + cfg.APIKey(), + apiBase, + cfg.Proxy, + userAgent, + cfg.RequestTimeout, + cfg.ExtraBody, + cfg.CustomHeaders, + ), modelID, nil + case "minimax": // Minimax requires reasoning_split: true in the request body if cfg.APIKey() == "" && cfg.APIBase == "" { diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index c362463ae..20cdd8a30 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -434,6 +434,62 @@ func TestCreateProviderFromConfig_Antigravity(t *testing.T) { } } +func TestCreateProviderFromConfig_Gemini(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-gemini", + Model: "gemini/gemini-2.5-flash", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gemini-2.5-flash" { + t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash") + } + if _, ok := provider.(*GeminiProvider); !ok { + t.Fatalf("expected *GeminiProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_GeminiMissingAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-gemini-no-key", + Model: "gemini/gemini-2.5-flash", + } + + _, _, err := CreateProviderFromConfig(cfg) + if err == nil { + t.Fatal("CreateProviderFromConfig() expected error for missing gemini API key") + } +} + +func TestCreateProviderFromConfig_GeminiCustomAPIBaseWithoutKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-gemini-custom-base", + Model: "gemini/gemini-2.5-flash", + APIBase: "https://proxy.example.com/v1beta", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gemini-2.5-flash" { + t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash") + } + if _, ok := provider.(*GeminiProvider); !ok { + t.Fatalf("expected *GeminiProvider, got %T", provider) + } +} + func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-claude-cli", diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go new file mode 100644 index 000000000..b3042fcd7 --- /dev/null +++ b/pkg/providers/gemini_provider.go @@ -0,0 +1,758 @@ +package providers + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/common" +) + +const ( + geminiDefaultAPIBase = "https://generativelanguage.googleapis.com/v1beta" + geminiDefaultModel = "gemini-2.0-flash" +) + +type GeminiProvider struct { + apiKey string + apiBase string + httpClient *http.Client + extraBody map[string]any + customHeaders map[string]string + userAgent string +} + +func NewGeminiProvider( + apiKey string, + apiBase string, + proxy string, + userAgent string, + requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, +) *GeminiProvider { + if strings.TrimSpace(apiBase) == "" { + apiBase = geminiDefaultAPIBase + } + client := common.NewHTTPClient(proxy) + if requestTimeoutSeconds > 0 { + client.Timeout = time.Duration(requestTimeoutSeconds) * time.Second + } + + return &GeminiProvider{ + apiKey: strings.TrimSpace(apiKey), + apiBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"), + httpClient: client, + extraBody: cloneAnyMap(extraBody), + customHeaders: cloneStringMap(customHeaders), + userAgent: strings.TrimSpace(userAgent), + } +} + +func (p *GeminiProvider) GetDefaultModel() string { + return geminiDefaultModel +} + +func (p *GeminiProvider) SupportsThinking() bool { + return true +} + +func (p *GeminiProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + model = normalizeGeminiModel(model) + requestBody := p.buildRequestBody(messages, tools, model, options) + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/models/%s:generateContent", p.apiBase, model) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + p.applyHeaders(req) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, common.HandleErrorResponse(resp, p.apiBase) + } + + var apiResp geminiGenerateContentResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return parseGeminiResponse(&apiResp), nil +} + +func (p *GeminiProvider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + model = normalizeGeminiModel(model) + requestBody := p.buildRequestBody(messages, tools, model, options) + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/models/%s:streamGenerateContent?alt=sse", p.apiBase, model) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + p.applyHeaders(req) + req.Header.Set("Accept", "text/event-stream") + + // Streaming should not use a whole-request timeout; context cancellation is the guard. + streamClient := &http.Client{Transport: p.httpClient.Transport} + resp, err := streamClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, common.HandleErrorResponse(resp, p.apiBase) + } + + return parseGeminiStreamResponse(ctx, resp.Body, onChunk) +} + +func (p *GeminiProvider) applyHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + if p.apiKey != "" { + req.Header.Set("x-goog-api-key", p.apiKey) + } + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } + for k, v := range p.customHeaders { + if strings.TrimSpace(k) == "" { + continue + } + req.Header.Set(k, v) + } +} + +func (p *GeminiProvider) buildRequestBody( + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) map[string]any { + contents := make([]geminiContent, 0, len(messages)) + toolCallNames := make(map[string]string) + var systemInstruction *geminiContent + + for _, msg := range messages { + switch msg.Role { + case "system": + if strings.TrimSpace(msg.Content) != "" { + systemInstruction = &geminiContent{Parts: []geminiPart{{Text: msg.Content}}} + } + + case "user": + if msg.ToolCallID != "" { + toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + contents = append(contents, geminiContent{ + Role: "user", + Parts: []geminiPart{{ + FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media), + }}, + }) + continue + } + + parts := make([]geminiPart, 0, 1+len(msg.Media)) + if strings.TrimSpace(msg.Content) != "" { + parts = append(parts, geminiPart{Text: msg.Content}) + } + parts = append(parts, buildInlineMediaParts(msg.Media)...) + if len(parts) > 0 { + contents = append(contents, geminiContent{Role: "user", Parts: parts}) + } + + case "assistant": + content := geminiContent{Role: "model"} + if strings.TrimSpace(msg.Content) != "" { + content.Parts = append(content.Parts, geminiPart{Text: msg.Content}) + } + for _, tc := range msg.ToolCalls { + toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc) + if toolName == "" { + continue + } + if tc.ID != "" { + toolCallNames[tc.ID] = toolName + } + part := geminiPart{ + FunctionCall: &geminiFunctionCall{ + Name: toolName, + Args: toolArgs, + ID: tc.ID, + }, + } + if thoughtSignature != "" { + part.ThoughtSignature = thoughtSignature + part.ThoughtSignatureSnake = thoughtSignature + } + content.Parts = append(content.Parts, part) + } + if len(content.Parts) > 0 { + contents = append(contents, content) + } + + case "tool": + toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + contents = append(contents, geminiContent{ + Role: "user", + Parts: []geminiPart{{ + FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media), + }}, + }) + } + } + + body := map[string]any{ + "contents": contents, + } + if systemInstruction != nil { + body["systemInstruction"] = systemInstruction + } + + if len(tools) > 0 { + funcDecls := make([]geminiFunctionDeclaration, 0, len(tools)) + for _, t := range tools { + if t.Type != "function" { + continue + } + funcDecls = append(funcDecls, geminiFunctionDeclaration{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: sanitizeSchemaForGemini(t.Function.Parameters), + }) + } + if len(funcDecls) > 0 { + body["tools"] = []geminiTool{{FunctionDeclarations: funcDecls}} + } + } + + generationConfig := make(map[string]any) + if val, ok := options["max_tokens"]; ok { + if maxTokens, ok := val.(int); ok && maxTokens > 0 { + generationConfig["maxOutputTokens"] = maxTokens + } else if maxTokens, ok := val.(float64); ok && maxTokens > 0 { + generationConfig["maxOutputTokens"] = int(maxTokens) + } + } + if temp, ok := options["temperature"].(float64); ok { + generationConfig["temperature"] = temp + } + + if thinkingConfig := buildGeminiThinkingConfig(model, options); len(thinkingConfig) > 0 { + generationConfig["thinkingConfig"] = thinkingConfig + } + + if len(generationConfig) > 0 { + body["generationConfig"] = generationConfig + } + + for k, v := range p.extraBody { + body[k] = v + } + + return body +} + +func normalizeGeminiModel(model string) string { + model = strings.TrimSpace(model) + model = strings.TrimPrefix(model, "models/") + if strings.Contains(model, "/") { + _, modelID := ExtractProtocol(model) + if modelID != "" { + return modelID + } + } + if model == "" { + return geminiDefaultModel + } + return model +} + +func mapGeminiThinkingLevel(level string) string { + switch strings.ToLower(strings.TrimSpace(level)) { + case "minimal", "off": + return "minimal" + case "low": + return "low" + case "medium": + return "medium" + case "high", "xhigh", "adaptive": + return "high" + default: + return "" + } +} + +func buildGeminiThinkingConfig(model string, options map[string]any) map[string]any { + if !geminiModelSupportsThinkingConfig(model) { + return nil + } + + config := map[string]any{"includeThoughts": true} + rawLevel, _ := options["thinking_level"].(string) + rawLevel = strings.ToLower(strings.TrimSpace(rawLevel)) + + if isGemini25Model(model) { + if budget, ok := mapGeminiThinkingBudget(rawLevel, model); ok { + config["thinkingBudget"] = budget + } + return config + } + + if thinkingLevel := mapGeminiThinkingLevel(rawLevel); thinkingLevel != "" { + config["thinkingLevel"] = thinkingLevel + } + return config +} + +func geminiModelSupportsThinkingConfig(model string) bool { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(lowerModel, "gemini-3") || isGemini25Model(lowerModel) +} + +func isGemini25Model(model string) bool { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25") +} + +func mapGeminiThinkingBudget(level string, model string) (int, bool) { + level = strings.ToLower(strings.TrimSpace(level)) + if level == "" { + return 0, false + } + + switch level { + case "adaptive": + return -1, true + case "minimal": + if strings.Contains(strings.ToLower(model), "pro") { + return 128, true + } + return 0, true + case "off": + if strings.Contains(strings.ToLower(model), "pro") { + // Gemini 2.5 Pro cannot disable thinking; use the lowest supported budget. + return 128, true + } + return 0, true + case "low": + return 1024, true + case "medium": + return 4096, true + case "high": + return 8192, true + case "xhigh": + return 16384, true + default: + return 0, false + } +} + +func parseGeminiResponse(resp *geminiGenerateContentResponse) *LLMResponse { + contentParts := make([]string, 0) + reasoningParts := make([]string, 0) + toolCalls := make([]ToolCall, 0) + finishReason := "" + + for _, candidate := range resp.Candidates { + for _, part := range candidate.Content.Parts { + if part.Text != "" { + if part.Thought { + reasoningParts = append(reasoningParts, part.Text) + } else { + contentParts = append(contentParts, part.Text) + } + } + if part.FunctionCall != nil { + toolCalls = append(toolCalls, buildGeminiToolCall(part)) + } + } + if candidate.FinishReason != "" { + finishReason = candidate.FinishReason + } + } + + var usage *UsageInfo + if resp.UsageMetadata.TotalTokenCount > 0 { + usage = &UsageInfo{ + PromptTokens: resp.UsageMetadata.PromptTokenCount, + CompletionTokens: resp.UsageMetadata.CandidatesTokenCount, + TotalTokens: resp.UsageMetadata.TotalTokenCount, + } + } + + return &LLMResponse{ + Content: strings.Join(contentParts, ""), + ReasoningContent: strings.Join(reasoningParts, ""), + ToolCalls: toolCalls, + FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)), + Usage: usage, + } +} + +func parseGeminiStreamResponse( + ctx context.Context, + reader io.Reader, + onChunk func(accumulated string), +) (*LLMResponse, error) { + var contentBuilder strings.Builder + var reasoningBuilder strings.Builder + var finishReason string + var usage *UsageInfo + + toolCallsByID := make(map[string]ToolCall) + toolCallOrder := make([]string, 0) + fallbackIndex := 0 + + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + for scanner.Scan() { + if err := ctx.Err(); err != nil { + return nil, err + } + + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk geminiGenerateContentResponse + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + for _, candidate := range chunk.Candidates { + for _, part := range candidate.Content.Parts { + if part.Text != "" { + if part.Thought { + reasoningBuilder.WriteString(part.Text) + } else { + contentBuilder.WriteString(part.Text) + if onChunk != nil { + onChunk(contentBuilder.String()) + } + } + } + if part.FunctionCall != nil { + tc := buildGeminiToolCall(part) + key := tc.ID + if strings.TrimSpace(key) == "" { + fallbackIndex++ + key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex) + tc.ID = key + } + if _, exists := toolCallsByID[key]; !exists { + toolCallOrder = append(toolCallOrder, key) + } + toolCallsByID[key] = tc + } + } + if candidate.FinishReason != "" { + finishReason = candidate.FinishReason + } + } + + if chunk.UsageMetadata.TotalTokenCount > 0 { + usage = &UsageInfo{ + PromptTokens: chunk.UsageMetadata.PromptTokenCount, + CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount, + TotalTokens: chunk.UsageMetadata.TotalTokenCount, + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("streaming read error: %w", err) + } + + toolCalls := make([]ToolCall, 0, len(toolCallOrder)) + for _, key := range toolCallOrder { + toolCalls = append(toolCalls, toolCallsByID[key]) + } + + return &LLMResponse{ + Content: contentBuilder.String(), + ReasoningContent: reasoningBuilder.String(), + ToolCalls: toolCalls, + FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)), + Usage: usage, + }, nil +} + +func normalizeGeminiFinishReason(reason string, toolCalls int) string { + if toolCalls > 0 { + return "tool_calls" + } + + switch strings.ToUpper(strings.TrimSpace(reason)) { + case "MAX_TOKENS": + return "length" + case "", "STOP": + return "stop" + default: + return strings.ToLower(strings.TrimSpace(reason)) + } +} + +func buildGeminiToolCall(part geminiPart) ToolCall { + if part.FunctionCall == nil { + return ToolCall{} + } + + args := part.FunctionCall.Args + if args == nil { + args = make(map[string]any) + } + argsJSON, _ := json.Marshal(args) + thoughtSignature := extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake) + + toolCall := ToolCall{ + ID: part.FunctionCall.ID, + Name: part.FunctionCall.Name, + Arguments: args, + ThoughtSignature: thoughtSignature, + Function: &FunctionCall{ + Name: part.FunctionCall.Name, + Arguments: string(argsJSON), + ThoughtSignature: thoughtSignature, + }, + } + + if thoughtSignature != "" { + toolCall.ExtraContent = &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: thoughtSignature}, + } + } + if strings.TrimSpace(toolCall.ID) == "" { + toolCall.ID = fmt.Sprintf("call_%s_%d", toolCall.Name, time.Now().UnixNano()) + } + + return toolCall +} + +func buildInlineMediaParts(media []string) []geminiPart { + parts := make([]geminiPart, 0, len(media)) + for _, mediaURL := range media { + mimeType, data, ok := parseBase64DataURL(mediaURL) + if !ok { + continue + } + parts = append(parts, geminiPart{ + InlineData: &geminiInlineData{ + MIMEType: mimeType, + Data: data, + }, + }) + } + return parts +} + +func buildGeminiFunctionResponse( + toolName string, + toolCallID string, + result string, + media []string, +) *geminiFunctionResponse { + response := &geminiFunctionResponse{ + ID: toolCallID, + Name: toolName, + Response: map[string]any{ + "result": result, + }, + } + + if parts := buildFunctionResponseMediaParts(media); len(parts) > 0 { + response.Parts = parts + } + + return response +} + +func buildFunctionResponseMediaParts(media []string) []geminiFunctionResponsePart { + parts := make([]geminiFunctionResponsePart, 0, len(media)) + for i, mediaURL := range media { + mimeType, data, ok := parseBase64DataURL(mediaURL) + if !ok { + continue + } + parts = append(parts, geminiFunctionResponsePart{ + InlineData: &geminiInlineData{ + MIMEType: mimeType, + Data: data, + DisplayName: defaultFunctionResponseDisplayName(mimeType, i+1), + }, + }) + } + return parts +} + +func defaultFunctionResponseDisplayName(mimeType string, index int) string { + suffix := "bin" + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "image/png": + suffix = "png" + case "image/jpeg": + suffix = "jpg" + case "image/webp": + suffix = "webp" + case "application/pdf": + suffix = "pdf" + case "text/plain": + suffix = "txt" + } + return fmt.Sprintf("attachment-%d.%s", index, suffix) +} + +func parseBase64DataURL(mediaURL string) (mimeType string, data string, ok bool) { + if !strings.HasPrefix(mediaURL, "data:") { + return "", "", false + } + + payload := strings.TrimPrefix(mediaURL, "data:") + header, data, found := strings.Cut(payload, ",") + if !found { + return "", "", false + } + mimeType, params, _ := strings.Cut(header, ";") + mimeType = strings.TrimSpace(mimeType) + data = strings.TrimSpace(data) + if mimeType == "" || data == "" { + return "", "", false + } + if !strings.Contains(strings.ToLower(params), "base64") { + return "", "", false + } + return mimeType, data, true +} + +func cloneAnyMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +type geminiGenerateContentResponse struct { + Candidates []struct { + Content struct { + Role string `json:"role"` + Parts []geminiPart `json:"parts"` + } `json:"content"` + FinishReason string `json:"finishReason"` + } `json:"candidates"` + UsageMetadata struct { + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + } `json:"usageMetadata"` +} + +type geminiContent struct { + Role string `json:"role,omitempty"` + Parts []geminiPart `json:"parts"` +} + +type geminiPart struct { + Text string `json:"text,omitempty"` + Thought bool `json:"thought,omitempty"` + ThoughtSignature string `json:"thoughtSignature,omitempty"` + ThoughtSignatureSnake string `json:"thought_signature,omitempty"` + InlineData *geminiInlineData `json:"inlineData,omitempty"` + FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"` + FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"` +} + +type geminiInlineData struct { + MIMEType string `json:"mimeType"` + Data string `json:"data"` + DisplayName string `json:"displayName,omitempty"` +} + +type geminiFunctionCall struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Args map[string]any `json:"args,omitempty"` +} + +type geminiFunctionResponse struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Response map[string]any `json:"response"` + Parts []geminiFunctionResponsePart `json:"parts,omitempty"` +} + +type geminiFunctionResponsePart struct { + InlineData *geminiInlineData `json:"inlineData,omitempty"` +} + +type geminiTool struct { + FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations"` +} + +type geminiFunctionDeclaration struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` +} diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go new file mode 100644 index 000000000..c1bdf7c7f --- /dev/null +++ b/pkg/providers/gemini_provider_test.go @@ -0,0 +1,440 @@ +package providers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) { + var capturedBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if !strings.Contains(r.URL.Path, ":generateContent") { + t.Fatalf("path = %s, expected generateContent endpoint", r.URL.Path) + } + if got := r.Header.Get("x-goog-api-key"); got != "test-key" { + t.Fatalf("x-goog-api-key = %q, want %q", got, "test-key") + } + if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{ + "role": "model", + "parts": []any{ + map[string]any{"text": "hidden", "thought": true}, + map[string]any{"text": "visible"}, + map[string]any{ + "functionCall": map[string]any{ + "id": "call_1", + "name": "search", + "args": map[string]any{"q": "hi"}, + }, + "thoughtSignature": "sig-1", + }, + }, + }, + "finishReason": "STOP", + }, + }, + "usageMetadata": map[string]any{ + "promptTokenCount": 2, + "candidatesTokenCount": 3, + "totalTokenCount": 5, + }, + }) + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "picoclaw-test", 0, nil, nil) + resp, err := provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-3-flash-preview", + map[string]any{"thinking_level": "high"}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if resp.Content != "visible" { + t.Fatalf("Content = %q, want %q", resp.Content, "visible") + } + if resp.ReasoningContent != "hidden" { + t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden") + } + if resp.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 5 { + t.Fatalf("Usage = %#v, expected total tokens = 5", resp.Usage) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].ID != "call_1" { + t.Fatalf("ToolCall ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") + } + if resp.ToolCalls[0].Name != "search" { + t.Fatalf("ToolCall Name = %q, want %q", resp.ToolCalls[0].Name, "search") + } + if resp.ToolCalls[0].ThoughtSignature != "sig-1" { + t.Fatalf("ToolCall ThoughtSignature = %q, want %q", resp.ToolCalls[0].ThoughtSignature, "sig-1") + } + if resp.ToolCalls[0].Function == nil || !strings.Contains(resp.ToolCalls[0].Function.Arguments, `"q":"hi"`) { + t.Fatalf("ToolCall Function arguments = %#v, want q=hi", resp.ToolCalls[0].Function) + } + + generationConfig, ok := capturedBody["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("request missing generationConfig: %#v", capturedBody) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("request missing thinkingConfig: %#v", generationConfig) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts { + t.Fatalf("thinkingConfig.includeThoughts = %#v, want true", thinkingConfig["includeThoughts"]) + } + if got := thinkingConfig["thinkingLevel"]; got != "high" { + t.Fatalf("thinkingConfig.thinkingLevel = %#v, want %q", got, "high") + } +} + +func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, ":streamGenerateContent") { + t.Fatalf("path = %s, expected streamGenerateContent endpoint", r.URL.Path) + } + if got := r.URL.Query().Get("alt"); got != "sse" { + t.Fatalf("alt query = %q, want %q", got, "sse") + } + + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not flushable") + } + + chunks := []map[string]any{ + { + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{"text": "think ", "thought": true}, + map[string]any{"text": "Hello "}, + }, + }, + }}, + }, + { + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{"text": "World"}, + map[string]any{ + "functionCall": map[string]any{ + "id": "call_stream", + "name": "search", + "args": map[string]any{"q": "stream"}, + }, + }, + }, + }, + "finishReason": "STOP", + }}, + "usageMetadata": map[string]any{ + "promptTokenCount": 1, + "candidatesTokenCount": 2, + "totalTokenCount": 3, + }, + }, + } + + for _, chunk := range chunks { + raw, err := json.Marshal(chunk) + if err != nil { + t.Fatalf("marshal chunk: %v", err) + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil { + t.Fatalf("write chunk: %v", err) + } + flusher.Flush() + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil) + updates := make([]string, 0) + resp, err := provider.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + func(accumulated string) { + updates = append(updates, accumulated) + }, + ) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + if resp.Content != "Hello World" { + t.Fatalf("Content = %q, want %q", resp.Content, "Hello World") + } + if resp.ReasoningContent != "think " { + t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "think ") + } + if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call_stream" { + t.Fatalf("ToolCalls = %#v, want single call_stream", resp.ToolCalls) + } + if resp.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 3 { + t.Fatalf("Usage = %#v, expected total tokens = 3", resp.Usage) + } + if len(updates) < 2 || updates[len(updates)-1] != "Hello World" { + t.Fatalf("stream updates = %#v, expected final accumulated text", updates) + } +} + +func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + + body := provider.buildRequestBody( + []Message{{ + Role: "user", + Content: "analyze attachments", + Media: []string{ + "data:application/pdf;base64,UEZERGF0YQ==", + "data:image/png;base64,aW1hZ2VEYXRh", + }, + }}, + nil, + "gemini-3-flash-preview", + map[string]any{ + "thinking_level": "low", + "max_tokens": 128, + "temperature": 0.2, + }, + ) + + contents, ok := body["contents"].([]geminiContent) + if !ok || len(contents) != 1 { + t.Fatalf("contents = %#v, want one gemini content", body["contents"]) + } + parts := contents[0].Parts + mimeSet := map[string]bool{} + for _, part := range parts { + if part.InlineData != nil { + mimeSet[part.InlineData.MIMEType] = true + } + } + if !mimeSet["application/pdf"] { + t.Fatalf("inline media missing application/pdf: %#v", parts) + } + if !mimeSet["image/png"] { + t.Fatalf("inline media missing image/png: %#v", parts) + } + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + if got := generationConfig["maxOutputTokens"]; got != 128 { + t.Fatalf("maxOutputTokens = %#v, want 128", got) + } + if got := generationConfig["temperature"]; got != 0.2 { + t.Fatalf("temperature = %#v, want 0.2", got) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts { + t.Fatalf("includeThoughts = %#v, want true", thinkingConfig["includeThoughts"]) + } + if got := thinkingConfig["thinkingLevel"]; got != "low" { + t.Fatalf("thinkingLevel = %#v, want %q", got, "low") + } +} + +func TestGeminiProvider_BuildRequestBody_UsesThinkingBudgetForGemini25(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + map[string]any{"thinking_level": "medium"}, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if got := thinkingConfig["thinkingBudget"]; got != 4096 { + t.Fatalf("thinkingBudget = %#v, want 4096", got) + } + if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel { + t.Fatalf("thinkingLevel should not be set for Gemini 2.5: %#v", thinkingConfig) + } +} + +func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.0-flash-exp", + map[string]any{"thinking_level": "high"}, + ) + + if _, ok := body["generationConfig"]; ok { + t.Fatalf("generationConfig should be omitted for Gemini 2.0 when only thinking_level is set: %#v", body) + } +} + +func TestGeminiProvider_BuildRequestBody_PreservesToolResponseMedia(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Name: "load_image", + Arguments: map[string]any{"path": "demo.png"}, + }}, + }, + { + Role: "tool", + ToolCallID: "call_1", + Content: "tool result", + Media: []string{ + "data:image/png;base64,aW1hZ2VEYXRh", + "data:application/pdf;base64,UEZERGF0YQ==", + }, + }, + }, + nil, + "gemini-3-flash-preview", + nil, + ) + + contents, ok := body["contents"].([]geminiContent) + if !ok || len(contents) != 2 { + t.Fatalf("contents = %#v, want two content entries", body["contents"]) + } + parts := contents[1].Parts + if len(parts) != 1 || parts[0].FunctionResponse == nil { + t.Fatalf("tool response part = %#v, want functionResponse", parts) + } + response := parts[0].FunctionResponse + if response.Name != "load_image" { + t.Fatalf("functionResponse.Name = %q, want %q", response.Name, "load_image") + } + if response.Response["result"] != "tool result" { + t.Fatalf("functionResponse.Response = %#v, want result=tool result", response.Response) + } + if len(response.Parts) != 2 { + t.Fatalf("functionResponse.Parts len = %d, want 2", len(response.Parts)) + } +} + +func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token") + } + if got := r.Header.Get("x-goog-api-key"); got != "" { + t.Fatalf("x-goog-api-key = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{ + "parts": []any{map[string]any{"text": "ok"}}, + }, + "finishReason": "STOP", + }, + }, + }) + })) + defer server.Close() + + provider := NewGeminiProvider( + "", + server.URL, + "", + "", + 0, + nil, + map[string]string{"Authorization": "Bearer test-token"}, + ) + + resp, err := provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if resp.Content != "ok" { + t.Fatalf("Content = %q, want %q", resp.Content, "ok") + } +} + +func TestGeminiProvider_ChatAllowsMissingAPIKeyForCustomAPIBase(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("x-goog-api-key"); got != "" { + t.Fatalf("x-goog-api-key = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{"parts": []any{map[string]any{"text": "ok"}}}, + "finishReason": "STOP", + }, + }, + }) + })) + defer server.Close() + + provider := NewGeminiProvider("", server.URL, "", "", 0, nil, nil) + resp, err := provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if resp.Content != "ok" { + t.Fatalf("Content = %q, want %q", resp.Content, "ok") + } +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index d25a0fce4..98a70cfd2 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log" + "maps" "net/http" "net/url" "strings" @@ -181,9 +182,7 @@ func (p *Provider) buildRequestBody( // Merge extra body fields configured per-provider/model. // These are injected last so they take precedence over defaults. - for k, v := range p.extraBody { - requestBody[k] = v - } + maps.Copy(requestBody, p.extraBody) return requestBody } diff --git a/web/backend/api/session.go b/web/backend/api/session.go index ae580d9aa..9bb6055e2 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -281,6 +281,12 @@ 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 + } + toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength) if len(toolSummaryMessages) > 0 { transcript = append(transcript, toolSummaryMessages...) @@ -309,6 +315,13 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen return transcript } +func assistantMessageTransientThought(msg providers.Message) bool { + return strings.TrimSpace(msg.Content) == "" && + strings.TrimSpace(msg.ReasoningContent) != "" && + len(msg.ToolCalls) == 0 && + len(msg.Media) == 0 +} + func assistantMessageInternalOnly(msg providers.Message) bool { return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText } diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index 5d7620362..599921bfe 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -218,6 +218,59 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { } } +func TestHandleGetSession_OmitsTransientThoughtMessages(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-transient-thought" + for _, msg := range []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", ReasoningContent: "internal chain of thought"}, + {Role: "assistant", Content: "final visible answer"}, + } { + 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-transient-thought", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + if resp.Messages[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]) + } +} + func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() From 83e93ca572382ce564f516959f38bb010e568d4d Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Sat, 11 Apr 2026 01:15:38 +0800 Subject: [PATCH 03/16] fix(gemini): align thinking-off and system prompt semantics --- pkg/providers/gemini_provider.go | 32 +++++++----- pkg/providers/gemini_provider_test.go | 75 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go index b3042fcd7..5952188fd 100644 --- a/pkg/providers/gemini_provider.go +++ b/pkg/providers/gemini_provider.go @@ -174,13 +174,13 @@ func (p *GeminiProvider) buildRequestBody( ) map[string]any { contents := make([]geminiContent, 0, len(messages)) toolCallNames := make(map[string]string) - var systemInstruction *geminiContent + systemPrompts := make([]string, 0, 1) for _, msg := range messages { switch msg.Role { case "system": if strings.TrimSpace(msg.Content) != "" { - systemInstruction = &geminiContent{Parts: []geminiPart{{Text: msg.Content}}} + systemPrompts = append(systemPrompts, msg.Content) } case "user": @@ -248,8 +248,12 @@ func (p *GeminiProvider) buildRequestBody( body := map[string]any{ "contents": contents, } - if systemInstruction != nil { - body["systemInstruction"] = systemInstruction + if len(systemPrompts) > 0 { + systemParts := make([]geminiPart, 0, len(systemPrompts)) + for _, prompt := range systemPrompts { + systemParts = append(systemParts, geminiPart{Text: prompt}) + } + body["systemInstruction"] = &geminiContent{Parts: systemParts} } if len(tools) > 0 { @@ -331,12 +335,19 @@ func buildGeminiThinkingConfig(model string, options map[string]any) map[string] return nil } - config := map[string]any{"includeThoughts": true} + config := map[string]any{} rawLevel, _ := options["thinking_level"].(string) rawLevel = strings.ToLower(strings.TrimSpace(rawLevel)) + if rawLevel == "" { + // Align with agent-level default: unset means ThinkingOff. + rawLevel = "off" + } + + includeThoughts := rawLevel != "off" && rawLevel != "minimal" + config["includeThoughts"] = includeThoughts if isGemini25Model(model) { - if budget, ok := mapGeminiThinkingBudget(rawLevel, model); ok { + if budget, ok := mapGeminiThinkingBudget(rawLevel); ok { config["thinkingBudget"] = budget } return config @@ -358,7 +369,7 @@ func isGemini25Model(model string) bool { return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25") } -func mapGeminiThinkingBudget(level string, model string) (int, bool) { +func mapGeminiThinkingBudget(level string) (int, bool) { level = strings.ToLower(strings.TrimSpace(level)) if level == "" { return 0, false @@ -368,15 +379,8 @@ func mapGeminiThinkingBudget(level string, model string) (int, bool) { case "adaptive": return -1, true case "minimal": - if strings.Contains(strings.ToLower(model), "pro") { - return 128, true - } return 0, true case "off": - if strings.Contains(strings.ToLower(model), "pro") { - // Gemini 2.5 Pro cannot disable thinking; use the lowest supported budget. - return 128, true - } return 0, true case "low": return 1024, true diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go index c1bdf7c7f..19b9fcd63 100644 --- a/pkg/providers/gemini_provider_test.go +++ b/pkg/providers/gemini_provider_test.go @@ -312,6 +312,81 @@ func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testi } } +func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if got := thinkingConfig["thinkingBudget"]; got != 0 { + t.Fatalf("thinkingBudget = %#v, want 0 for default/off", got) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts { + t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"]) + } +} + +func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-3-flash-preview", + nil, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if got := thinkingConfig["thinkingLevel"]; got != "minimal" { + t.Fatalf("thinkingLevel = %#v, want minimal for default/off", got) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts { + t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"]) + } +} + +func TestGeminiProvider_BuildRequestBody_PreservesMultipleSystemMessages(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "system", Content: "Be concise."}, + {Role: "user", Content: "hello"}, + }, + nil, + "gemini-3-flash-preview", + nil, + ) + + systemInstruction, ok := body["systemInstruction"].(*geminiContent) + if !ok || systemInstruction == nil { + t.Fatalf("systemInstruction = %#v, want *geminiContent", body["systemInstruction"]) + } + if len(systemInstruction.Parts) != 2 { + t.Fatalf("systemInstruction.Parts len = %d, want 2", len(systemInstruction.Parts)) + } + if systemInstruction.Parts[0].Text != "You are helpful." || systemInstruction.Parts[1].Text != "Be concise." { + t.Fatalf("systemInstruction.Parts = %#v, want ordered system prompts", systemInstruction.Parts) + } +} + func TestGeminiProvider_BuildRequestBody_PreservesToolResponseMedia(t *testing.T) { provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) body := provider.buildRequestBody( From cbae69ad640f4fdf9e7c1f0f4cfa38c6c0daf497 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Sat, 11 Apr 2026 01:38:13 +0800 Subject: [PATCH 04/16] fix(gemini): honor pro-model thinking constraints --- pkg/providers/gemini_provider.go | 19 ++++++++++ pkg/providers/gemini_provider_test.go | 50 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go index 5952188fd..7b913b775 100644 --- a/pkg/providers/gemini_provider.go +++ b/pkg/providers/gemini_provider.go @@ -347,12 +347,21 @@ func buildGeminiThinkingConfig(model string, options map[string]any) map[string] config["includeThoughts"] = includeThoughts if isGemini25Model(model) { + if isGemini25ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") { + // Gemini 2.5 Pro cannot disable thinking; keep model-default thinking. + return config + } if budget, ok := mapGeminiThinkingBudget(rawLevel); ok { config["thinkingBudget"] = budget } return config } + if isGemini3ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") { + // Gemini 3.x Pro does not support minimal thinking level. + return config + } + if thinkingLevel := mapGeminiThinkingLevel(rawLevel); thinkingLevel != "" { config["thinkingLevel"] = thinkingLevel } @@ -369,6 +378,16 @@ func isGemini25Model(model string) bool { return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25") } +func isGemini25ProModel(model string) bool { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + return isGemini25Model(lowerModel) && strings.Contains(lowerModel, "pro") +} + +func isGemini3ProModel(model string) bool { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(lowerModel, "gemini-3") && strings.Contains(lowerModel, "pro") +} + func mapGeminiThinkingBudget(level string) (int, bool) { level = strings.ToLower(strings.TrimSpace(level)) if level == "" { diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go index 19b9fcd63..cbfb97c45 100644 --- a/pkg/providers/gemini_provider_test.go +++ b/pkg/providers/gemini_provider_test.go @@ -362,6 +362,56 @@ func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testin } } +func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25Pro(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-pro", + nil, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts { + t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"]) + } + if _, hasBudget := thinkingConfig["thinkingBudget"]; hasBudget { + t.Fatalf("thinkingBudget should be omitted for Gemini 2.5 Pro default/off: %#v", thinkingConfig) + } +} + +func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini31Pro(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-3.1-pro", + nil, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts { + t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"]) + } + if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel { + t.Fatalf("thinkingLevel should be omitted for Gemini 3.1 Pro default/off: %#v", thinkingConfig) + } +} + func TestGeminiProvider_BuildRequestBody_PreservesMultipleSystemMessages(t *testing.T) { provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) body := provider.buildRequestBody( From b73caebe6f3d798499bce4c69e0ff743b1cd03ca Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Sat, 11 Apr 2026 01:44:39 +0800 Subject: [PATCH 05/16] fix(chat): improve thought readability in dark mode --- web/frontend/src/components/chat/assistant-message.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 418516172..8dcbe15a1 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -39,7 +39,7 @@ export function AssistantMessage({
PicoClaw {isThought && ( - + {t("chat.reasoningLabel")} @@ -57,7 +57,7 @@ export function AssistantMessage({ className={cn( "relative overflow-hidden rounded-xl border", isThought - ? "border-amber-200/90 bg-amber-50/70 text-amber-950" + ? "border-amber-200/90 bg-amber-50/70 text-amber-950 dark:border-amber-500/35 dark:bg-amber-500/10 dark:text-amber-100" : "bg-card text-card-foreground", )} > @@ -82,7 +82,7 @@ export function AssistantMessage({ className={cn( "absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100", isThought - ? "bg-amber-100/70 hover:bg-amber-200/80" + ? "bg-amber-100/70 hover:bg-amber-200/80 dark:bg-amber-500/20 dark:hover:bg-amber-400/30" : "bg-background/50 hover:bg-background/80", )} onClick={handleCopy} From 86917faa9ba2fc8f7d08b3106080c15a04daaa89 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Sat, 11 Apr 2026 02:23:35 +0800 Subject: [PATCH 06/16] fix(ci): resolve lint header casing and fallback test routing --- pkg/agent/loop_test.go | 2 +- pkg/providers/gemini_provider.go | 2 +- pkg/providers/gemini_provider_test.go | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 56ea000c8..7fe5836b3 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1921,7 +1921,7 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) { }, { ModelName: "gemma-fallback", - Model: "gemini/gemma-3-27b-it", + Model: "openrouter/gemma-3-27b-it", APIBase: fallbackServer.URL, APIKeys: config.SimpleSecureStrings("fallback-key"), Workspace: workspace, diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go index 7b913b775..370ce5674 100644 --- a/pkg/providers/gemini_provider.go +++ b/pkg/providers/gemini_provider.go @@ -153,7 +153,7 @@ func (p *GeminiProvider) ChatStream( func (p *GeminiProvider) applyHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") if p.apiKey != "" { - req.Header.Set("x-goog-api-key", p.apiKey) + req.Header.Set("X-Goog-Api-Key", p.apiKey) } if p.userAgent != "" { req.Header.Set("User-Agent", p.userAgent) diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go index cbfb97c45..9debcd79f 100644 --- a/pkg/providers/gemini_provider_test.go +++ b/pkg/providers/gemini_provider_test.go @@ -19,8 +19,8 @@ func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) { if !strings.Contains(r.URL.Path, ":generateContent") { t.Fatalf("path = %s, expected generateContent endpoint", r.URL.Path) } - if got := r.Header.Get("x-goog-api-key"); got != "test-key" { - t.Fatalf("x-goog-api-key = %q, want %q", got, "test-key") + if got := r.Header.Get("X-Goog-Api-Key"); got != "test-key" { + t.Fatalf("X-Goog-Api-Key = %q, want %q", got, "test-key") } if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { t.Fatalf("decode request body: %v", err) @@ -489,8 +489,8 @@ func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) { if got := r.Header.Get("Authorization"); got != "Bearer test-token" { t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token") } - if got := r.Header.Get("x-goog-api-key"); got != "" { - t.Fatalf("x-goog-api-key = %q, want empty", got) + if got := r.Header.Get("X-Goog-Api-Key"); got != "" { + t.Fatalf("X-Goog-Api-Key = %q, want empty", got) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ @@ -533,8 +533,8 @@ func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) { func TestGeminiProvider_ChatAllowsMissingAPIKeyForCustomAPIBase(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("x-goog-api-key"); got != "" { - t.Fatalf("x-goog-api-key = %q, want empty", got) + if got := r.Header.Get("X-Goog-Api-Key"); got != "" { + t.Fatalf("X-Goog-Api-Key = %q, want empty", got) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ From e9f55d776de85117710c3f289d852959d2c1f7c1 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:18:41 +0800 Subject: [PATCH 07/16] fix(review): address copilot backpressure and SSE parse feedback --- pkg/agent/loop.go | 2 +- pkg/providers/gemini_provider.go | 7 ++- pkg/providers/gemini_provider_test.go | 77 +++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 03fdfec82..a856c0fca 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -2261,7 +2261,7 @@ turnLoop: reasoningContent = response.ReasoningContent } if ts.channel == "pico" { - al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) + go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) } else { go al.handleReasoning( turnCtx, diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go index 370ce5674..96f8da66d 100644 --- a/pkg/providers/gemini_provider.go +++ b/pkg/providers/gemini_provider.go @@ -481,14 +481,17 @@ func parseGeminiStreamResponse( if !strings.HasPrefix(line, "data: ") { continue } - data := strings.TrimPrefix(line, "data: ") + data := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + if data == "" { + continue + } if data == "[DONE]" { break } var chunk geminiGenerateContentResponse if err := json.Unmarshal([]byte(data), &chunk); err != nil { - continue + return nil, fmt.Errorf("invalid gemini stream chunk: %w", err) } for _, candidate := range chunk.Candidates { diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go index 9debcd79f..3c90cc4e2 100644 --- a/pkg/providers/gemini_provider_test.go +++ b/pkg/providers/gemini_provider_test.go @@ -212,6 +212,83 @@ func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) { } } +func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not flushable") + } + + _, _ = fmt.Fprint(w, "data: \n\n") + flusher.Flush() + + chunk := map[string]any{ + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{map[string]any{"text": "ok"}}, + }, + "finishReason": "STOP", + }}, + } + raw, err := json.Marshal(chunk) + if err != nil { + t.Fatalf("marshal chunk: %v", err) + } + _, _ = fmt.Fprintf(w, "data: %s\n\n", raw) + flusher.Flush() + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil) + resp, err := provider.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + nil, + ) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + if resp.Content != "ok" { + t.Fatalf("Content = %q, want %q", resp.Content, "ok") + } +} + +func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not flushable") + } + + _, _ = fmt.Fprint(w, "data: {invalid-json}\n\n") + flusher.Flush() + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil) + _, err := provider.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + nil, + ) + if err == nil { + t.Fatal("ChatStream() expected error for invalid SSE data frame") + } + if !strings.Contains(err.Error(), "invalid gemini stream chunk") { + t.Fatalf("error = %v, want contains %q", err, "invalid gemini stream chunk") + } +} + func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) { provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) From 6fbd7e0a3fb04929f21f8d1b3ebd2261057c144f Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:02:58 +0800 Subject: [PATCH 08/16] fix(gemini): align thoughtSignature and stream tool IDs --- pkg/providers/gemini_provider.go | 24 +++-- pkg/providers/gemini_provider_test.go | 121 ++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go index 96f8da66d..561387534 100644 --- a/pkg/providers/gemini_provider.go +++ b/pkg/providers/gemini_provider.go @@ -226,7 +226,6 @@ func (p *GeminiProvider) buildRequestBody( } if thoughtSignature != "" { part.ThoughtSignature = thoughtSignature - part.ThoughtSignatureSnake = thoughtSignature } content.Parts = append(content.Parts, part) } @@ -508,12 +507,25 @@ func parseGeminiStreamResponse( } if part.FunctionCall != nil { tc := buildGeminiToolCall(part) - key := tc.ID - if strings.TrimSpace(key) == "" { - fallbackIndex++ - key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex) - tc.ID = key + if strings.TrimSpace(tc.Name) == "" { + continue } + + key := strings.TrimSpace(part.FunctionCall.ID) + if key == "" { + if len(toolCallOrder) > 0 { + lastKey := toolCallOrder[len(toolCallOrder)-1] + if lastTC, exists := toolCallsByID[lastKey]; exists && lastTC.Name == tc.Name { + key = lastKey + } + } + if key == "" { + fallbackIndex++ + key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex) + } + } + + tc.ID = key if _, exists := toolCallsByID[key]; !exists { toolCallOrder = append(toolCallOrder, key) } diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go index 3c90cc4e2..a0ab748eb 100644 --- a/pkg/providers/gemini_provider_test.go +++ b/pkg/providers/gemini_provider_test.go @@ -289,6 +289,127 @@ func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) { } } +func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + + body := provider.buildRequestBody( + []Message{{ + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Name: "search", + Arguments: map[string]any{"q": "hello"}, + Function: &FunctionCall{ + Name: "search", + Arguments: `{"q":"hello"}`, + ThoughtSignature: "sig-1", + }, + }}, + }}, + nil, + "gemini-2.5-flash", + nil, + ) + + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + jsonBody := string(raw) + + if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) { + t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody) + } + if strings.Contains(jsonBody, `"thought_signature"`) { + t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody) + } +} + +func TestGeminiProvider_ChatStreamCoalescesToolCallWithoutWireID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not flushable") + } + + chunks := []map[string]any{ + { + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{ + "functionCall": map[string]any{ + "name": "search", + "args": map[string]any{"q": "first"}, + }, + }, + }, + }, + }}, + }, + { + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{ + "functionCall": map[string]any{ + "name": "search", + "args": map[string]any{"q": "second"}, + }, + }, + }, + }, + "finishReason": "STOP", + }}, + }, + } + + for _, chunk := range chunks { + raw, err := json.Marshal(chunk) + if err != nil { + t.Fatalf("marshal chunk: %v", err) + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil { + t.Fatalf("write chunk: %v", err) + } + flusher.Flush() + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil) + resp, err := provider.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + nil, + ) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.ID != "search#1" { + t.Fatalf("ToolCall ID = %q, want %q", tc.ID, "search#1") + } + if tc.Name != "search" { + t.Fatalf("ToolCall Name = %q, want %q", tc.Name, "search") + } + if argQ, ok := tc.Arguments["q"].(string); !ok || argQ != "second" { + t.Fatalf("ToolCall Arguments = %#v, want q=second", tc.Arguments) + } + if resp.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } +} + func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) { provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) From 080f532d825a4585b8030dce1ec14ef803a477c1 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Sun, 12 Apr 2026 17:41:10 +0800 Subject: [PATCH 09/16] build: add Android arm64 cross-compile support - Add build-android-arm64, build-launcher-android-arm64, build-all-android targets to Makefile and web/Makefile - Use -tags stdjson (no goolm) for Android; CGO_ENABLED=0 throughout - Output staged as build/android-staging/arm64-v8a/libpicoclaw{,-web}.so for JNI consumption; zip packaging handled by CI - Exclude Matrix channel from android builds (channel_matrix.go) to avoid modernc.org/sqlite CGO dependency - Exclude systray from android builds; use headless stub instead (systray.go / systray_stub_nocgo.go) --- Makefile | 34 +++++++++++++++++++++++++++++++ pkg/gateway/channel_matrix.go | 2 +- web/Makefile | 15 +++++++++++++- web/backend/systray.go | 2 +- web/backend/systray_stub_nocgo.go | 2 +- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index f7ebc7411..ef0dcd9b1 100644 --- a/Makefile +++ b/Makefile @@ -205,6 +205,40 @@ build-linux-mipsle: generate $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle" +## build-android-arm64: Build core for Android ARM64 +build-android-arm64: generate + @echo "Building for android/arm64..." + @mkdir -p $(BUILD_DIR) + GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64" + +## build-launcher-android-arm64: Build launcher for Android ARM64 +build-launcher-android-arm64: + @echo "Building picoclaw-launcher for android/arm64..." + @mkdir -p $(BUILD_DIR) + @$(MAKE) -C web build \ + OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \ + WEB_GO='GOOS=android GOARCH=arm64 CGO_ENABLED=0 go' \ + GO_BUILD_TAGS='stdjson' \ + LDFLAGS='$(LDFLAGS)' + @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64" + +## build-all-android: Build core and launcher for all Android architectures and package as universal zip +build-all-android: generate + @echo "Building core for all Android architectures..." + @mkdir -p $(BUILD_DIR) + GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR) + @echo "Building launcher for Android arm64..." + @$(MAKE) build-launcher-android-arm64 + @echo "Staging JNI libs..." + @rm -rf $(BUILD_DIR)/android-staging + @mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a + @cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so + @cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so + @cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip . + @rm -rf $(BUILD_DIR)/android-staging + @echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip" + ## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit) build-pi-zero: build-linux-arm build-linux-arm64 @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" diff --git a/pkg/gateway/channel_matrix.go b/pkg/gateway/channel_matrix.go index a46addae1..b6adbe498 100644 --- a/pkg/gateway/channel_matrix.go +++ b/pkg/gateway/channel_matrix.go @@ -1,4 +1,4 @@ -//go:build !mipsle && !netbsd && !(freebsd && arm) +//go:build !mipsle && !netbsd && !(freebsd && arm) && !android package gateway diff --git a/web/Makefile b/web/Makefile index 891c170c2..58b65621d 100644 --- a/web/Makefile +++ b/web/Makefile @@ -1,4 +1,5 @@ -.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean +.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \ + build-android-arm64 build-all-android # Go variables GO?=CGO_ENABLED=0 go @@ -9,6 +10,7 @@ GOFLAGS?=-v -tags $(GO_BUILD_TAGS) # Build variables BUILD_DIR=build OUTPUT?=$(BUILD_DIR)/picoclaw-launcher +OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64 FRONTEND_DIR=frontend BACKEND_DIR=backend BACKEND_DIST=$(BACKEND_DIR)/dist @@ -91,6 +93,17 @@ build: build-frontend @mkdir -p "$$(dirname "$(OUTPUT)")" ${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/ +# Build launcher for Android ARM64 (frontend must already be built) +build-android-arm64: build-frontend + @mkdir -p $(BUILD_DIR) + GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/ + +# Build launcher for all Android architectures +build-all-android: build-frontend + @mkdir -p $(BUILD_DIR) + GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/ + @echo "All Android launcher builds complete" + build-frontend: @if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ [ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \ diff --git a/web/backend/systray.go b/web/backend/systray.go index 9dcc025df..204bd7dc6 100644 --- a/web/backend/systray.go +++ b/web/backend/systray.go @@ -1,4 +1,4 @@ -//go:build (!darwin && !freebsd) || cgo +//go:build (!darwin && !freebsd && !android) || cgo package main diff --git a/web/backend/systray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go index 9e75e112a..41514feef 100644 --- a/web/backend/systray_stub_nocgo.go +++ b/web/backend/systray_stub_nocgo.go @@ -1,4 +1,4 @@ -//go:build (darwin || freebsd) && !cgo +//go:build (darwin || freebsd || android) && !cgo package main From 168b6bec5817e4b214e3be2596beb94fe8f07715 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Sun, 12 Apr 2026 18:35:05 +0800 Subject: [PATCH 10/16] build(android): ci build added --- .github/workflows/release.yml | 11 +++++++++++ Makefile | 1 + 2 files changed, 12 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ce341770..03c7ce7d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,6 +110,17 @@ jobs: MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + - name: Build and upload Android arm64 + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + sudo apt-get install -y zip + make build-all-android + gh release upload "${{ inputs.tag }}" \ + build/picoclaw-android-universal.zip \ + --clobber + - name: Apply release flags shell: bash env: diff --git a/Makefile b/Makefile index ef0dcd9b1..1cc853458 100644 --- a/Makefile +++ b/Makefile @@ -260,6 +260,7 @@ build-all: generate GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR) GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR) + @$(MAKE) build-all-android @echo "All builds complete" ## install: Install picoclaw to system and copy builtin skills From b6617a4b176f5e1c622e40fbe282b102231e3692 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Sun, 12 Apr 2026 03:44:24 -0700 Subject: [PATCH 11/16] feat(cli): structured terminal UI for PicoClaw CLI like modern CLIs (#2229) * feat(cli): add boxed help/error UI with no-color support * fix: CI testing error * fix: lint errors * fix linter error * fix: address review --- cmd/picoclaw/internal/cliui/cliui.go | 147 +++++++++++ cmd/picoclaw/internal/cliui/cliui_test.go | 180 +++++++++++++ cmd/picoclaw/internal/cliui/help_cmd.go | 298 ++++++++++++++++++++++ cmd/picoclaw/internal/cliui/help_error.go | 75 ++++++ cmd/picoclaw/internal/cliui/onboard.go | 110 ++++++++ cmd/picoclaw/internal/cliui/status.go | 168 ++++++++++++ cmd/picoclaw/internal/cliui/version.go | 61 +++++ cmd/picoclaw/internal/onboard/helpers.go | 25 +- cmd/picoclaw/internal/status/helpers.go | 130 ++++++++-- cmd/picoclaw/internal/version/command.go | 11 +- cmd/picoclaw/main.go | 84 +++++- cmd/picoclaw/main_test.go | 9 +- go.mod | 11 +- go.sum | 19 ++ 14 files changed, 1257 insertions(+), 71 deletions(-) create mode 100644 cmd/picoclaw/internal/cliui/cliui.go create mode 100644 cmd/picoclaw/internal/cliui/cliui_test.go create mode 100644 cmd/picoclaw/internal/cliui/help_cmd.go create mode 100644 cmd/picoclaw/internal/cliui/help_error.go create mode 100644 cmd/picoclaw/internal/cliui/onboard.go create mode 100644 cmd/picoclaw/internal/cliui/status.go create mode 100644 cmd/picoclaw/internal/cliui/version.go diff --git a/cmd/picoclaw/internal/cliui/cliui.go b/cmd/picoclaw/internal/cliui/cliui.go new file mode 100644 index 000000000..b1ba636c9 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/cliui.go @@ -0,0 +1,147 @@ +// Package cliui renders human-oriented CLI output: bordered panels and columns +// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI +// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY +// stdout falls back to plain line-oriented output. +package cliui + +import ( + "os" + "sync" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "golang.org/x/term" +) + +// Minimum terminal width (columns) for bordered / structured layout. +// Below this, plain line-oriented output is used so boxes do not wrap badly. +const minWidthFancy = 88 + +// Minimum width to lay out some views in two columns (e.g. status providers). +const minWidthColumns = 104 + +var initMu sync.Mutex + +// Init configures lipgloss for this process. When disableAnsiColors is true +// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode +// borders still render when UseFancyLayout() is true. +func Init(disableAnsiColors bool) { + initMu.Lock() + defer initMu.Unlock() + if disableAnsiColors { + lipgloss.SetColorProfile(termenv.Ascii) + return + } + lipgloss.SetColorProfile(termenv.EnvColorProfile()) +} + +// StdoutWidth returns the terminal width or a sane default if unknown. +func StdoutWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w < 20 { + return 80 + } + return w +} + +// UseFancyLayout is true when styled boxes/columns should be used. +func UseFancyLayout() bool { + if !term.IsTerminal(int(os.Stdout.Fd())) { + return false + } + return StdoutWidth() >= minWidthFancy +} + +// UseColumnLayout is true when a second content column is viable. +func UseColumnLayout() bool { + return UseFancyLayout() && StdoutWidth() >= minWidthColumns +} + +// InnerWidth is the target content width inside borders/margins. +func InnerWidth() int { + w := StdoutWidth() + // Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding). + const borderBudget = 8 + if w > borderBudget+48 { + return w - borderBudget + } + return 48 +} + +// StderrWidth returns stderr terminal width or a sane default. +func StderrWidth() int { + w, _, err := term.GetSize(int(os.Stderr.Fd())) + if err != nil || w < 20 { + return 80 + } + return w +} + +// UseFancyStderr is true when stderr can show boxed errors without ugly wraps. +func UseFancyStderr() bool { + if !term.IsTerminal(int(os.Stderr.Fd())) { + return false + } + return StderrWidth() >= minWidthFancy +} + +// InnerStderrWidth mirrors InnerWidth but for stderr. +func InnerStderrWidth() int { + w := StderrWidth() + const borderBudget = 8 + if w > borderBudget+48 { + return w - borderBudget + } + return 48 +} + +var ( + accentBlue = lipgloss.Color("#3E5DB9") + accentRed = lipgloss.Color("#D54646") + colorMuted = lipgloss.Color("#6B6B6B") + colorOK = lipgloss.Color("#2E7D32") +) + +func borderStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accentBlue). + Padding(0, 1) +} + +func titleBarStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(accentRed). + Bold(true) +} + +func mutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorMuted) +} + +func bodyStyle() lipgloss.Style { + return lipgloss.NewStyle() +} + +func kvKeyStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +func kvValStyle() lipgloss.Style { + return lipgloss.NewStyle() +} + +// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side). +func helpIntroStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +// helpIdentStyle is the left column for commands and flags (blue identifiers). +func helpIdentStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +// helpPlaceholderStyle highlights in usage lines (red accent). +func helpPlaceholderStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentRed).Bold(true) +} diff --git a/cmd/picoclaw/internal/cliui/cliui_test.go b/cmd/picoclaw/internal/cliui/cliui_test.go new file mode 100644 index 000000000..c07e220ee --- /dev/null +++ b/cmd/picoclaw/internal/cliui/cliui_test.go @@ -0,0 +1,180 @@ +package cliui + +import ( + "testing" + + flag "github.com/spf13/pflag" +) + +func init() { + // Disable ANSI colors in tests so output is predictable plain text. + Init(true) +} + +// --------------------------------------------------------------------------- +// showErrHint +// --------------------------------------------------------------------------- + +func TestShowErrHint(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + // Cobra flag errors — should show hint + {"unknown flag: --foo", true}, + {"unknown shorthand flag: 'f' in -f", true}, + {"flag needs an argument: --output", true}, + {"required flag(s) \"model\" not set", true}, + // Generic invalid-argument errors — should show hint + {"invalid argument \"abc\" for --count", true}, + // required flag errors — should show hint + {"required flag(s) \"model\" not set", true}, + // usage: in message — should show hint + {"bad input\nusage: picoclaw ...", true}, + // Should NOT false-positive on broad words + {"connection flagged by remote", false}, + {"feature flag not set", false}, + {"invalid API key provided", false}, + {"authentication required", false}, + // Unrelated messages — no hint + {"something went wrong", false}, + {"network timeout", false}, + } + + for _, tc := range cases { + got := showErrHint(tc.msg) + if got != tc.want { + t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// styleUsageTokens +// --------------------------------------------------------------------------- + +func TestStyleUsageTokensContainsTokens(t *testing.T) { + cases := []struct { + input string + contains []string // substrings that must appear in plain output + }{ + { + "picoclaw agent ", + []string{"picoclaw agent", ""}, + }, + { + "picoclaw [command] [flags]", + []string{"picoclaw", "[command]", "[flags]"}, + }, + { + "picoclaw", + []string{"picoclaw"}, + }, + { + "cmd [--flag]", + []string{"cmd", "", "[--flag]"}, + }, + } + + for _, tc := range cases { + out := styleUsageTokens(tc.input) + for _, sub := range tc.contains { + if !containsStripped(out, sub) { + t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub) + } + } + } +} + +// containsStripped checks whether plain contains sub after stripping ANSI escapes. +// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests, +// so this is just a plain substring check. +func containsStripped(plain, sub string) bool { + return len(plain) >= len(sub) && findSubstring(plain, sub) +} + +func findSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// collectFlagRows +// --------------------------------------------------------------------------- + +func TestCollectFlagRows_Empty(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + rows := collectFlagRows(fs) + if len(rows) != 0 { + t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows)) + } +} + +func TestCollectFlagRows_BasicFlags(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("output", "", "output file path") + fs.Bool("verbose", false, "enable verbose mode") + fs.Int("count", 1, "number of items") + + rows := collectFlagRows(fs) + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + // Rows must be sorted alphabetically by flag name. + names := make([]string, 0, len(rows)) + for _, r := range rows { + names = append(names, r[0]) + } + if names[0] > names[1] || names[1] > names[2] { + t.Errorf("rows not sorted: %v", names) + } +} + +func TestCollectFlagRows_Shorthand(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.StringP("model", "m", "", "model name") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + left := rows[0][0] + if !findSubstring(left, "-m") || !findSubstring(left, "--model") { + t.Errorf("expected shorthand and long form in %q", left) + } +} + +func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("visible", "", "this shows up") + hidden := fs.String("hidden", "", "this should not show up") + _ = hidden + _ = fs.MarkHidden("hidden") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows)) + } + if !findSubstring(rows[0][0], "visible") { + t.Errorf("expected visible flag in rows, got %q", rows[0][0]) + } +} + +func TestCollectFlagRows_UsageInRightColumn(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("format", "json", "output format: json or text") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0][1] != "output format: json or text" { + t.Errorf("expected usage in right column, got %q", rows[0][1]) + } +} diff --git a/cmd/picoclaw/internal/cliui/help_cmd.go b/cmd/picoclaw/internal/cliui/help_cmd.go new file mode 100644 index 000000000..72956afaa --- /dev/null +++ b/cmd/picoclaw/internal/cliui/help_cmd.go @@ -0,0 +1,298 @@ +package cliui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" +) + +// RenderCommandHelp builds Ruff-style sectioned, two-column help when +// UseFancyLayout(); otherwise plain Cobra-style text. +func RenderCommandHelp(c *cobra.Command) string { + if !UseFancyLayout() { + return plainCommandHelp(c) + } + syncFlags(c) + + var b strings.Builder + head, sub := helpIntro(c) + if head != "" { + b.WriteString(helpIntroStyle().Render(head)) + b.WriteString("\n") + } + if sub != "" { + b.WriteString(mutedStyle().Render(sub)) + b.WriteString("\n") + } + if head != "" || sub != "" { + b.WriteString("\n") + } + + inner := InnerWidth() + contentW := inner - 6 + if contentW < 36 { + contentW = 36 + } + + // Usage + usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine())) + b.WriteString(sectionPanel("Usage", usageBody, inner)) + b.WriteString("\n") + + // Examples + if ex := strings.TrimSpace(c.Example); ex != "" { + exBody := bodyStyle().Width(contentW).Render(ex) + b.WriteString(sectionPanel("Examples", exBody, inner)) + b.WriteString("\n") + } + + // Subcommands + subs := visibleSubcommands(c) + if len(subs) > 0 { + rows := make([][2]string, 0, len(subs)) + for _, sub := range subs { + left := sub.Name() + if a := sub.Aliases; len(a) > 0 { + left += " (" + strings.Join(a, ", ") + ")" + } + rows = append(rows, [2]string{left, sub.Short}) + } + b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner)) + b.WriteString("\n") + } + + // Local options + local := c.LocalFlags() + opts := collectFlagRows(local) + if len(opts) > 0 { + title := "Options" + if !c.HasParent() { + title = "Flags" + } + b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner)) + b.WriteString("\n") + } + + // Global (inherited) options + if c.HasAvailableInheritedFlags() { + inh := collectFlagRows(c.InheritedFlags()) + if len(inh) > 0 { + b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner)) + b.WriteString("\n") + } + } + + return b.String() +} + +// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help, +// for embedding after errors (stderr). outerW is typically InnerStderrWidth(). +func RenderCommandQuickRef(c *cobra.Command, outerW int) string { + if c == nil || outerW < 40 { + return "" + } + syncFlags(c) + contentW := outerW - 6 + if contentW < 36 { + contentW = 36 + } + var b strings.Builder + usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine())) + b.WriteString(sectionPanel("Usage", usageBody, outerW)) + b.WriteString("\n") + if len(c.Aliases) > 0 { + al := "Aliases: " + strings.Join(c.Aliases, ", ") + alBody := mutedStyle().MaxWidth(contentW).Render(al) + b.WriteString(sectionPanel("Aliases", alBody, outerW)) + b.WriteString("\n") + } + opts := collectFlagRows(c.LocalFlags()) + if len(opts) > 0 { + title := "Options" + if !c.HasParent() { + title = "Flags" + } + b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW)) + b.WriteString("\n") + } + if c.HasAvailableInheritedFlags() { + inh := collectFlagRows(c.InheritedFlags()) + if len(inh) > 0 { + b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW)) + b.WriteString("\n") + } + } + return b.String() +} + +func syncFlags(c *cobra.Command) { + _ = c.LocalFlags() + if c.HasAvailableInheritedFlags() { + _ = c.InheritedFlags() + } +} + +func plainCommandHelp(c *cobra.Command) string { + desc := c.Long + if desc == "" { + desc = c.Short + } + desc = strings.TrimRight(desc, " \t\n\r") + var b strings.Builder + if desc != "" { + fmt.Fprintln(&b, desc) + fmt.Fprintln(&b) + } + if c.Runnable() || c.HasSubCommands() { + b.WriteString(c.UsageString()) + } + return b.String() +} + +func helpIntro(c *cobra.Command) (head, sub string) { + head = strings.TrimSpace(c.Short) + long := strings.TrimSpace(c.Long) + if long == "" || long == head { + return head, "" + } + lines := strings.Split(long, "\n") + var rest []string + for i, ln := range lines { + ln = strings.TrimSpace(ln) + if ln == "" { + continue + } + if i == 0 && ln == head { + continue + } + rest = append(rest, ln) + } + sub = strings.Join(rest, "\n") + return head, sub +} + +func visibleSubcommands(c *cobra.Command) []*cobra.Command { + var out []*cobra.Command + for _, sub := range c.Commands() { + if sub.Hidden { + continue + } + out = append(out, sub) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) + return out +} + +func sectionPanel(title, body string, width int) string { + head := titleBarStyle().Render(title) + "\n\n" + return borderStyle().Width(width).Render(head + body) +} + +// styleUsageTokens highlights PicoClaw-blue command tokens and red /[groups]. +func styleUsageTokens(s string) string { + var b strings.Builder + for len(s) > 0 { + ia := strings.Index(s, "<") + ib := strings.Index(s, "[") + next, kind := -1, 0 // 1 = angle, 2 = bracket + switch { + case ia >= 0 && (ib < 0 || ia < ib): + next, kind = ia, 1 + case ib >= 0: + next, kind = ib, 2 + } + if next < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + if next > 0 { + b.WriteString(helpIdentStyle().Render(s[:next])) + } + s = s[next:] + if kind == 1 { + j := strings.Index(s, ">") + if j < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + b.WriteString(helpPlaceholderStyle().Render(s[:j+1])) + s = s[j+1:] + continue + } + j := strings.Index(s, "]") + if j < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + b.WriteString(helpPlaceholderStyle().Render(s[:j+1])) + s = s[j+1:] + } + return b.String() +} + +func collectFlagRows(fs *flag.FlagSet) [][2]string { + var names []string + seen := map[string][2]string{} + fs.VisitAll(func(f *flag.Flag) { + if f.Hidden { + return + } + left := formatFlagLeft(f) + right := f.Usage + if f.Deprecated != "" { + right += " (deprecated: " + f.Deprecated + ")" + } + names = append(names, f.Name) + seen[f.Name] = [2]string{left, right} + }) + sort.Strings(names) + rows := make([][2]string, 0, len(names)) + for _, n := range names { + rows = append(rows, seen[n]) + } + return rows +} + +func formatFlagLeft(f *flag.Flag) string { + if len(f.Shorthand) > 0 { + return "-" + f.Shorthand + ", --" + f.Name + } + return "--" + f.Name +} + +func renderTwoColPairs(rows [][2]string, contentW int) string { + if len(rows) == 0 { + return "" + } + leftW := 0 + for _, r := range rows { + if w := lipgloss.Width(r[0]); w > leftW { + leftW = w + } + } + const minLeft, maxLeft = 16, 34 + if leftW < minLeft { + leftW = minLeft + } + if leftW > maxLeft { + leftW = maxLeft + } + gap := " " + rightW := contentW - leftW - lipgloss.Width(gap) + if rightW < 24 { + rightW = 24 + } + + var b strings.Builder + for _, r := range rows { + left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0]) + right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1])) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/cmd/picoclaw/internal/cliui/help_error.go b/cmd/picoclaw/internal/cliui/help_error.go new file mode 100644 index 000000000..1e859b08f --- /dev/null +++ b/cmd/picoclaw/internal/cliui/help_error.go @@ -0,0 +1,75 @@ +package cliui + +import ( + "strings" + + "github.com/spf13/cobra" +) + +// FormatCLIError formats errors with the same boxed sections as help. When ctx +// is the command that was running when the error occurred, Usage / Flags panels +// are appended so styling matches picoclaw -h. +func FormatCLIError(msg string, ctx *cobra.Command) string { + msg = strings.TrimRight(msg, "\n") + if !UseFancyStderr() { + s := "Error: " + msg + "\n" + if ctx != nil && showErrHint(msg) { + s += "\n" + plainCommandHelp(ctx) + } + return s + } + w := InnerStderrWidth() + contentW := w - 6 + if contentW < 36 { + contentW = 36 + } + + title := titleBarStyle().Render("Error") + "\n\n" + + paras := strings.Split(msg, "\n") + var body strings.Builder + for i, p := range paras { + p = strings.TrimRight(p, " ") + if p == "" { + continue + } + st := bodyStyle().Width(contentW) + if i > 0 { + body.WriteString("\n") + } + if i == 0 { + body.WriteString(st.Render(p)) + } else { + body.WriteString(mutedStyle().Width(contentW).Render(p)) + } + } + + foot := "" + if showErrHint(msg) { + if ctx != nil { + foot = "\n\n" + mutedStyle().Width(contentW). + Render("Full command help: "+ctx.CommandPath()+" --help") + } else { + foot = "\n\n" + mutedStyle().Width(contentW). + Render("Tip: picoclaw --help · picoclaw --help") + } + } + + out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n" + if ctx != nil && showErrHint(msg) { + if ref := RenderCommandQuickRef(ctx, w); ref != "" { + out += "\n" + ref + } + } + return out +} + +func showErrHint(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "unknown flag") || + strings.Contains(m, "unknown shorthand flag") || + strings.Contains(m, "flag needs an argument") || + strings.Contains(m, "invalid argument") || + strings.Contains(m, "required flag") || + strings.Contains(m, "usage:") +} diff --git a/cmd/picoclaw/internal/cliui/onboard.go b/cmd/picoclaw/internal/cliui/onboard.go new file mode 100644 index 000000000..e74cf68c6 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/onboard.go @@ -0,0 +1,110 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// PrintOnboardComplete prints the post-onboard “ready” message and next steps. +func PrintOnboardComplete(logo string, encrypt bool, configPath string) { + if !UseFancyLayout() { + printOnboardPlain(logo, encrypt, configPath) + return + } + printOnboardFancy(logo, encrypt, configPath) +} + +func printOnboardPlain(logo string, encrypt bool, configPath string) { + fmt.Printf("\n%s picoclaw is ready!\n", logo) + fmt.Println("\nNext steps:") + if encrypt { + fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") + fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") + fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") + fmt.Println("") + fmt.Println(" 2. Add your API key to", configPath) + } else { + fmt.Println(" 1. Add your API key to", configPath) + } + fmt.Println("") + fmt.Println(" Recommended:") + fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") + fmt.Println(" - Ollama: https://ollama.com (local, free)") + fmt.Println("") + fmt.Println(" See README.md for 17+ supported providers.") + fmt.Println("") + if encrypt { + fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") + } else { + fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + } +} + +func printOnboardFancy(logo string, encrypt bool, configPath string) { + inner := InnerWidth() + box := borderStyle().MaxWidth(inner + 8) + + ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n" + fmt.Println() + fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready))) + fmt.Println() + + steps := buildOnboardingSteps(encrypt, configPath) + rec := recommendedBlock() + chat := chatStep(encrypt) + + if UseColumnLayout() { + leftW := min(inner/2-2, 52) + rightW := inner - leftW - 4 + if rightW < 36 { + rightW = 36 + } + leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW). + Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps)) + rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW). + Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec)) + gap := strings.Repeat(" ", 2) + fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock)) + fmt.Println() + full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat)) + fmt.Println(full) + return + } + + // Same order as plain output: numbered steps → recommended → chat line. + next := titleBarStyle().Render("Next steps") + "\n\n" + + bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat) + fmt.Println(borderStyle().Width(inner).Render(next)) +} + +func buildOnboardingSteps(encrypt bool, configPath string) string { + var b strings.Builder + if encrypt { + b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n") + b.WriteString(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS\n") + b.WriteString(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd\n\n") + b.WriteString("2. Add your API key to\n ") + b.WriteString(configPath) + b.WriteString("\n") + } else { + b.WriteString("1. Add your API key to\n ") + b.WriteString(configPath) + b.WriteString("\n") + } + return b.String() +} + +func recommendedBlock() string { + return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" + + "• Ollama: https://ollama.com\n (local, free)\n\n" + + "See README.md for 17+ supported providers." +} + +func chatStep(encrypt bool) string { + if encrypt { + return "3. Chat:\n picoclaw agent -m \"Hello!\"" + } + return "2. Chat:\n picoclaw agent -m \"Hello!\"" +} diff --git a/cmd/picoclaw/internal/cliui/status.go b/cmd/picoclaw/internal/cliui/status.go new file mode 100644 index 000000000..f01fe296d --- /dev/null +++ b/cmd/picoclaw/internal/cliui/status.go @@ -0,0 +1,168 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ProviderRow holds one provider's display name and status value. +type ProviderRow struct { + Name string + Val string +} + +// StatusReport is a structured status view for PrintStatus. +type StatusReport struct { + Logo string + Version string + Build string + ConfigPath string + ConfigOK bool + WorkspacePath string + WorkspaceOK bool + Model string + Providers []ProviderRow + OAuthLines []string // each full line "provider (method): state" +} + +// PrintStatus renders picoclaw status (plain or fancy). +func PrintStatus(r StatusReport) { + if !UseFancyLayout() { + printStatusPlain(r) + return + } + printStatusFancy(r) +} + +func printStatusPlain(r StatusReport) { + fmt.Printf("%s picoclaw Status\n", r.Logo) + fmt.Printf("Version: %s\n", r.Version) + if r.Build != "" { + fmt.Printf("Build: %s\n", r.Build) + } + fmt.Println() + + printPathLine("Config", r.ConfigPath, r.ConfigOK) + printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK) + + if r.ConfigOK { + fmt.Printf("Model: %s\n", r.Model) + for _, p := range r.Providers { + fmt.Printf("%s: %s\n", p.Name, p.Val) + } + if len(r.OAuthLines) > 0 { + fmt.Println("\nOAuth/Token Auth:") + for _, line := range r.OAuthLines { + fmt.Printf(" %s\n", line) + } + } + } +} + +func printPathLine(label, path string, ok bool) { + mark := "✗" + if ok { + mark = "✓" + } + fmt.Println(label+":", path, mark) +} + +func printStatusFancy(r StatusReport) { + inner := InnerWidth() + topBox := borderStyle().Width(inner) + + var head strings.Builder + head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status")) + head.WriteString("\n\n") + head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version)) + if r.Build != "" { + head.WriteString("\n") + head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build)) + } + fmt.Println(topBox.Render(head.String())) + fmt.Println() + + if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK { + leftW := (inner - 2) / 2 + rightW := inner - leftW - 2 + pathsNarrow := pathStatusPanel(r, leftW) + prov := providerTablePanel(r, rightW) + gap := strings.Repeat(" ", 2) + fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov)) + } else { + fmt.Println(pathStatusPanel(r, inner)) + if len(r.Providers) > 0 && r.ConfigOK { + fmt.Println(providerTablePanel(r, inner)) + } + } + + if len(r.OAuthLines) > 0 && r.ConfigOK { + var ob strings.Builder + ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n") + for _, line := range r.OAuthLines { + ob.WriteString(" • " + line + "\n") + } + fmt.Println() + fmt.Println(borderStyle().Width(inner).Render(ob.String())) + } +} + +func pathStatusPanel(r StatusReport, inner int) string { + cfgMark := statusMark(r.ConfigOK) + wsMark := statusMark(r.WorkspaceOK) + var b strings.Builder + b.WriteString(kvKeyStyle().Render("Config") + "\n") + b.WriteString(mutedStyle().Render(r.ConfigPath)) + b.WriteString(" " + cfgMark + "\n\n") + b.WriteString(kvKeyStyle().Render("Workspace") + "\n") + b.WriteString(mutedStyle().Render(r.WorkspacePath)) + b.WriteString(" " + wsMark + "\n") + if r.ConfigOK { + b.WriteString("\n") + b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model)) + } + return borderStyle().Width(inner).Render(b.String()) +} + +func statusMark(ok bool) string { + if ok { + return lipgloss.NewStyle().Foreground(colorOK).Render("✓") + } + return lipgloss.NewStyle().Foreground(accentRed).Render("✗") +} + +func providerTablePanel(r StatusReport, colW int) string { + if len(r.Providers) == 0 { + return "" + } + keyW := min(22, colW/3) + if keyW < 14 { + keyW = 14 + } + valW := colW - keyW - 3 + if valW < 12 { + valW = 12 + } + + var b strings.Builder + b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n") + for _, p := range r.Providers { + k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name) + v := styleProviderVal(p.Val).Width(valW).Render(p.Val) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v)) + b.WriteString("\n") + } + return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n")) +} + +func styleProviderVal(s string) lipgloss.Style { + if s == "✓" || strings.HasPrefix(s, "✓ ") { + return lipgloss.NewStyle().Foreground(colorOK) + } + if s == "not set" { + return mutedStyle() + } + return lipgloss.NewStyle() +} diff --git a/cmd/picoclaw/internal/cliui/version.go b/cmd/picoclaw/internal/cliui/version.go new file mode 100644 index 000000000..7ecbdae7f --- /dev/null +++ b/cmd/picoclaw/internal/cliui/version.go @@ -0,0 +1,61 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// PrintVersion prints version, optional build info, and Go toolchain line. +func PrintVersion(logo, versionLine string, build, goVer string) { + if !UseFancyLayout() { + fmt.Printf("%s %s\n", logo, versionLine) + if build != "" { + fmt.Printf(" Build: %s\n", build) + } + if goVer != "" { + fmt.Printf(" Go: %s\n", goVer) + } + return + } + + inner := InnerWidth() + box := borderStyle().Width(inner) + + if UseColumnLayout() { + leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right) + rightW := inner - 16 + rightStyle := kvValStyle().Width(rightW) + + rows := [][]string{ + {leftCol.Render("Version"), rightStyle.Render(versionLine)}, + } + if build != "" { + rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)}) + } + if goVer != "" { + rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)}) + } + var body strings.Builder + for _, r := range rows { + body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1])) + body.WriteString("\n") + } + header := titleBarStyle().Render(logo+" picoclaw") + "\n\n" + fmt.Println(box.Render(header + body.String())) + return + } + + var lines []string + lines = append(lines, titleBarStyle().Render(logo+" picoclaw")) + lines = append(lines, "") + lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine)) + if build != "" { + lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build)) + } + if goVer != "" { + lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer)) + } + fmt.Println(box.Render(strings.Join(lines, "\n"))) +} diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 626698fec..721d74552 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -9,6 +9,7 @@ import ( "golang.org/x/term" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/credential" ) @@ -79,29 +80,7 @@ func onboard(encrypt bool) { workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo) - fmt.Println("\nNext steps:") - if encrypt { - fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") - fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") - fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") - fmt.Println("") - fmt.Println(" 2. Add your API key to", configPath) - } else { - fmt.Println(" 1. Add your API key to", configPath) - } - fmt.Println("") - fmt.Println(" Recommended:") - fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") - fmt.Println(" - Ollama: https://ollama.com (local, free)") - fmt.Println("") - fmt.Println(" See README.md for 17+ supported providers.") - fmt.Println("") - if encrypt { - fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") - } else { - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") - } + cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath) } // promptPassphrase reads the encryption passphrase twice from the terminal diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go index 43c5786a8..e8e4fee9a 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -3,8 +3,10 @@ package status import ( "fmt" "os" + "strings" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -17,43 +19,125 @@ func statusCmd() { } configPath := internal.GetConfigPath() - - fmt.Printf("%s picoclaw Status\n", internal.Logo) - fmt.Printf("Version: %s\n", config.FormatVersion()) build, _ := config.FormatBuildInfo() - if build != "" { - fmt.Printf("Build: %s\n", build) - } - fmt.Println() - if _, err := os.Stat(configPath); err == nil { - fmt.Println("Config:", configPath, "✓") - } else { - fmt.Println("Config:", configPath, "✗") - } + _, configStatErr := os.Stat(configPath) + configOK := configStatErr == nil workspace := cfg.WorkspacePath() - if _, err := os.Stat(workspace); err == nil { - fmt.Println("Workspace:", workspace, "✓") - } else { - fmt.Println("Workspace:", workspace, "✗") + _, wsErr := os.Stat(workspace) + wsOK := wsErr == nil + + report := cliui.StatusReport{ + Logo: internal.Logo, + Version: config.FormatVersion(), + Build: build, + ConfigPath: configPath, + ConfigOK: configOK, + WorkspacePath: workspace, + WorkspaceOK: wsOK, + Model: cfg.Agents.Defaults.GetModelName(), } - if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName()) + if configOK { + // PicoClaw moved to a model-centric configuration (model_list). Status should + // not depend on a legacy cfg.Providers field (which may not exist under some + // build tags). We infer provider availability from model_list entries. + hasProtocolKey := func(protocol string) bool { + prefix := protocol + "/" + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" { + return true + } + } + return false + } + findLocalModelBase := func(modelName string) (string, bool) { + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if m.ModelName == modelName && m.APIBase != "" { + return m.APIBase, true + } + } + return "", false + } + findProtocolBase := func(protocol string) (string, bool) { + prefix := protocol + "/" + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" { + return m.APIBase, true + } + } + return "", false + } + + hasOpenRouter := hasProtocolKey("openrouter") + hasAnthropic := hasProtocolKey("anthropic") + hasOpenAI := hasProtocolKey("openai") + hasGemini := hasProtocolKey("gemini") + hasZhipu := hasProtocolKey("zhipu") + hasQwen := hasProtocolKey("qwen") + hasGroq := hasProtocolKey("groq") + hasMoonshot := hasProtocolKey("moonshot") + hasDeepSeek := hasProtocolKey("deepseek") + hasVolcEngine := hasProtocolKey("volcengine") + hasNvidia := hasProtocolKey("nvidia") + + // Local endpoints: allow both the special reserved name and protocol-based entries. + vllmBase, hasVLLM := findLocalModelBase("local-model") + if !hasVLLM { + vllmBase, hasVLLM = findProtocolBase("vllm") + } + ollamaBase, hasOllama := findProtocolBase("ollama") + + val := func(enabled bool, extra ...string) string { + if enabled { + if len(extra) > 0 && extra[0] != "" { + return "✓ " + extra[0] + } + return "✓" + } + return "not set" + } + + report.Providers = []cliui.ProviderRow{ + {Name: "OpenRouter API", Val: val(hasOpenRouter)}, + {Name: "Anthropic API", Val: val(hasAnthropic)}, + {Name: "OpenAI API", Val: val(hasOpenAI)}, + {Name: "Gemini API", Val: val(hasGemini)}, + {Name: "Zhipu API", Val: val(hasZhipu)}, + {Name: "Qwen API", Val: val(hasQwen)}, + {Name: "Groq API", Val: val(hasGroq)}, + {Name: "Moonshot API", Val: val(hasMoonshot)}, + {Name: "DeepSeek API", Val: val(hasDeepSeek)}, + {Name: "VolcEngine API", Val: val(hasVolcEngine)}, + {Name: "Nvidia API", Val: val(hasNvidia)}, + {Name: "vLLM / local", Val: val(hasVLLM, vllmBase)}, + {Name: "Ollama", Val: val(hasOllama, ollamaBase)}, + } store, _ := auth.LoadStore() if store != nil && len(store.Credentials) > 0 { - fmt.Println("\nOAuth/Token Auth:") for provider, cred := range store.Credentials { - status := "authenticated" + st := "authenticated" if cred.IsExpired() { - status = "expired" + st = "expired" } else if cred.NeedsRefresh() { - status = "needs refresh" + st = "needs refresh" } - fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status) + report.OAuthLines = append(report.OAuthLines, + fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st)) } } } + + cliui.PrintStatus(report) } diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go index 71c7dd2f8..81da4b878 100644 --- a/cmd/picoclaw/internal/version/command.go +++ b/cmd/picoclaw/internal/version/command.go @@ -1,11 +1,10 @@ package version import ( - "fmt" - "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/config" ) @@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command { } func printVersion() { - fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion()) build, goVer := config.FormatBuildInfo() - if build != "" { - fmt.Printf(" Build: %s\n", build) - } - if goVer != "" { - fmt.Printf(" Go: %s\n", goVer) - } + cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer) } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 543577e68..0867203a6 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -16,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" @@ -28,15 +29,57 @@ import ( "github.com/sipeed/picoclaw/pkg/updater" ) +var rootNoColor bool + +func syncCliUIColor(root *cobra.Command) { + no, _ := root.PersistentFlags().GetBool("no-color") + cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb") +} + +// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags. +func earlyColorDisabled() bool { + if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { + return true + } + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" { + return true + } + } + return false +} + func NewPicoclawCommand() *cobra.Command { - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion()) + short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo) + long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant. + +Version: %s`, internal.Logo, config.FormatVersion()) cmd := &cobra.Command{ - Use: "picoclaw", - Short: short, - Example: "picoclaw version", + Use: "picoclaw", + Short: short, + Long: long, + Example: `picoclaw version +picoclaw onboard +picoclaw --no-color status`, + SilenceErrors: true, + // Avoid plain UsageString() on stderr/stdout when a command fails; cliui + // renders matching panels on stderr instead. + SilenceUsage: true, + PersistentPreRun: func(c *cobra.Command, _ []string) { + syncCliUIColor(c.Root()) + }, } + cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false, + "Disable colors (boxed layout unchanged)") + + cmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + syncCliUIColor(c.Root()) + fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c)) + }) + cmd.AddCommand( onboard.NewOnboardCommand(), agent.NewAgentCommand(), @@ -65,17 +108,31 @@ const ( colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + "\033[0m\r\n" + plainBanner = "\r\n" + + "██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" + + "██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" + + "██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" + + "██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" + + "██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + + "\r\n" ) func main() { - fmt.Printf("%s", banner) + cliui.Init(earlyColorDisabled()) - tz_env := os.Getenv("TZ") - if tz_env != "" { - fmt.Println("TZ environment:", tz_env) - zoneinfo_env := os.Getenv("ZONEINFO") - fmt.Println("ZONEINFO environment:", zoneinfo_env) - loc, err := time.LoadLocation(tz_env) + if earlyColorDisabled() { + fmt.Print(plainBanner) + } else { + fmt.Printf("%s", banner) + } + + tzEnv := os.Getenv("TZ") + if tzEnv != "" { + fmt.Println("TZ environment:", tzEnv) + zoneinfoEnv := os.Getenv("ZONEINFO") + fmt.Println("ZONEINFO environment:", zoneinfoEnv) + loc, err := time.LoadLocation(tzEnv) if err != nil { fmt.Println("Error loading time zone:", err) } else { @@ -85,7 +142,10 @@ func main() { } cmd := NewPicoclawCommand() - if err := cmd.Execute(); err != nil { + last, err := cmd.ExecuteC() + if err != nil { + syncCliUIColor(cmd) + fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last)) os.Exit(1) } } diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 3e147cbfe..309e60ba9 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -3,6 +3,7 @@ package main import ( "fmt" "slices" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -17,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) { require.NotNil(t, cmd) - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion()) + short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo) + longHas := strings.Contains(cmd.Long, config.FormatVersion()) assert.Equal(t, "picoclaw", cmd.Use) assert.Equal(t, short, cmd.Short) + assert.True(t, longHas) assert.True(t, cmd.HasSubCommands()) assert.True(t, cmd.HasAvailableSubCommands()) - assert.False(t, cmd.HasFlags()) + assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil) assert.Nil(t, cmd.Run) assert.Nil(t, cmd.RunE) - assert.Nil(t, cmd.PersistentPreRun) + assert.NotNil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) allowedCommands := []string{ diff --git a/go.mod b/go.mod index 9eaa72a0b..b7259bde7 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 @@ -25,6 +26,7 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/minio/selfupdate v0.6.0 + github.com/muesli/termenv v0.16.0 github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/mymmrac/telego v1.8.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 @@ -35,6 +37,7 @@ require ( github.com/rs/zerolog v1.35.0 github.com/slack-go/slack v0.17.3 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 go.mau.fi/util v0.9.7 @@ -65,7 +68,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beeper/argo-go v1.1.2 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -79,6 +87,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect @@ -88,10 +97,10 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.mau.fi/libsignal v0.2.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect diff --git a/go.sum b/go.sum index 6a2194960..8306976c4 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -67,6 +69,16 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -179,12 +191,16 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow= @@ -218,6 +234,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -276,6 +293,8 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= From 681b2a258b727102f8d89e27ed58b051d363072e Mon Sep 17 00:00:00 2001 From: sky5454 Date: Sun, 12 Apr 2026 18:50:52 +0800 Subject: [PATCH 12/16] =?UTF-8?q?build:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20fix=20Android=20launcher=20flags,=20systray=20tag,?= =?UTF-8?q?=20rename=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 2 +- Makefile | 13 +++++-------- web/Makefile | 4 ++-- web/backend/systray.go | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03c7ce7d8..aab9cf874 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,7 +116,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | sudo apt-get install -y zip - make build-all-android + make build-android-bundle gh release upload "${{ inputs.tag }}" \ build/picoclaw-android-universal.zip \ --clobber diff --git a/Makefile b/Makefile index 1cc853458..beddd1138 100644 --- a/Makefile +++ b/Makefile @@ -216,15 +216,12 @@ build-android-arm64: generate build-launcher-android-arm64: @echo "Building picoclaw-launcher for android/arm64..." @mkdir -p $(BUILD_DIR) - @$(MAKE) -C web build \ - OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \ - WEB_GO='GOOS=android GOARCH=arm64 CGO_ENABLED=0 go' \ - GO_BUILD_TAGS='stdjson' \ - LDFLAGS='$(LDFLAGS)' + @$(MAKE) -C web build-android-arm64 \ + OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64" -## build-all-android: Build core and launcher for all Android architectures and package as universal zip -build-all-android: generate +## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip +build-android-bundle: generate @echo "Building core for all Android architectures..." @mkdir -p $(BUILD_DIR) GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR) @@ -260,7 +257,7 @@ build-all: generate GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR) GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR) - @$(MAKE) build-all-android + @$(MAKE) build-android-bundle @echo "All builds complete" ## install: Install picoclaw to system and copy builtin skills diff --git a/web/Makefile b/web/Makefile index 58b65621d..cf5ea774a 100644 --- a/web/Makefile +++ b/web/Makefile @@ -1,5 +1,5 @@ .PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \ - build-android-arm64 build-all-android + build-android-arm64 build-android-bundle # Go variables GO?=CGO_ENABLED=0 go @@ -99,7 +99,7 @@ build-android-arm64: build-frontend GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/ # Build launcher for all Android architectures -build-all-android: build-frontend +build-android-bundle: build-frontend @mkdir -p $(BUILD_DIR) GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/ @echo "All Android launcher builds complete" diff --git a/web/backend/systray.go b/web/backend/systray.go index 204bd7dc6..41fea1fbe 100644 --- a/web/backend/systray.go +++ b/web/backend/systray.go @@ -1,4 +1,4 @@ -//go:build (!darwin && !freebsd && !android) || cgo +//go:build !android && ((!darwin && !freebsd) || cgo) package main From 2b2bc26f8ea7aa29ae5d33146c389fe0ce536cbb Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:46:17 +0800 Subject: [PATCH 13/16] docs: fix Conventional Commits links in CONTRIBUTING files (#2494) - CONTRIBUTING.md: change link from zh-hans to en locale - CONTRIBUTING.zh.md: fix NBSP causing surrounding text to be absorbed into the link - Both files now use proper markdown link syntax --- CONTRIBUTING.md | 2 +- CONTRIBUTING.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ceff723d2..cbb6a6347 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider - Reference the related issue when relevant: `Fix session leak (#123)`. - Keep commits focused. One logical change per commit is preferred. - For minor cleanups or typo fixes, squash them into a single commit before opening a PR. -- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/ +- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) ### Keeping Up to Date diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md index 196aecc65..ca6c66b3d 100644 --- a/CONTRIBUTING.zh.md +++ b/CONTRIBUTING.zh.md @@ -108,7 +108,7 @@ git checkout -b 你的功能分支名 - 有关联 Issue 时请引用:`Fix session leak (#123)`。 - 保持 commit 专注,每个 commit 只做一件事。 - 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。 -- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写 +- 按照 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 规范来撰写 ### 保持与上游同步 From ea2107e8a939a07621a8866f0757e504d081cec0 Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 13 Apr 2026 11:23:55 +0800 Subject: [PATCH 14/16] build(release): split core builds from release-only artifacts - add a dedicated build-release-artifacts target for Android bundle packaging - switch CI and release workflows to Corepack-managed pnpm with cache support - pin the frontend pnpm version and make dependency installs deterministic - inject version metadata into launcher binaries in GoReleaser - update build documentation to reflect the new workflow --- .github/workflows/build.yml | 10 ++++++++++ .github/workflows/create_dmg.yml | 24 +++++++++++++++--------- .github/workflows/nightly.yml | 12 +++++++++--- .github/workflows/release.yml | 8 +++++--- .goreleaser.yaml | 16 +++++++++++----- Makefile | 20 ++++++++++++++------ README.fr.md | 18 ++++++++++++++++-- README.id.md | 17 ++++++++++++++++- README.it.md | 17 ++++++++++++++++- README.ja.md | 17 ++++++++++++++++- README.ko.md | 17 ++++++++++++++++- README.md | 25 +++++++++++++++++++++---- README.my.md | 17 ++++++++++++++++- README.pt-br.md | 17 ++++++++++++++++- README.vi.md | 21 ++++++++++++++++++--- README.zh.md | 18 ++++++++++++++++-- web/Makefile | 12 ++++++++---- web/frontend/package.json | 1 + 18 files changed, 240 insertions(+), 47 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b89b69ae..a7c066677 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,5 +16,15 @@ jobs: with: go-version-file: go.mod + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml + + - name: Setup pnpm + run: corepack enable && corepack install + - name: Build run: make build-all diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml index e03357566..a2221bb70 100644 --- a/.github/workflows/create_dmg.yml +++ b/.github/workflows/create_dmg.yml @@ -17,29 +17,35 @@ jobs: with: ref: main - # 1. 安装指定版本的 Go (可选,但推荐) + # 1. Install Go from go.mod - name: Setup Go uses: actions/setup-go@v6 with: go-version-file: go.mod - # 2. 安装 pnpm - - name: Install pnpm - run: brew install pnpm + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml - # 3. 运行你的 Makefile 编译二进制文件 + - name: Setup pnpm + run: corepack enable && corepack install + + # 3. Build the application bundle - name: Build with Make run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }} - # 4. 签名 + # 4. Apply ad-hoc signing - name: Ad-hoc Sign run: codesign --force --deep --sign - "build/PicoClaw Launcher.app" - # 5. 安装打包工具 + # 5. Install the DMG packaging tool - name: Install create-dmg run: brew install create-dmg - # 6. 执行打包命令 + # 6. Create the DMG - name: Create DMG run: | mkdir -p dist @@ -54,7 +60,7 @@ jobs: "dist/picoclaw-${{ matrix.arch }}.dmg" \ "build/PicoClaw Launcher.app" - # 7. 上传文件到 GitHub Artifacts (供你下载) + # 7. Upload the DMG as a GitHub artifact - name: Upload DMG uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a5002fec5..7e8c7111c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -51,9 +51,11 @@ jobs: uses: actions/setup-node@v6 with: node-version: 22 + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml - name: Setup pnpm - run: corepack enable && corepack prepare pnpm@latest --activate + run: corepack enable && corepack install - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -97,6 +99,11 @@ jobs: MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + - name: Build release-only artifacts + run: | + sudo apt-get install -y zip + make build-release-artifacts + - name: Update nightly release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -123,7 +130,7 @@ jobs: # Collect release artifacts from goreleaser dist/ ASSETS=() - for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do + for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt build/picoclaw-android-universal.zip; do [ -f "$f" ] && ASSETS+=("$f") done @@ -135,4 +142,3 @@ jobs: --prerelease \ --latest=false \ "${ASSETS[@]}" - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aab9cf874..8d7bc02ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,9 +69,11 @@ jobs: uses: actions/setup-node@v6 with: node-version: 22 + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml - name: Setup pnpm - run: corepack enable && corepack prepare pnpm@latest --activate + run: corepack enable && corepack install - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -110,13 +112,13 @@ jobs: MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - - name: Build and upload Android arm64 + - name: Build and upload release-only artifacts shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | sudo apt-get install -y zip - make build-android-bundle + make build-release-artifacts gh release upload "${{ inputs.tag }}" \ build/picoclaw-android-universal.zip \ --clobber diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9c26de34f..b20856110 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,11 +9,9 @@ git: before: hooks: - - go mod tidy - go generate ./... - - sh -c 'cd web/frontend && pnpm install && pnpm build:backend' - - go install github.com/tc-hib/go-winres@latest - - go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }} + - sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend' + - sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}' builds: - id: picoclaw @@ -27,7 +25,7 @@ builds: - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} - - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }} + - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }} goos: - linux - windows @@ -67,6 +65,10 @@ builds: - stdjson ldflags: - -s -w + - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} + - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} + - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} + - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }} goos: - linux - windows @@ -106,6 +108,10 @@ builds: - stdjson ldflags: - -s -w + - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} + - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} + - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} + - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }} goos: - linux - windows diff --git a/Makefile b/Makefile index beddd1138..717273efa 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build install uninstall clean help test +.PHONY: all build install uninstall clean help test build-core-all build-release-artifacts # Build variables BINARY_NAME=picoclaw @@ -217,7 +217,9 @@ build-launcher-android-arm64: @echo "Building picoclaw-launcher for android/arm64..." @mkdir -p $(BUILD_DIR) @$(MAKE) -C web build-android-arm64 \ - OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" + OUTPUT_ANDROID_ARM64="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \ + GO='$(GO)' \ + LDFLAGS='$(LDFLAGS)' @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64" ## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip @@ -240,8 +242,8 @@ build-android-bundle: generate build-pi-zero: build-linux-arm build-linux-arm64 @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" -## build-all: Build picoclaw for all platforms -build-all: generate +## build-core-all: Build the picoclaw core binary for all Makefile-managed platforms +build-core-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) @@ -257,8 +259,14 @@ build-all: generate GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR) GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR) - @$(MAKE) build-android-bundle - @echo "All builds complete" + @echo "Core builds complete" + +## build-all: Build the picoclaw core binary for all Makefile-managed platforms +build-all: build-core-all + +## build-release-artifacts: Build release-only artifacts that sit outside GoReleaser +build-release-artifacts: build-android-bundle + @echo "Release artifact builds complete" ## install: Install picoclaw to system and copy builtin skills install: build diff --git a/README.fr.md b/README.fr.md index 3b2552f6d..ecafefdc7 100644 --- a/README.fr.md +++ b/README.fr.md @@ -167,21 +167,32 @@ Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page ### Compiler depuis les sources (pour le développement) +Prérequis : + +- Go 1.25+ +- Node.js 22+ avec Corepack activé pour les builds Web UI / launcher + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps +# Installer le gestionnaire de paquets frontend déclaré par le dépôt +(cd web/frontend && corepack install) + # Compiler le binaire principal make build # Compiler le Web UI Launcher (requis pour le mode WebUI) make build-launcher -# Compiler pour plusieurs plateformes +# Compiler les binaires core pour toutes les plateformes gérées par le Makefile make build-all +# Compiler les artefacts de release empaquetés séparément des sorties principales de GoReleaser +make build-release-artifacts + # Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64) make build-pi-zero @@ -189,6 +200,10 @@ make build-pi-zero make install ``` +`make build-all` compile les binaires core de `picoclaw` pour toutes les plateformes gérées par le Makefile. + +`make build-release-artifacts` compile les artefacts de release empaquetés séparément des sorties principales de GoReleaser. + **Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32 bits -> `make build-linux-arm` ; 64 bits -> `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux. ## 🚀 Guide de démarrage rapide @@ -621,4 +636,3 @@ WeChat : WeChat group QR code - diff --git a/README.id.md b/README.id.md index 5aa7b58f5..f57d2f0bc 100644 --- a/README.id.md +++ b/README.id.md @@ -164,21 +164,32 @@ Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://gi ### Build dari source (untuk pengembangan) +Prasyarat: + +- Go 1.25+ +- Node.js 22+ dengan Corepack aktif untuk build Web UI / launcher + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps +# Instal package manager frontend yang dideklarasikan repo +(cd web/frontend && corepack install) + # Build binary inti make build # Build Web UI Launcher (diperlukan untuk mode WebUI) make build-launcher -# Build untuk berbagai platform +# Build binary inti untuk semua platform yang dikelola Makefile make build-all +# Build artefak rilis yang dikemas terpisah dari output utama GoReleaser +make build-release-artifacts + # Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -186,6 +197,10 @@ make build-pi-zero make install ``` +`make build-all` membangun binary inti `picoclaw` untuk semua platform yang dikelola Makefile. + +`make build-release-artifacts` membangun artefak rilis yang dikemas terpisah dari output utama GoReleaser. + **Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya. ## 🚀 Panduan Memulai Cepat diff --git a/README.it.md b/README.it.md index 57dd014b3..4c18f6f5b 100644 --- a/README.it.md +++ b/README.it.md @@ -164,21 +164,32 @@ In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [Gi ### Compila dai sorgenti (per lo sviluppo) +Prerequisiti: + +- Go 1.25+ +- Node.js 22+ con Corepack abilitato per le build Web UI / launcher + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps +# Installa il package manager frontend dichiarato dal repository +(cd web/frontend && corepack install) + # Compila il binario core make build # Compila il Web UI Launcher (necessario per la modalità WebUI) make build-launcher -# Compila per più piattaforme +# Compila i binari core per tutte le piattaforme gestite dal Makefile make build-all +# Compila gli artefatti di release impacchettati separatamente dagli output principali di GoReleaser +make build-release-artifacts + # Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -186,6 +197,10 @@ make build-pi-zero make install ``` +`make build-all` compila i binari core di `picoclaw` per tutte le piattaforme gestite dal Makefile. + +`make build-release-artifacts` compila gli artefatti di release impacchettati separatamente dagli output principali di GoReleaser. + **Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi. ## 🚀 Guida Rapida diff --git a/README.ja.md b/README.ja.md index 64bff9ee9..0ad159a53 100644 --- a/README.ja.md +++ b/README.ja.md @@ -164,21 +164,32 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます! ### ソースからビルド(開発用) +前提条件: + +- Go 1.25+ +- Web UI / launcher のビルドには Corepack を有効にした Node.js 22+ + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps +# リポジトリで宣言されたフロントエンド用パッケージマネージャーをインストール +(cd web/frontend && corepack install) + # コアバイナリをビルド make build # Web UI Launcher をビルド(WebUI モードに必要) make build-launcher -# 複数プラットフォーム向けビルド +# Makefile が管理するすべてのプラットフォーム向けにコアバイナリをビルド make build-all +# メインの GoReleaser 出力とは別にパッケージ化されるリリース専用成果物をビルド +make build-release-artifacts + # Raspberry Pi Zero 2 W 向けビルド(32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -186,6 +197,10 @@ make build-pi-zero make install ``` +`make build-all` は、Makefile が管理するすべてのプラットフォーム向けにコアの `picoclaw` バイナリをビルドします。 + +`make build-release-artifacts` は、メインの GoReleaser 出力とは別にパッケージ化されるリリース専用成果物をビルドします。 + **Raspberry Pi Zero 2 W:** OS に合ったバイナリを使用してください:32-bit Raspberry Pi OS → `make build-linux-arm`、64-bit → `make build-linux-arm64`。または `make build-pi-zero` で両方をビルド。 ## 🚀 クイックスタートガイド diff --git a/README.ko.md b/README.ko.md index 341c09812..5f99dd32e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -164,21 +164,32 @@ PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다! ### 소스에서 빌드(개발용) +필수 사항: + +- Go 1.25+ +- Web UI / launcher 빌드를 위한 Corepack 활성화된 Node.js 22+ + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps +# 저장소에 선언된 프런트엔드 패키지 매니저 설치 +(cd web/frontend && corepack install) + # 코어 바이너리 빌드 make build # WebUI 런처 빌드 (WebUI 모드에 필요) make build-launcher -# 여러 플랫폼용 빌드 +# Makefile이 관리하는 모든 플랫폼용 코어 바이너리 빌드 make build-all +# 메인 GoReleaser 출력과 별도로 패키징되는 릴리스 전용 산출물 빌드 +make build-release-artifacts + # Raspberry Pi Zero 2 W용 빌드 (32비트: make build-linux-arm, 64비트: make build-linux-arm64) make build-pi-zero @@ -186,6 +197,10 @@ make build-pi-zero make install ``` +`make build-all`은 Makefile이 관리하는 모든 플랫폼용 핵심 `picoclaw` 바이너리를 빌드합니다. + +`make build-release-artifacts`는 메인 GoReleaser 출력과 별도로 패키징되는 릴리스 전용 산출물을 빌드합니다. + **Raspberry Pi Zero 2 W:** OS에 맞는 바이너리를 사용하세요. 32비트 Raspberry Pi OS는 `make build-linux-arm`, 64비트는 `make build-linux-arm64`입니다. 또는 `make build-pi-zero`로 둘 다 빌드할 수 있습니다. ## 🚀 빠른 시작 가이드 diff --git a/README.md b/README.md index eb0d389d2..fd082f6bf 100644 --- a/README.md +++ b/README.md @@ -164,28 +164,45 @@ Alternatively, download the binary for your platform from the [GitHub Releases]( ### Build from source (for development) +Prerequisites: + +- Go 1.25+ +- Node.js 22+ with Corepack enabled for Web UI / launcher builds + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps -# Build core binary +# Install frontend package manager declared by the repo +(cd web/frontend && corepack install) + +# Build the core binary for the current platform make build -# Build Web UI Launcher (required for WebUI mode) +# Build the Web UI Launcher (required for WebUI mode) make build-launcher -# Build for multiple platforms +# Build core binaries for all Makefile-managed platforms make build-all -# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +# Build release-only artifacts packaged separately from the main GoReleaser outputs +make build-release-artifacts + +# Build for Raspberry Pi Zero 2 W +# 32-bit: make build-linux-arm +# 64-bit: make build-linux-arm64 make build-pi-zero # Build and install make install ``` +`make build-all` builds the core `picoclaw` binaries for all Makefile-managed platforms. + +`make build-release-artifacts` builds release-only artifacts that are packaged separately from the main GoReleaser outputs. + **Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Or run `make build-pi-zero` to build both. ## 🚀 Quick Start Guide diff --git a/README.my.md b/README.my.md index f8e602f83..a5719c696 100644 --- a/README.my.md +++ b/README.my.md @@ -165,20 +165,31 @@ Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://git ### Bina dari sumber (untuk pembangunan) +Prasyarat: + +- Go 1.25+ +- Node.js 22+ dengan Corepack diaktifkan untuk binaan Web UI / launcher + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps +# Pasang pengurus pakej frontend yang diisytiharkan oleh repositori +(cd web/frontend && corepack install) + # Bina binari teras make build # Bina Pelancar Web UI (diperlukan untuk mod WebUI) make build-launcher -# Bina untuk pelbagai platform +# Bina binari teras untuk semua platform yang diuruskan oleh Makefile make build-all +# Bina artifak keluaran yang dibungkus berasingan daripada output utama GoReleaser +make build-release-artifacts + # Bina untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -186,6 +197,10 @@ make build-pi-zero make install ``` +`make build-all` membina binari teras `picoclaw` untuk semua platform yang diuruskan oleh Makefile. + +`make build-release-artifacts` membina artifak keluaran yang dibungkus berasingan daripada output utama GoReleaser. + **Raspberry Pi Zero 2 W:** Gunakan binari yang sepadan dengan OS anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk membina kedua-duanya. ## 🚀 Panduan Permulaan Pantas diff --git a/README.pt-br.md b/README.pt-br.md index 65d23d1d1..d9b64c959 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -164,21 +164,32 @@ Alternativamente, baixe o binário para sua plataforma na página de [GitHub Rel ### Compilar a partir do código-fonte (para desenvolvimento) +Pré-requisitos: + +- Go 1.25+ +- Node.js 22+ com Corepack habilitado para builds do Web UI / launcher + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps +# Instalar o gerenciador de pacotes de frontend declarado pelo repositório +(cd web/frontend && corepack install) + # Compilar o binário principal make build # Compilar o Web UI Launcher (necessário para o modo WebUI) make build-launcher -# Compilar para múltiplas plataformas +# Compilar os binários core para todas as plataformas gerenciadas pelo Makefile make build-all +# Compilar os artefatos de release empacotados separadamente das saídas principais do GoReleaser +make build-release-artifacts + # Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -186,6 +197,10 @@ make build-pi-zero make install ``` +`make build-all` compila os binários core do `picoclaw` para todas as plataformas gerenciadas pelo Makefile. + +`make build-release-artifacts` compila os artefatos de release empacotados separadamente das saídas principais do GoReleaser. + **Raspberry Pi Zero 2 W:** Use o binário que corresponde ao seu SO: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos. ## 🚀 Guia de Início Rápido diff --git a/README.vi.md b/README.vi.md index 1d70d0615..3475830fb 100644 --- a/README.vi.md +++ b/README.vi.md @@ -164,21 +164,32 @@ Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases ### Xây dựng từ mã nguồn (để phát triển) +Yêu cầu: + +- Go 1.25+ +- Node.js 22+ với Corepack được bật cho các bản build Web UI / launcher + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps -# Build core binary +# Cài đặt trình quản lý gói frontend được khai báo bởi repo +(cd web/frontend && corepack install) + +# Build binary lõi make build -# Build Web UI Launcher (required for WebUI mode) +# Build Web UI Launcher (cần cho chế độ WebUI) make build-launcher -# Build for multiple platforms +# Build các binary lõi cho mọi nền tảng do Makefile quản lý make build-all +# Build các release artifact được đóng gói tách biệt với các đầu ra chính của GoReleaser +make build-release-artifacts + # Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -186,6 +197,10 @@ make build-pi-zero make install ``` +`make build-all` build các binary lõi `picoclaw` cho mọi nền tảng do Makefile quản lý. + +`make build-release-artifacts` build các release artifact được đóng gói tách biệt với các đầu ra chính của GoReleaser. + **Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành của bạn: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để xây dựng cả hai. ## 🚀 Hướng dẫn Khởi động Nhanh diff --git a/README.zh.md b/README.zh.md index e61ff7e28..ddb3bb230 100644 --- a/README.zh.md +++ b/README.zh.md @@ -164,21 +164,32 @@ PicoClaw 几乎可以部署在任何 Linux 设备上! ### 从源码构建(开发用) +前置要求: + +- Go 1.25+ +- Node.js 22+,并启用 Corepack(用于 Web UI / launcher 构建) + ```bash git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps +# 安装仓库声明的前端包管理器 +(cd web/frontend && corepack install) + # 构建核心二进制文件 make build # 构建 Web UI Launcher(WebUI 模式必需) make build-launcher -# 为多平台构建 +# 为 Makefile 管理的所有平台构建核心二进制文件 make build-all +# 构建独立于主 GoReleaser 输出之外的发布附加产物 +make build-release-artifacts + # 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64) make build-pi-zero @@ -186,6 +197,10 @@ make build-pi-zero make install ``` +`make build-all` 会为所有由 Makefile 管理的平台构建核心 `picoclaw` 二进制文件。 + +`make build-release-artifacts` 会构建独立于主 GoReleaser 输出之外打包的发布附加产物。 + **Raspberry Pi Zero 2 W:** 请使用与系统匹配的二进制文件:32 位 Raspberry Pi OS → `make build-linux-arm`;64 位 → `make build-linux-arm64`。或运行 `make build-pi-zero` 同时构建两者。 ## 🚀 快速开始 @@ -619,4 +634,3 @@ WeChat: - diff --git a/web/Makefile b/web/Makefile index cf5ea774a..4dca810e7 100644 --- a/web/Makefile +++ b/web/Makefile @@ -12,6 +12,7 @@ BUILD_DIR=build OUTPUT?=$(BUILD_DIR)/picoclaw-launcher OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64 FRONTEND_DIR=frontend +FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp BACKEND_DIR=backend BACKEND_DIST=$(BACKEND_DIR)/dist PICOCLAW_BINARY_NAME=picoclaw @@ -105,11 +106,14 @@ build-android-bundle: build-frontend @echo "All Android launcher builds complete" build-frontend: - @if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ - [ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \ - [ $(FRONTEND_DIR)/pnpm-lock.yaml -nt $(FRONTEND_DIR)/node_modules ]; then \ + @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \ + if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ + [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \ + [ ! -f $(FRONTEND_INSTALL_STAMP) ] || \ + [ "$$(cat $(FRONTEND_INSTALL_STAMP) 2>/dev/null)" != "$$expected_stamp" ]; then \ echo "Installing frontend dependencies..."; \ - cd $(FRONTEND_DIR) && pnpm install --frozen-lockfile; \ + (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \ + printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \ fi @echo "Building frontend..." @cd $(FRONTEND_DIR) && pnpm build:backend diff --git a/web/frontend/package.json b/web/frontend/package.json index 51e6f1dd9..40d5cf3d8 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@10.33.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, From d73a0e89b4780ca0cc7816e069e61beffd7f12aa Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 13 Apr 2026 11:52:35 +0800 Subject: [PATCH 15/16] build(release): move Android bundle publishing into GoReleaser - build the Android universal bundle from GoReleaser hooks - attach the bundle as a release asset - remove the separate post-release upload step - simplify Make targets around cross-platform builds --- .github/workflows/build.yml | 2 +- .github/workflows/nightly.yml | 9 ++++----- .github/workflows/release.yml | 15 ++++----------- .goreleaser.yaml | 3 +++ Makefile | 13 +++---------- README.fr.md | 8 -------- README.id.md | 7 ------- README.it.md | 7 ------- README.ja.md | 7 ------- README.ko.md | 7 ------- README.md | 13 +++---------- README.my.md | 7 ------- README.pt-br.md | 7 ------- README.vi.md | 7 ------- README.zh.md | 8 -------- 15 files changed, 18 insertions(+), 102 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7c066677..f21e3ef5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,5 +26,5 @@ jobs: - name: Setup pnpm run: corepack enable && corepack install - - name: Build + - name: Build core binaries run: make build-all diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7e8c7111c..f713c4db2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -77,6 +77,9 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Install zip + run: sudo apt-get install -y zip + - name: Create local tag for GoReleaser run: git tag "${{ steps.version.outputs.version }}" @@ -92,6 +95,7 @@ jobs: DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} + INCLUDE_ANDROID_BUNDLE: "true" NIGHTLY_BUILD: "true" MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} @@ -99,11 +103,6 @@ jobs: MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - - name: Build release-only artifacts - run: | - sudo apt-get install -y zip - make build-release-artifacts - - name: Update nightly release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d7bc02ad..41218032c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,9 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Install zip + run: sudo apt-get install -y zip + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: @@ -106,23 +109,13 @@ jobs: GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} + INCLUDE_ANDROID_BUNDLE: "true" MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - - name: Build and upload release-only artifacts - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - sudo apt-get install -y zip - make build-release-artifacts - gh release upload "${{ inputs.tag }}" \ - build/picoclaw-android-universal.zip \ - --clobber - - name: Apply release flags shell: bash env: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b20856110..d8c51b069 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -12,6 +12,7 @@ before: - go generate ./... - sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend' - sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}' + - sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi' builds: - id: picoclaw @@ -251,6 +252,8 @@ changelog: release: disable: '{{ isEnvSet "NIGHTLY_BUILD" }}' + extra_files: + - glob: ./build/picoclaw-android-universal.zip footer: >- --- diff --git a/Makefile b/Makefile index 717273efa..afaa7c29a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build install uninstall clean help test build-core-all build-release-artifacts +.PHONY: all build install uninstall clean help test build-all # Build variables BINARY_NAME=picoclaw @@ -242,8 +242,8 @@ build-android-bundle: generate build-pi-zero: build-linux-arm build-linux-arm64 @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" -## build-core-all: Build the picoclaw core binary for all Makefile-managed platforms -build-core-all: generate +## build-all: Build the picoclaw core binary for all Makefile-managed platforms +build-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) @@ -261,13 +261,6 @@ build-core-all: generate GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR) @echo "Core builds complete" -## build-all: Build the picoclaw core binary for all Makefile-managed platforms -build-all: build-core-all - -## build-release-artifacts: Build release-only artifacts that sit outside GoReleaser -build-release-artifacts: build-android-bundle - @echo "Release artifact builds complete" - ## install: Install picoclaw to system and copy builtin skills install: build @echo "Installing $(BINARY_NAME)..." diff --git a/README.fr.md b/README.fr.md index ecafefdc7..570365d00 100644 --- a/README.fr.md +++ b/README.fr.md @@ -190,9 +190,6 @@ make build-launcher # Compiler les binaires core pour toutes les plateformes gérées par le Makefile make build-all -# Compiler les artefacts de release empaquetés séparément des sorties principales de GoReleaser -make build-release-artifacts - # Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64) make build-pi-zero @@ -200,10 +197,6 @@ make build-pi-zero make install ``` -`make build-all` compile les binaires core de `picoclaw` pour toutes les plateformes gérées par le Makefile. - -`make build-release-artifacts` compile les artefacts de release empaquetés séparément des sorties principales de GoReleaser. - **Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32 bits -> `make build-linux-arm` ; 64 bits -> `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux. ## 🚀 Guide de démarrage rapide @@ -635,4 +628,3 @@ Discord : WeChat : WeChat group QR code - diff --git a/README.id.md b/README.id.md index f57d2f0bc..f4257f338 100644 --- a/README.id.md +++ b/README.id.md @@ -187,9 +187,6 @@ make build-launcher # Build binary inti untuk semua platform yang dikelola Makefile make build-all -# Build artefak rilis yang dikemas terpisah dari output utama GoReleaser -make build-release-artifacts - # Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -197,10 +194,6 @@ make build-pi-zero make install ``` -`make build-all` membangun binary inti `picoclaw` untuk semua platform yang dikelola Makefile. - -`make build-release-artifacts` membangun artefak rilis yang dikemas terpisah dari output utama GoReleaser. - **Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya. ## 🚀 Panduan Memulai Cepat diff --git a/README.it.md b/README.it.md index 4c18f6f5b..b559cda2e 100644 --- a/README.it.md +++ b/README.it.md @@ -187,9 +187,6 @@ make build-launcher # Compila i binari core per tutte le piattaforme gestite dal Makefile make build-all -# Compila gli artefatti di release impacchettati separatamente dagli output principali di GoReleaser -make build-release-artifacts - # Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -197,10 +194,6 @@ make build-pi-zero make install ``` -`make build-all` compila i binari core di `picoclaw` per tutte le piattaforme gestite dal Makefile. - -`make build-release-artifacts` compila gli artefatti di release impacchettati separatamente dagli output principali di GoReleaser. - **Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi. ## 🚀 Guida Rapida diff --git a/README.ja.md b/README.ja.md index 0ad159a53..0e6483be6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -187,9 +187,6 @@ make build-launcher # Makefile が管理するすべてのプラットフォーム向けにコアバイナリをビルド make build-all -# メインの GoReleaser 出力とは別にパッケージ化されるリリース専用成果物をビルド -make build-release-artifacts - # Raspberry Pi Zero 2 W 向けビルド(32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -197,10 +194,6 @@ make build-pi-zero make install ``` -`make build-all` は、Makefile が管理するすべてのプラットフォーム向けにコアの `picoclaw` バイナリをビルドします。 - -`make build-release-artifacts` は、メインの GoReleaser 出力とは別にパッケージ化されるリリース専用成果物をビルドします。 - **Raspberry Pi Zero 2 W:** OS に合ったバイナリを使用してください:32-bit Raspberry Pi OS → `make build-linux-arm`、64-bit → `make build-linux-arm64`。または `make build-pi-zero` で両方をビルド。 ## 🚀 クイックスタートガイド diff --git a/README.ko.md b/README.ko.md index 5f99dd32e..e520ffd29 100644 --- a/README.ko.md +++ b/README.ko.md @@ -187,9 +187,6 @@ make build-launcher # Makefile이 관리하는 모든 플랫폼용 코어 바이너리 빌드 make build-all -# 메인 GoReleaser 출력과 별도로 패키징되는 릴리스 전용 산출물 빌드 -make build-release-artifacts - # Raspberry Pi Zero 2 W용 빌드 (32비트: make build-linux-arm, 64비트: make build-linux-arm64) make build-pi-zero @@ -197,10 +194,6 @@ make build-pi-zero make install ``` -`make build-all`은 Makefile이 관리하는 모든 플랫폼용 핵심 `picoclaw` 바이너리를 빌드합니다. - -`make build-release-artifacts`는 메인 GoReleaser 출력과 별도로 패키징되는 릴리스 전용 산출물을 빌드합니다. - **Raspberry Pi Zero 2 W:** OS에 맞는 바이너리를 사용하세요. 32비트 Raspberry Pi OS는 `make build-linux-arm`, 64비트는 `make build-linux-arm64`입니다. 또는 `make build-pi-zero`로 둘 다 빌드할 수 있습니다. ## 🚀 빠른 시작 가이드 diff --git a/README.md b/README.md index fd082f6bf..bbe48061a 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,6 @@ make build-launcher # Build core binaries for all Makefile-managed platforms make build-all -# Build release-only artifacts packaged separately from the main GoReleaser outputs -make build-release-artifacts - # Build for Raspberry Pi Zero 2 W # 32-bit: make build-linux-arm # 64-bit: make build-linux-arm64 @@ -199,10 +196,6 @@ make build-pi-zero make install ``` -`make build-all` builds the core `picoclaw` binaries for all Makefile-managed platforms. - -`make build-release-artifacts` builds release-only artifacts that are packaged separately from the main GoReleaser outputs. - **Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Or run `make build-pi-zero` to build both. ## 🚀 Quick Start Guide @@ -232,7 +225,7 @@ picoclaw-launcher WebUI Launcher

-**Getting started:** +**Getting started:** Open the WebUI, then: **1)** Configure a Provider (add your LLM API key) -> **2)** Configure a Channel (e.g., Telegram) -> **3)** Start the Gateway -> **4)** Chat! @@ -310,7 +303,7 @@ picoclaw-launcher-tui TUI Launcher

-**Getting started:** +**Getting started:** Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat! @@ -385,7 +378,7 @@ This creates `~/.picoclaw/config.json` and the workspace directory. ``` > See `config/config.example.json` in the repo for a complete configuration template with all available options. -> +> > Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details. diff --git a/README.my.md b/README.my.md index a5719c696..255773263 100644 --- a/README.my.md +++ b/README.my.md @@ -187,9 +187,6 @@ make build-launcher # Bina binari teras untuk semua platform yang diuruskan oleh Makefile make build-all -# Bina artifak keluaran yang dibungkus berasingan daripada output utama GoReleaser -make build-release-artifacts - # Bina untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -197,10 +194,6 @@ make build-pi-zero make install ``` -`make build-all` membina binari teras `picoclaw` untuk semua platform yang diuruskan oleh Makefile. - -`make build-release-artifacts` membina artifak keluaran yang dibungkus berasingan daripada output utama GoReleaser. - **Raspberry Pi Zero 2 W:** Gunakan binari yang sepadan dengan OS anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk membina kedua-duanya. ## 🚀 Panduan Permulaan Pantas diff --git a/README.pt-br.md b/README.pt-br.md index d9b64c959..36d65d8c4 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -187,9 +187,6 @@ make build-launcher # Compilar os binários core para todas as plataformas gerenciadas pelo Makefile make build-all -# Compilar os artefatos de release empacotados separadamente das saídas principais do GoReleaser -make build-release-artifacts - # Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -197,10 +194,6 @@ make build-pi-zero make install ``` -`make build-all` compila os binários core do `picoclaw` para todas as plataformas gerenciadas pelo Makefile. - -`make build-release-artifacts` compila os artefatos de release empacotados separadamente das saídas principais do GoReleaser. - **Raspberry Pi Zero 2 W:** Use o binário que corresponde ao seu SO: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos. ## 🚀 Guia de Início Rápido diff --git a/README.vi.md b/README.vi.md index 3475830fb..67845d073 100644 --- a/README.vi.md +++ b/README.vi.md @@ -187,9 +187,6 @@ make build-launcher # Build các binary lõi cho mọi nền tảng do Makefile quản lý make build-all -# Build các release artifact được đóng gói tách biệt với các đầu ra chính của GoReleaser -make build-release-artifacts - # Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) make build-pi-zero @@ -197,10 +194,6 @@ make build-pi-zero make install ``` -`make build-all` build các binary lõi `picoclaw` cho mọi nền tảng do Makefile quản lý. - -`make build-release-artifacts` build các release artifact được đóng gói tách biệt với các đầu ra chính của GoReleaser. - **Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành của bạn: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để xây dựng cả hai. ## 🚀 Hướng dẫn Khởi động Nhanh diff --git a/README.zh.md b/README.zh.md index ddb3bb230..329fedb86 100644 --- a/README.zh.md +++ b/README.zh.md @@ -187,9 +187,6 @@ make build-launcher # 为 Makefile 管理的所有平台构建核心二进制文件 make build-all -# 构建独立于主 GoReleaser 输出之外的发布附加产物 -make build-release-artifacts - # 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64) make build-pi-zero @@ -197,10 +194,6 @@ make build-pi-zero make install ``` -`make build-all` 会为所有由 Makefile 管理的平台构建核心 `picoclaw` 二进制文件。 - -`make build-release-artifacts` 会构建独立于主 GoReleaser 输出之外打包的发布附加产物。 - **Raspberry Pi Zero 2 W:** 请使用与系统匹配的二进制文件:32 位 Raspberry Pi OS → `make build-linux-arm`;64 位 → `make build-linux-arm64`。或运行 `make build-pi-zero` 同时构建两者。 ## 🚀 快速开始 @@ -633,4 +626,3 @@ WeChat: WeChat group QR code - From 6a870cb2601828c95bec790a752947119708028b Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 13 Apr 2026 11:56:43 +0800 Subject: [PATCH 16/16] ci(build): remove unused Node.js and pnpm setup from core build workflow --- .github/workflows/build.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f21e3ef5f..def19c3e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,15 +16,5 @@ jobs: with: go-version-file: go.mod - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: web/frontend/pnpm-lock.yaml - - - name: Setup pnpm - run: corepack enable && corepack install - - name: Build core binaries run: make build-all