feat: add PicoWatch menu bar app — trial monitoring + WhatsApp status counter
macOS menu bar app that monitors the PicoClaw WhatsApp agent: - Detects trial users from session JSONL files (STATUS: TRIAL) - Sends macOS notifications on new trial conversations - Updates WhatsApp Status with daily trial counter (dd/MM: N) - Weekly report with trial stats persisted to picowatch_stats.json - Resolves LID to real phone number from check-access.sh output Gateway changes: - POST /api/send-status endpoint for WhatsApp Status broadcast - SendStatus() on WhatsAppNativeChannel (black bg, white bold text) - HandleFunc() on Manager to register custom HTTP handlers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e96f6d5ea2
commit
ef982dd9b2
9 changed files with 1394 additions and 1 deletions
|
|
@ -1034,6 +1034,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
|
|||
return channel, ok
|
||||
}
|
||||
|
||||
// HandleFunc registers a custom HTTP handler on the shared gateway mux.
|
||||
func (m *Manager) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
|
||||
if m.mux != nil {
|
||||
m.mux.HandleFunc(pattern, handler)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetStatus() map[string]any {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
|
|
|||
|
|
@ -483,6 +483,32 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
// SendStatus sends a text message to the WhatsApp Status broadcast (status@broadcast).
|
||||
func (c *WhatsAppNativeChannel) SendStatus(ctx context.Context, text string) error {
|
||||
c.mu.Lock()
|
||||
client := c.client
|
||||
c.mu.Unlock()
|
||||
if client == nil || !client.IsConnected() {
|
||||
return fmt.Errorf("whatsapp not connected")
|
||||
}
|
||||
if client.Store.ID == nil {
|
||||
return fmt.Errorf("whatsapp not paired")
|
||||
}
|
||||
black := uint32(0xFF000000)
|
||||
white := uint32(0xFFFFFFFF)
|
||||
font := waE2E.ExtendedTextMessage_SYSTEM_BOLD
|
||||
msg := &waE2E.Message{
|
||||
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
|
||||
Text: proto.String(text),
|
||||
BackgroundArgb: &black,
|
||||
TextArgb: &white,
|
||||
Font: &font,
|
||||
},
|
||||
}
|
||||
_, err := client.SendMessage(ctx, types.StatusBroadcastJID, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendMedia implements the channels.MediaSender interface.
|
||||
func (c *WhatsAppNativeChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
if !c.IsRunning() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package gateway
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
|
|
@ -32,7 +34,7 @@ import (
|
|||
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
||||
whatsappnative "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
"github.com/sipeed/picoclaw/pkg/devices"
|
||||
|
|
@ -382,6 +384,37 @@ func setupAndStartServices(
|
|||
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken)
|
||||
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
||||
|
||||
// API: send text to WhatsApp Status broadcast
|
||||
runningServices.ChannelManager.HandleFunc("/api/send-status", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" {
|
||||
http.Error(w, "bad request: text required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ch, ok := runningServices.ChannelManager.GetChannel("whatsapp_native")
|
||||
if !ok {
|
||||
http.Error(w, "whatsapp not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
waCh, ok := ch.(*whatsappnative.WhatsAppNativeChannel)
|
||||
if !ok {
|
||||
http.Error(w, "whatsapp channel type mismatch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := waCh.SendStatus(r.Context(), body.Text); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("error starting channels: %w", err)
|
||||
}
|
||||
|
|
|
|||
89
tools/PicoWatch/PicoWatch/AppDelegate.swift
Normal file
89
tools/PicoWatch/PicoWatch/AppDelegate.swift
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var popover: NSPopover!
|
||||
private var engine: MonitorEngine!
|
||||
private var eventMonitor: Any?
|
||||
private var pulseTimer: Timer?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
|
||||
if let button = statusItem.button {
|
||||
button.image = NSImage(systemSymbolName: "brain.head.profile",
|
||||
accessibilityDescription: "PicoWatch")
|
||||
button.action = #selector(togglePopover)
|
||||
button.target = self
|
||||
}
|
||||
|
||||
engine = MonitorEngine()
|
||||
|
||||
popover = NSPopover()
|
||||
popover.contentSize = NSSize(width: 380, height: 720)
|
||||
popover.behavior = .transient
|
||||
popover.contentViewController = NSHostingController(
|
||||
rootView: PopoverView(engine: engine)
|
||||
)
|
||||
|
||||
NSUserNotificationCenter.default.delegate = self
|
||||
|
||||
engine.start()
|
||||
|
||||
// Pulse the status bar icon to show activity
|
||||
pulseTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.updateStatusIcon()
|
||||
}
|
||||
}
|
||||
|
||||
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
||||
if let popover = self?.popover, popover.isShown {
|
||||
popover.performClose(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatusIcon() {
|
||||
guard let button = statusItem.button else { return }
|
||||
let symbolName: String
|
||||
if engine.gatewayUp {
|
||||
symbolName = engine.recentSkillActivity ? "brain.head.profile.fill" : "brain.head.profile"
|
||||
} else {
|
||||
symbolName = "brain.head.profile"
|
||||
}
|
||||
button.image = NSImage(systemSymbolName: symbolName,
|
||||
accessibilityDescription: "PicoWatch")
|
||||
// Show skill count as badge
|
||||
let skillCount = engine.totalSkills
|
||||
button.title = skillCount > 0 ? " \(skillCount)" : ""
|
||||
}
|
||||
|
||||
@objc private func togglePopover() {
|
||||
if let button = statusItem.button {
|
||||
if popover.isShown {
|
||||
popover.performClose(nil)
|
||||
} else {
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
pulseTimer?.invalidate()
|
||||
engine.stop()
|
||||
if let monitor = eventMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate: NSUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(_ center: NSUserNotificationCenter,
|
||||
shouldPresent notification: NSUserNotification) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
24
tools/PicoWatch/PicoWatch/Info.plist
Normal file
24
tools/PicoWatch/PicoWatch/Info.plist
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PicoWatch</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>PicoWatch</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.picoclaw.PicoWatch</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>PicoWatch</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
683
tools/PicoWatch/PicoWatch/MonitorEngine.swift
Normal file
683
tools/PicoWatch/PicoWatch/MonitorEngine.swift
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Event types
|
||||
|
||||
struct SkillEvent: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let timestamp: Date
|
||||
let operation: String // create, update, patch, delete, block, tool_call
|
||||
let skillName: String
|
||||
let detail: String
|
||||
|
||||
init(operation: String, skillName: String, detail: String = "") {
|
||||
self.id = UUID()
|
||||
self.timestamp = Date()
|
||||
self.operation = operation
|
||||
self.skillName = skillName
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
struct DailyStats: Codable, Identifiable {
|
||||
let date: String // yyyy-MM-dd
|
||||
var skillsCreated: Int = 0
|
||||
var skillsPatched: Int = 0
|
||||
var skillsDeleted: Int = 0
|
||||
var blockedAttempts: Int = 0
|
||||
var totalConversations: Int = 0
|
||||
var toolCalls: Int = 0
|
||||
var skillManageCalls: Int = 0
|
||||
var statusViews: Int = 0
|
||||
var trialStarts: Int = 0
|
||||
|
||||
var id: String { date }
|
||||
|
||||
var totalSkillOps: Int { skillsCreated + skillsPatched + skillsDeleted }
|
||||
}
|
||||
|
||||
struct StatusInteraction: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let timestamp: Date
|
||||
let phone: String
|
||||
let content: String
|
||||
let crmStatus: String // active, inactive, trial, trial_expired, ignored, unknown
|
||||
let contactName: String
|
||||
|
||||
init(phone: String, content: String, crmStatus: String = "unknown", contactName: String = "") {
|
||||
self.id = UUID()
|
||||
self.timestamp = Date()
|
||||
self.phone = phone
|
||||
self.content = content
|
||||
self.crmStatus = crmStatus
|
||||
self.contactName = contactName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Engine
|
||||
|
||||
@MainActor
|
||||
final class MonitorEngine: ObservableObject {
|
||||
// Live state
|
||||
@Published var gatewayUp = false
|
||||
@Published var gatewayUptime = ""
|
||||
@Published var totalSkills = 0
|
||||
@Published var skillsList: [SkillInfo] = []
|
||||
@Published var recentEvents: [SkillEvent] = []
|
||||
@Published var recentSkillActivity = false
|
||||
@Published var weeklyStats: [DailyStats] = []
|
||||
@Published var todayStats = DailyStats(date: MonitorEngine.todayString())
|
||||
@Published var lastMessage = ""
|
||||
@Published var activeSessionCount = 0
|
||||
@Published var statusInteractions: [StatusInteraction] = []
|
||||
@Published var trialInteractions: [StatusInteraction] = []
|
||||
|
||||
struct SkillInfo: Identifiable {
|
||||
let name: String
|
||||
let description: String
|
||||
let modified: Date
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
private let picoHome = NSHomeDirectory() + "/.picoclaw"
|
||||
private let skillsDir: String
|
||||
private let sessionsDir: String
|
||||
private let logPath: String
|
||||
private let statsPath: String
|
||||
|
||||
// Session file tracking
|
||||
private struct TrackedSession {
|
||||
let path: String
|
||||
var offset: UInt64
|
||||
let sessionId: String
|
||||
}
|
||||
private var trackedSessions: [String: TrackedSession] = [:]
|
||||
|
||||
// Status broadcast parsing state
|
||||
private var pendingStatusContent: String = ""
|
||||
private var pendingStatusPhone: String = ""
|
||||
|
||||
// Trial detection: sessionId -> phone from check-access.sh
|
||||
private var pendingSessionPhone: [String: String] = [:]
|
||||
// Sessions already counted as trial (dedup)
|
||||
private var countedTrialSessions: Set<String> = []
|
||||
|
||||
private struct CRMContact {
|
||||
let name: String
|
||||
let status: String
|
||||
}
|
||||
private var crmCache: [String: CRMContact] = [:]
|
||||
|
||||
// Log file tracking
|
||||
private var logOffset: UInt64 = 0
|
||||
|
||||
private var healthTimer: Timer?
|
||||
private var sessionTimer: Timer?
|
||||
private var sessionScanTimer: Timer?
|
||||
private var skillsTimer: Timer?
|
||||
private var statsTimer: Timer?
|
||||
private var dirWatcher: DispatchSourceFileSystemObject?
|
||||
|
||||
init() {
|
||||
skillsDir = picoHome + "/workspace/skills"
|
||||
sessionsDir = picoHome + "/workspace/sessions"
|
||||
logPath = picoHome + "/logs/gateway.log"
|
||||
statsPath = picoHome + "/logs/picowatch_stats.json"
|
||||
}
|
||||
|
||||
func start() {
|
||||
loadStats()
|
||||
loadCRM()
|
||||
scanSkills()
|
||||
discoverSessions()
|
||||
seekLogEnd()
|
||||
|
||||
processAllSessions(initialScan: true)
|
||||
|
||||
healthTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.checkHealth() }
|
||||
}
|
||||
Task { checkHealth() }
|
||||
|
||||
sessionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.processAllSessions(initialScan: false) }
|
||||
}
|
||||
|
||||
sessionScanTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.discoverSessions() }
|
||||
}
|
||||
|
||||
skillsTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.scanSkills() }
|
||||
}
|
||||
|
||||
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.tailLog() }
|
||||
}
|
||||
|
||||
statsTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.saveStats() }
|
||||
}
|
||||
|
||||
Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.loadCRM() }
|
||||
}
|
||||
|
||||
watchSkillsDir()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
healthTimer?.invalidate()
|
||||
sessionTimer?.invalidate()
|
||||
sessionScanTimer?.invalidate()
|
||||
skillsTimer?.invalidate()
|
||||
statsTimer?.invalidate()
|
||||
dirWatcher?.cancel()
|
||||
saveStats()
|
||||
}
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
private func checkHealth() {
|
||||
let url = URL(string: "http://127.0.0.1:18790/health")!
|
||||
let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if let data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let status = json["status"] as? String, status == "ok" {
|
||||
self.gatewayUp = true
|
||||
self.gatewayUptime = (json["uptime"] as? String) ?? ""
|
||||
} else {
|
||||
self.gatewayUp = false
|
||||
self.gatewayUptime = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
// MARK: - Session files (JSONL)
|
||||
|
||||
private func discoverSessions() {
|
||||
let fm = FileManager.default
|
||||
guard let files = try? fm.contentsOfDirectory(atPath: sessionsDir) else { return }
|
||||
|
||||
for file in files where file.hasSuffix(".jsonl") {
|
||||
let path = sessionsDir + "/" + file
|
||||
guard trackedSessions[path] == nil else { continue }
|
||||
let sessionId = file.replacingOccurrences(of: ".jsonl", with: "")
|
||||
trackedSessions[path] = TrackedSession(path: path, offset: 0, sessionId: sessionId)
|
||||
}
|
||||
|
||||
activeSessionCount = trackedSessions.count
|
||||
}
|
||||
|
||||
private func processAllSessions(initialScan: Bool) {
|
||||
let fm = FileManager.default
|
||||
|
||||
for (path, session) in trackedSessions {
|
||||
guard let attrs = try? fm.attributesOfItem(atPath: path),
|
||||
let size = (attrs[.size] as? NSNumber)?.uint64Value else { continue }
|
||||
|
||||
if size <= session.offset { continue }
|
||||
|
||||
var offset = session.offset
|
||||
if initialScan && offset == 0 && size > 20_000 {
|
||||
offset = size - 20_000
|
||||
}
|
||||
|
||||
guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { continue }
|
||||
defer { try? handle.close() }
|
||||
|
||||
do { try handle.seek(toOffset: offset) } catch { continue }
|
||||
|
||||
let data = handle.readDataToEndOfFile()
|
||||
guard let text = String(data: data, encoding: .utf8) else { continue }
|
||||
|
||||
let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||
let startIdx = (initialScan && offset > 0) ? 1 : 0
|
||||
|
||||
for i in startIdx..<lines.count {
|
||||
let line = lines[i].trimmingCharacters(in: .whitespaces)
|
||||
if line.isEmpty { continue }
|
||||
processSessionLine(line, sessionId: session.sessionId, isInitial: initialScan)
|
||||
}
|
||||
|
||||
var updated = session
|
||||
updated.offset = size
|
||||
trackedSessions[path] = updated
|
||||
}
|
||||
}
|
||||
|
||||
private func processSessionLine(_ line: String, sessionId: String, isInitial: Bool) {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||||
|
||||
let role = json["role"] as? String ?? ""
|
||||
|
||||
// Route status@broadcast to dedicated handler
|
||||
if sessionId.contains("status@broadcast") {
|
||||
processStatusLine(json, role: role, isInitial: isInitial)
|
||||
return
|
||||
}
|
||||
|
||||
if role == "user" {
|
||||
todayStats.totalConversations += 1
|
||||
}
|
||||
|
||||
if role == "assistant" {
|
||||
if let content = json["content"] as? String, !content.isEmpty {
|
||||
let clean = content.replacingOccurrences(of: "<|[SPLIT]|>", with: " ")
|
||||
lastMessage = String(clean.prefix(100))
|
||||
}
|
||||
}
|
||||
|
||||
// Detect tool_calls
|
||||
if let toolCalls = json["tool_calls"] as? [[String: Any]] {
|
||||
for tc in toolCalls {
|
||||
guard let function = tc["function"] as? [String: Any],
|
||||
let fnName = function["name"] as? String else { continue }
|
||||
|
||||
todayStats.toolCalls += 1
|
||||
|
||||
// Track check-access.sh for trial detection
|
||||
if fnName == "exec",
|
||||
let argsStr = function["arguments"] as? String,
|
||||
argsStr.contains("check-access.sh") {
|
||||
if let range = argsStr.range(of: "check-access.sh ") {
|
||||
let raw = String(argsStr[range.upperBound...])
|
||||
.replacingOccurrences(of: "\"", with: "")
|
||||
.replacingOccurrences(of: "}", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let digits = raw.unicodeScalars
|
||||
.filter { CharacterSet.decimalDigits.contains($0) }
|
||||
.map { String($0) }.joined()
|
||||
if digits.count >= 10 {
|
||||
pendingSessionPhone[sessionId] = "+" + digits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fnName == "skill_manage" {
|
||||
todayStats.skillManageCalls += 1
|
||||
|
||||
if let argsStr = function["arguments"] as? String,
|
||||
let argsData = argsStr.data(using: .utf8),
|
||||
let args = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] {
|
||||
|
||||
let op = args["operation"] as? String ?? "unknown"
|
||||
let name = args["name"] as? String ?? "?"
|
||||
|
||||
recentSkillActivity = true
|
||||
switch op {
|
||||
case "create":
|
||||
todayStats.skillsCreated += 1
|
||||
addEvent(SkillEvent(operation: "create", skillName: name))
|
||||
case "patch":
|
||||
todayStats.skillsPatched += 1
|
||||
addEvent(SkillEvent(operation: "patch", skillName: name))
|
||||
case "update":
|
||||
todayStats.skillsPatched += 1
|
||||
addEvent(SkillEvent(operation: "update", skillName: name))
|
||||
case "delete":
|
||||
todayStats.skillsDeleted += 1
|
||||
addEvent(SkillEvent(operation: "delete", skillName: name))
|
||||
case "list":
|
||||
addEvent(SkillEvent(operation: "list", skillName: "all",
|
||||
detail: "Agente listou skills"))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if !isInitial {
|
||||
addEvent(SkillEvent(operation: "tool_call", skillName: fnName,
|
||||
detail: "via \(sessionId.prefix(20))"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect trial starts from check-access.sh tool responses
|
||||
if role == "tool", let phone = pendingSessionPhone[sessionId] {
|
||||
let output = (json["content"] as? String) ?? ""
|
||||
if output.contains("STATUS: TRIAL") && !output.contains("TRIAL_EXPIRED") {
|
||||
// Dedup: only count once per session
|
||||
if !countedTrialSessions.contains(sessionId) {
|
||||
countedTrialSessions.insert(sessionId)
|
||||
// Extract real phone from TELEFONE: +XXXX in tool output
|
||||
var realPhone = phone
|
||||
if let telRange = output.range(of: "TELEFONE: ") {
|
||||
let after = String(output[telRange.upperBound...])
|
||||
let phoneLine = after.components(separatedBy: .newlines).first ?? ""
|
||||
let cleaned = phoneLine.trimmingCharacters(in: .whitespaces)
|
||||
if !cleaned.isEmpty { realPhone = cleaned }
|
||||
}
|
||||
let contact = crmCache[realPhone]
|
||||
let name = contact?.name ?? ""
|
||||
let interaction = StatusInteraction(
|
||||
phone: realPhone,
|
||||
content: "Conversa de teste iniciada",
|
||||
crmStatus: "trial",
|
||||
contactName: name
|
||||
)
|
||||
trialInteractions.insert(interaction, at: 0)
|
||||
if trialInteractions.count > 30 {
|
||||
trialInteractions = Array(trialInteractions.prefix(30))
|
||||
}
|
||||
todayStats.trialStarts += 1
|
||||
if !isInitial {
|
||||
sendTrialNotification(phone: realPhone, name: name)
|
||||
}
|
||||
}
|
||||
pendingSessionPhone.removeValue(forKey: sessionId)
|
||||
} else if output.contains("STATUS:") {
|
||||
pendingSessionPhone.removeValue(forKey: sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status broadcast
|
||||
|
||||
private func processStatusLine(_ json: [String: Any], role: String, isInitial: Bool) {
|
||||
if role == "user" {
|
||||
pendingStatusContent = (json["content"] as? String) ?? ""
|
||||
pendingStatusPhone = ""
|
||||
}
|
||||
|
||||
if role == "assistant", let toolCalls = json["tool_calls"] as? [[String: Any]] {
|
||||
for tc in toolCalls {
|
||||
guard let function = tc["function"] as? [String: Any],
|
||||
let argsStr = function["arguments"] as? String,
|
||||
argsStr.contains("check-access.sh") else { continue }
|
||||
|
||||
if let range = argsStr.range(of: "check-access.sh ") {
|
||||
let raw = String(argsStr[range.upperBound...])
|
||||
.replacingOccurrences(of: "\"", with: "")
|
||||
.replacingOccurrences(of: "}", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let digits = raw.unicodeScalars
|
||||
.filter { CharacterSet.decimalDigits.contains($0) }
|
||||
.map { String($0) }.joined()
|
||||
if digits.count >= 10 {
|
||||
pendingStatusPhone = "+" + digits
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if role == "tool", !pendingStatusPhone.isEmpty {
|
||||
let output = (json["content"] as? String) ?? ""
|
||||
|
||||
var crmStatus = "unknown"
|
||||
if output.contains("STATUS: ACTIVE") { crmStatus = "active" }
|
||||
else if output.contains("STATUS: TRIAL_EXPIRED") { crmStatus = "trial_expired" }
|
||||
else if output.contains("STATUS: TRIAL") { crmStatus = "trial" }
|
||||
else if output.contains("STATUS: INACTIVE") { crmStatus = "inactive" }
|
||||
else if output.contains("STATUS: IGNORED") { crmStatus = "ignored" }
|
||||
|
||||
let contact = crmCache[pendingStatusPhone]
|
||||
let name = contact?.name ?? ""
|
||||
let finalStatus = contact?.status ?? crmStatus
|
||||
|
||||
let interaction = StatusInteraction(
|
||||
phone: pendingStatusPhone,
|
||||
content: pendingStatusContent,
|
||||
crmStatus: finalStatus,
|
||||
contactName: name
|
||||
)
|
||||
statusInteractions.insert(interaction, at: 0)
|
||||
if statusInteractions.count > 30 {
|
||||
statusInteractions = Array(statusInteractions.prefix(30))
|
||||
}
|
||||
if !isInitial {
|
||||
todayStats.statusViews += 1
|
||||
recentSkillActivity = true
|
||||
}
|
||||
|
||||
pendingStatusContent = ""
|
||||
pendingStatusPhone = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CRM cache
|
||||
|
||||
private func loadCRM() {
|
||||
let subscribersPath = picoHome + "/workspace/scripts/.subscribers.json"
|
||||
let trialsPath = picoHome + "/workspace/scripts/.trials.json"
|
||||
var cache: [String: CRMContact] = [:]
|
||||
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: subscribersPath)),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let subs = json["subscribers"] as? [String: [String: Any]] {
|
||||
for (phone, info) in subs {
|
||||
let name = info["name"] as? String ?? ""
|
||||
let status = info["status"] as? String ?? "unknown"
|
||||
cache[phone] = CRMContact(name: name, status: status)
|
||||
}
|
||||
}
|
||||
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: trialsPath)),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let trials = json["trials"] as? [String: [String: Any]] {
|
||||
for (phone, info) in trials {
|
||||
if cache[phone] != nil { continue }
|
||||
let name = info["name"] as? String ?? ""
|
||||
let step = info["step"] as? String ?? ""
|
||||
let status = step == "connected" ? "trial_expired" : "trial"
|
||||
cache[phone] = CRMContact(name: name, status: status)
|
||||
}
|
||||
}
|
||||
|
||||
crmCache = cache
|
||||
}
|
||||
|
||||
// MARK: - Gateway log (for security blocks)
|
||||
|
||||
private func seekLogEnd() {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: logPath),
|
||||
let size = (attrs[.size] as? NSNumber)?.uint64Value else { return }
|
||||
logOffset = size
|
||||
}
|
||||
|
||||
private func tailLog() {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: logPath),
|
||||
let size = (attrs[.size] as? NSNumber)?.uint64Value,
|
||||
size > logOffset else { return }
|
||||
|
||||
guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: logPath)) else { return }
|
||||
defer { try? handle.close() }
|
||||
|
||||
try? handle.seek(toOffset: logOffset)
|
||||
let data = handle.readDataToEndOfFile()
|
||||
logOffset = size
|
||||
|
||||
guard let text = String(data: data, encoding: .utf8) else { return }
|
||||
|
||||
for line in text.split(separator: "\n") {
|
||||
let s = String(line)
|
||||
guard s.hasPrefix("{"),
|
||||
let d = s.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { continue }
|
||||
|
||||
let message = json["message"] as? String ?? ""
|
||||
if message.contains("security scan blocked") || message.contains("guard blocked") {
|
||||
todayStats.blockedAttempts += 1
|
||||
let name = json["skill_name"] as? String ?? "unknown"
|
||||
addEvent(SkillEvent(operation: "block", skillName: name, detail: message))
|
||||
recentSkillActivity = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skills directory
|
||||
|
||||
private func scanSkills() {
|
||||
let fm = FileManager.default
|
||||
guard let dirs = try? fm.contentsOfDirectory(atPath: skillsDir) else {
|
||||
totalSkills = 0
|
||||
skillsList = []
|
||||
return
|
||||
}
|
||||
|
||||
var skills: [SkillInfo] = []
|
||||
for dir in dirs {
|
||||
let skillMd = skillsDir + "/\(dir)/SKILL.md"
|
||||
guard fm.fileExists(atPath: skillMd) else { continue }
|
||||
let attrs = try? fm.attributesOfItem(atPath: skillMd)
|
||||
let modified = (attrs?[.modificationDate] as? Date) ?? Date.distantPast
|
||||
let desc = extractDescription(from: skillMd)
|
||||
skills.append(SkillInfo(name: dir, description: desc, modified: modified))
|
||||
}
|
||||
|
||||
skills.sort { $0.modified > $1.modified }
|
||||
totalSkills = skills.count
|
||||
skillsList = skills
|
||||
}
|
||||
|
||||
private func extractDescription(from path: String) -> String {
|
||||
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return "" }
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
var inFrontmatter = false
|
||||
for line in lines {
|
||||
if line.trimmingCharacters(in: .whitespaces) == "---" {
|
||||
if inFrontmatter { break }
|
||||
inFrontmatter = true
|
||||
continue
|
||||
}
|
||||
if inFrontmatter && line.hasPrefix("description:") {
|
||||
return String(line.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func watchSkillsDir() {
|
||||
let fd = open(skillsDir, O_EVTONLY)
|
||||
guard fd >= 0 else { return }
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
eventMask: [.write, .rename, .delete],
|
||||
queue: .main
|
||||
)
|
||||
source.setEventHandler { [weak self] in
|
||||
Task { @MainActor in self?.scanSkills() }
|
||||
}
|
||||
source.setCancelHandler { close(fd) }
|
||||
source.resume()
|
||||
dirWatcher = source
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
private func sendTrialNotification(phone: String, name: String) {
|
||||
let notification = NSUserNotification()
|
||||
notification.title = "Novo Trial no WhatsApp"
|
||||
notification.subtitle = name.isEmpty ? phone : "\(name) (\(phone))"
|
||||
notification.informativeText = "Usuario em fase trial iniciou conversa"
|
||||
notification.soundName = NSUserNotificationDefaultSoundName
|
||||
NSUserNotificationCenter.default.deliver(notification)
|
||||
updateWhatsAppStatus()
|
||||
}
|
||||
|
||||
private func updateWhatsAppStatus() {
|
||||
let count = todayStats.trialStarts
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "dd/MM"
|
||||
let text = "\(f.string(from: Date())): \(count)"
|
||||
let url = URL(string: "http://127.0.0.1:18790/api/send-status")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: ["text": text])
|
||||
URLSession.shared.dataTask(with: request) { _, _, _ in }.resume()
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
private func addEvent(_ event: SkillEvent) {
|
||||
recentEvents.insert(event, at: 0)
|
||||
if recentEvents.count > 50 {
|
||||
recentEvents = Array(recentEvents.prefix(50))
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
|
||||
self?.recentSkillActivity = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats persistence
|
||||
|
||||
static func todayString() -> String {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f.string(from: Date())
|
||||
}
|
||||
|
||||
private func loadStats() {
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: statsPath)),
|
||||
var stats = try? JSONDecoder().decode([DailyStats].self, from: data) else {
|
||||
weeklyStats = []
|
||||
return
|
||||
}
|
||||
|
||||
let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
stats = stats.filter { stat in
|
||||
guard let d = f.date(from: stat.date) else { return false }
|
||||
return d >= cutoff
|
||||
}
|
||||
|
||||
weeklyStats = stats
|
||||
|
||||
let today = Self.todayString()
|
||||
if let existing = stats.first(where: { $0.date == today }) {
|
||||
todayStats = existing
|
||||
}
|
||||
}
|
||||
|
||||
private func saveStats() {
|
||||
let today = Self.todayString()
|
||||
todayStats = DailyStats(
|
||||
date: today,
|
||||
skillsCreated: todayStats.skillsCreated,
|
||||
skillsPatched: todayStats.skillsPatched,
|
||||
skillsDeleted: todayStats.skillsDeleted,
|
||||
blockedAttempts: todayStats.blockedAttempts,
|
||||
totalConversations: todayStats.totalConversations,
|
||||
toolCalls: todayStats.toolCalls,
|
||||
skillManageCalls: todayStats.skillManageCalls,
|
||||
statusViews: todayStats.statusViews,
|
||||
trialStarts: todayStats.trialStarts
|
||||
)
|
||||
|
||||
var stats = weeklyStats.filter { $0.date != today }
|
||||
stats.append(todayStats)
|
||||
stats.sort { $0.date < $1.date }
|
||||
|
||||
if stats.count > 7 {
|
||||
stats = Array(stats.suffix(7))
|
||||
}
|
||||
|
||||
weeklyStats = stats
|
||||
|
||||
if let data = try? JSONEncoder().encode(stats) {
|
||||
try? data.write(to: URL(fileURLWithPath: statsPath))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Weekly summary
|
||||
|
||||
var weeklySummary: (skills: Int, patches: Int, blocks: Int, conversations: Int, toolCalls: Int, statusViews: Int, trialStarts: Int) {
|
||||
let all = weeklyStats + [todayStats]
|
||||
return (
|
||||
skills: all.reduce(0) { $0 + $1.skillsCreated },
|
||||
patches: all.reduce(0) { $0 + $1.skillsPatched },
|
||||
blocks: all.reduce(0) { $0 + $1.blockedAttempts },
|
||||
conversations: all.reduce(0) { $0 + $1.totalConversations },
|
||||
toolCalls: all.reduce(0) { $0 + $1.toolCalls },
|
||||
statusViews: all.reduce(0) { $0 + $1.statusViews },
|
||||
trialStarts: all.reduce(0) { $0 + $1.trialStarts }
|
||||
)
|
||||
}
|
||||
}
|
||||
12
tools/PicoWatch/PicoWatch/PicoWatchApp.swift
Normal file
12
tools/PicoWatch/PicoWatch/PicoWatchApp.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PicoWatchApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
Settings {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
490
tools/PicoWatch/PicoWatch/PopoverView.swift
Normal file
490
tools/PicoWatch/PicoWatch/PopoverView.swift
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import SwiftUI
|
||||
|
||||
struct PopoverView: View {
|
||||
@ObservedObject var engine: MonitorEngine
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
headerView
|
||||
Divider()
|
||||
gatewayStatus
|
||||
Divider()
|
||||
trialActivity
|
||||
Divider()
|
||||
weeklyReport
|
||||
Divider()
|
||||
recentActivity
|
||||
Divider()
|
||||
footerView
|
||||
}
|
||||
.frame(width: 380)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerView: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "brain.head.profile")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.purple)
|
||||
Text("PicoWatch")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(engine.gatewayUp ? Color.green : Color.red)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(engine.gatewayUp ? "Online" : "Offline")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Gateway
|
||||
|
||||
private var gatewayStatus: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Gateway")
|
||||
.font(.system(.caption, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
if engine.gatewayUp {
|
||||
Text("Uptime: \(engine.gatewayUptime)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
} else {
|
||||
Text("Not running")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Skills")
|
||||
.font(.system(.caption, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
Text("\(engine.totalSkills)")
|
||||
.font(.system(.title2, design: .rounded, weight: .bold))
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
}
|
||||
if !engine.lastMessage.isEmpty {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "bubble.left.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.green)
|
||||
Text(engine.lastMessage)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("\(engine.activeSessionCount) sessoes ativas")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: - Trial Activity
|
||||
|
||||
private var trialActivity: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "person.badge.clock")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Conversas de Teste")
|
||||
.font(.system(.caption, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
Spacer()
|
||||
Text("\(engine.todayStats.trialStarts) hoje")
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if engine.trialInteractions.isEmpty {
|
||||
Text("Nenhuma conversa de teste ainda")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.vertical, 2)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(engine.trialInteractions.prefix(6)) { trial in
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color.orange)
|
||||
.frame(width: 7, height: 7)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trial.contactName.isEmpty ? trial.phone : trial.contactName)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(trial.content)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text(relativeDate(trial.timestamp))
|
||||
.font(.system(size: 8, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(RoundedRectangle(cornerRadius: 5).fill(Color.orange.opacity(0.06)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 110)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: - Status Broadcast
|
||||
|
||||
private var statusActivity: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.cyan)
|
||||
Text("Status Broadcast")
|
||||
.font(.system(.caption, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
Spacer()
|
||||
Text("\(engine.todayStats.statusViews) hoje")
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.cyan)
|
||||
}
|
||||
|
||||
if engine.statusInteractions.isEmpty {
|
||||
Text("Nenhuma interacao de status ainda")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.vertical, 2)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(engine.statusInteractions.prefix(6)) { interaction in
|
||||
statusRow(interaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 110)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func statusRow(_ interaction: StatusInteraction) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(crmBadgeColor(interaction.crmStatus))
|
||||
.frame(width: 7, height: 7)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Text(interaction.contactName.isEmpty ? interaction.phone : interaction.contactName)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(crmBadgeLabel(interaction.crmStatus))
|
||||
.font(.system(size: 8, weight: .medium))
|
||||
.foregroundStyle(crmBadgeColor(interaction.crmStatus))
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(crmBadgeColor(interaction.crmStatus).opacity(0.12))
|
||||
)
|
||||
}
|
||||
if !interaction.content.isEmpty {
|
||||
Text(interaction.content)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(relativeDate(interaction.timestamp))
|
||||
.font(.system(size: 8, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(RoundedRectangle(cornerRadius: 5).fill(Color.cyan.opacity(0.04)))
|
||||
}
|
||||
|
||||
private func crmBadgeColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "active": return .green
|
||||
case "trial": return .orange
|
||||
case "trial_expired": return .red
|
||||
case "inactive": return .red
|
||||
case "ignored": return .gray
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func crmBadgeLabel(_ status: String) -> String {
|
||||
switch status {
|
||||
case "active": return "assinante"
|
||||
case "trial": return "trial"
|
||||
case "trial_expired": return "expirado"
|
||||
case "inactive": return "inativo"
|
||||
case "ignored": return "ignorado"
|
||||
default: return "novo"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
private var skillsSummary: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Installed Skills")
|
||||
.font(.system(.caption, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
if engine.skillsList.isEmpty {
|
||||
Text("Nenhuma skill ainda — o agente cria quando completa tarefas complexas")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(engine.skillsList) { skill in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.purple)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(skill.name)
|
||||
.font(.system(.caption, design: .monospaced, weight: .semibold))
|
||||
if !skill.description.isEmpty {
|
||||
Text(skill.description)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(relativeDate(skill.modified))
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(RoundedRectangle(cornerRadius: 5).fill(Color.purple.opacity(0.06)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: - Weekly Report
|
||||
|
||||
private var weeklyReport: some View {
|
||||
let summary = engine.weeklySummary
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Relatorio Semanal")
|
||||
.font(.system(.caption, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
Spacer()
|
||||
Text("\(engine.weeklyStats.count + 1) dias")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
metricCard(icon: "person.badge.clock", color: .orange,
|
||||
label: "Trials", value: "\(summary.trialStarts)")
|
||||
}
|
||||
|
||||
// Mini bar chart for daily activity
|
||||
if !engine.weeklyStats.isEmpty {
|
||||
weeklyChart
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func metricCard(icon: String, color: Color, label: String, value: String) -> some View {
|
||||
VStack(spacing: 3) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(color)
|
||||
Text(value)
|
||||
.font(.system(.callout, design: .rounded, weight: .bold))
|
||||
}
|
||||
Text(label)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.08)))
|
||||
}
|
||||
|
||||
private var weeklyChart: some View {
|
||||
let allDays = engine.weeklyStats + [engine.todayStats]
|
||||
let maxOps = max(allDays.map(\.totalSkillOps).max() ?? 1, 1)
|
||||
|
||||
return HStack(alignment: .bottom, spacing: 3) {
|
||||
ForEach(allDays) { day in
|
||||
VStack(spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.purple.opacity(0.6))
|
||||
.frame(height: max(2, CGFloat(day.totalSkillOps) / CGFloat(maxOps) * 30))
|
||||
Text(dayLabel(day.date))
|
||||
.font(.system(size: 7, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(height: 45)
|
||||
}
|
||||
|
||||
// MARK: - Recent Activity
|
||||
|
||||
private var recentActivity: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Atividade Recente")
|
||||
.font(.system(.caption, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
if engine.recentEvents.isEmpty {
|
||||
Text("Nenhum evento de skill ainda — use o agente no WhatsApp")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(engine.recentEvents.prefix(8)) { event in
|
||||
eventRow(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func eventRow(_ event: SkillEvent) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: eventIcon(event.operation))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(eventColor(event.operation))
|
||||
.frame(width: 16)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Text(eventLabel(event.operation))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(eventColor(event.operation))
|
||||
Text(event.skillName)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
}
|
||||
if !event.detail.isEmpty {
|
||||
Text(event.detail)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(relativeDate(event.timestamp))
|
||||
.font(.system(size: 8, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
|
||||
private func eventIcon(_ op: String) -> String {
|
||||
switch op {
|
||||
case "create": return "plus.circle.fill"
|
||||
case "patch", "update": return "wrench.fill"
|
||||
case "delete": return "trash.fill"
|
||||
case "block": return "shield.slash.fill"
|
||||
default: return "circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func eventColor(_ op: String) -> Color {
|
||||
switch op {
|
||||
case "create": return .green
|
||||
case "patch", "update": return .orange
|
||||
case "delete": return .red
|
||||
case "block": return .red
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func eventLabel(_ op: String) -> String {
|
||||
switch op {
|
||||
case "create": return "CRIOU"
|
||||
case "patch": return "PATCH"
|
||||
case "update": return "UPDATE"
|
||||
case "delete": return "DELETOU"
|
||||
case "block": return "BLOQUEOU"
|
||||
default: return op.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
private var footerView: some View {
|
||||
HStack {
|
||||
Button("Quit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Text("~/.picoclaw/workspace/skills")
|
||||
.font(.system(size: 8, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func relativeDate(_ date: Date) -> String {
|
||||
let elapsed = Date().timeIntervalSince(date)
|
||||
if elapsed < 60 { return "agora" }
|
||||
if elapsed < 3600 { return "\(Int(elapsed / 60))m" }
|
||||
if elapsed < 86400 { return "\(Int(elapsed / 3600))h" }
|
||||
return "\(Int(elapsed / 86400))d"
|
||||
}
|
||||
|
||||
private func dayLabel(_ dateStr: String) -> String {
|
||||
String(dateStr.suffix(2))
|
||||
}
|
||||
}
|
||||
29
tools/PicoWatch/build.sh
Executable file
29
tools/PicoWatch/build.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="PicoWatch"
|
||||
BUILD_DIR="build"
|
||||
APP_DIR="$BUILD_DIR/$APP_NAME.app"
|
||||
|
||||
echo "Building $APP_NAME..."
|
||||
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$APP_DIR/Contents/MacOS"
|
||||
mkdir -p "$APP_DIR/Contents/Resources"
|
||||
|
||||
xcrun swiftc \
|
||||
-sdk "$(xcrun --show-sdk-path -sdk macosx)" \
|
||||
-target arm64-apple-macosx14.0 \
|
||||
-framework AppKit -framework SwiftUI \
|
||||
-parse-as-library \
|
||||
-O \
|
||||
PicoWatch/PicoWatchApp.swift \
|
||||
PicoWatch/AppDelegate.swift \
|
||||
PicoWatch/MonitorEngine.swift \
|
||||
PicoWatch/PopoverView.swift \
|
||||
-o "$APP_DIR/Contents/MacOS/$APP_NAME"
|
||||
|
||||
cp PicoWatch/Info.plist "$APP_DIR/Contents/Info.plist"
|
||||
|
||||
echo "Built: $APP_DIR"
|
||||
echo "Run with: open \"$APP_DIR\""
|
||||
Loading…
Add table
Reference in a new issue