mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
feat(heartbeat): provider/model override + fix cache invalidation
- Add ProviderModelSelect to heartbeat config dialog (allowEmpty, verify button) - Backend: accept providerName in HEARTBEAT.SET, resolve to UUID via GetProviderByName - Add ModelOverride to RunRequest, used by Loop when set (cheaper model for heartbeat) - Ticker passes heartbeat model override to agent RunRequest - Fix: InvalidateCache after UpdateState so ListDue picks up new next_run_at immediately - i18n: add sectionModel/modelHint keys (en/vi/zh)
This commit is contained in:
1 parent
74c9fdd66b
commit
5b349db7eb
12 files changed
+105
-9
No files matched your search
@@ -728,6 +728,7 @@ func runGateway() {
|
||||
heartbeatTool.SetWakeFn(heartbeatTicker.Wake)
|
||||
heartbeatMethods.SetWakeFn(heartbeatTicker.Wake)
|
||||
heartbeatMethods.SetAgentStore(pgStores.Agents)
|
||||
heartbeatMethods.SetProviderStore(pgStores.Providers)
|
||||
cronHeartbeatWakeFn = func(agentID string) {
|
||||
if id, err := uuid.Parse(agentID); err == nil {
|
||||
heartbeatTicker.Wake(id)
|
||||
|
||||
@@ -618,10 +618,16 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
toolDefs = filtered
|
||||
}
|
||||
|
||||
// Use per-request model override if set (e.g. heartbeat uses cheaper model).
|
||||
model := l.model
|
||||
if req.ModelOverride != "" {
|
||||
model = req.ModelOverride
|
||||
}
|
||||
|
||||
chatReq := providers.ChatRequest{
|
||||
Messages: messages,
|
||||
Tools: toolDefs,
|
||||
Model: l.model,
|
||||
Model: model,
|
||||
Options: map[string]any{
|
||||
providers.OptMaxTokens: l.effectiveMaxTokens(),
|
||||
providers.OptTemperature: 0.7,
|
||||
|
||||
@@ -370,6 +370,7 @@ type RunRequest struct {
|
||||
TraceName string // override trace name (default: "chat <agentID>")
|
||||
TraceTags []string // additional tags for the trace (e.g. "cron")
|
||||
MaxIterations int // per-request override (0 = use agent default, must be lower)
|
||||
ModelOverride string // per-request model override (heartbeat uses cheaper model)
|
||||
LightContext bool // skip loading context files (only inject ExtraSystemPrompt)
|
||||
|
||||
// Run classification
|
||||
|
||||
@@ -19,10 +19,11 @@ import (
|
||||
|
||||
// HeartbeatMethods handles heartbeat.get/set/toggle/test/logs/checklist RPC methods.
|
||||
type HeartbeatMethods struct {
|
||||
hbStore store.HeartbeatStore
|
||||
agentStore store.AgentStore
|
||||
eventBus bus.EventPublisher
|
||||
wakeFn func(uuid.UUID) // triggers immediate heartbeat run
|
||||
hbStore store.HeartbeatStore
|
||||
agentStore store.AgentStore
|
||||
providerStore store.ProviderStore
|
||||
eventBus bus.EventPublisher
|
||||
wakeFn func(uuid.UUID) // triggers immediate heartbeat run
|
||||
}
|
||||
|
||||
func NewHeartbeatMethods(hb store.HeartbeatStore, eventBus bus.EventPublisher) *HeartbeatMethods {
|
||||
@@ -34,6 +35,11 @@ func (m *HeartbeatMethods) SetAgentStore(as store.AgentStore) {
|
||||
m.agentStore = as
|
||||
}
|
||||
|
||||
// SetProviderStore sets the provider store for resolving provider names to UUIDs.
|
||||
func (m *HeartbeatMethods) SetProviderStore(ps store.ProviderStore) {
|
||||
m.providerStore = ps
|
||||
}
|
||||
|
||||
// SetWakeFn sets the function called when "heartbeat.test" triggers an immediate run.
|
||||
func (m *HeartbeatMethods) SetWakeFn(fn func(uuid.UUID)) {
|
||||
m.wakeFn = fn
|
||||
@@ -89,6 +95,7 @@ func (m *HeartbeatMethods) handleSet(ctx context.Context, client *gateway.Client
|
||||
Enabled *bool `json:"enabled"`
|
||||
IntervalSec *int `json:"intervalSec"`
|
||||
Prompt *string `json:"prompt"`
|
||||
ProviderName *string `json:"providerName"`
|
||||
Model *string `json:"model"`
|
||||
IsolatedSession *bool `json:"isolatedSession"`
|
||||
LightContext *bool `json:"lightContext"`
|
||||
@@ -143,8 +150,24 @@ func (m *HeartbeatMethods) handleSet(ctx context.Context, client *gateway.Client
|
||||
if params.Prompt != nil {
|
||||
hb.Prompt = params.Prompt
|
||||
}
|
||||
if params.ProviderName != nil {
|
||||
if *params.ProviderName == "" {
|
||||
hb.ProviderID = nil // clear override
|
||||
} else if m.providerStore != nil {
|
||||
prov, err := m.providerStore.GetProviderByName(ctx, *params.ProviderName)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "provider not found: "+*params.ProviderName))
|
||||
return
|
||||
}
|
||||
hb.ProviderID = &prov.ID
|
||||
}
|
||||
}
|
||||
if params.Model != nil {
|
||||
hb.Model = params.Model
|
||||
if *params.Model == "" {
|
||||
hb.Model = nil // clear override
|
||||
} else {
|
||||
hb.Model = params.Model
|
||||
}
|
||||
}
|
||||
if params.IsolatedSession != nil {
|
||||
hb.IsolatedSession = *params.IsolatedSession
|
||||
|
||||
@@ -220,6 +220,12 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) {
|
||||
var result *agent.RunResult
|
||||
maxAttempts := hb.MaxRetries + 1
|
||||
|
||||
// Model override: use heartbeat-specific model if configured.
|
||||
var modelOverride string
|
||||
if hb.Model != nil && *hb.Model != "" {
|
||||
modelOverride = *hb.Model
|
||||
}
|
||||
|
||||
for attempt := range maxAttempts {
|
||||
outCh := t.runAgent(ctx, agent.RunRequest{
|
||||
SessionKey: sessionKey,
|
||||
@@ -229,6 +235,7 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) {
|
||||
RunID: fmt.Sprintf("heartbeat:%s", agentIDStr),
|
||||
Stream: false,
|
||||
ExtraSystemPrompt: extraSystem,
|
||||
ModelOverride: modelOverride,
|
||||
LightContext: hb.LightContext,
|
||||
TraceName: fmt.Sprintf("Heartbeat [%s]", agentKey),
|
||||
TraceTags: []string{"heartbeat"},
|
||||
|
||||
@@ -199,6 +199,9 @@ func (s *PGHeartbeatStore) UpdateState(ctx context.Context, id uuid.UUID, state
|
||||
id, state.NextRunAt, state.LastRunAt, state.LastStatus, state.LastError,
|
||||
state.RunCount, state.SuppressCount,
|
||||
)
|
||||
if err == nil {
|
||||
s.InvalidateCache() // ensure ListDue picks up new next_run_at immediately
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -438,6 +438,8 @@
|
||||
"activeHoursStart": "Active From (HH:MM)",
|
||||
"activeHoursEnd": "Active Until (HH:MM)",
|
||||
"timezone": "Timezone",
|
||||
"sectionModel": "Model",
|
||||
"modelHint": "Override provider/model for heartbeat runs. Leave empty to use agent defaults.",
|
||||
"sectionDelivery": "Delivery",
|
||||
"channel": "Channel",
|
||||
"channelPlaceholder": "Select channel",
|
||||
|
||||
@@ -438,6 +438,8 @@
|
||||
"activeHoursStart": "Bắt đầu (HH:MM)",
|
||||
"activeHoursEnd": "Kết thúc (HH:MM)",
|
||||
"timezone": "Múi giờ",
|
||||
"sectionModel": "Model",
|
||||
"modelHint": "Ghi đè provider/model cho heartbeat. Để trống để dùng mặc định của agent.",
|
||||
"sectionDelivery": "Gửi kết quả",
|
||||
"channel": "Kênh",
|
||||
"channelPlaceholder": "Chọn kênh",
|
||||
|
||||
@@ -438,6 +438,8 @@
|
||||
"activeHoursStart": "开始时间(HH:MM)",
|
||||
"activeHoursEnd": "结束时间(HH:MM)",
|
||||
"timezone": "时区",
|
||||
"sectionModel": "模型",
|
||||
"modelHint": "覆盖心跳运行的提供者/模型。留空使用代理默认值。",
|
||||
"sectionDelivery": "推送设置",
|
||||
"channel": "渠道",
|
||||
"channelPlaceholder": "选择渠道",
|
||||
|
||||
@@ -128,6 +128,8 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
setChecklist={hb.setChecklist}
|
||||
fetchTargets={hb.fetchTargets}
|
||||
refresh={hb.refresh}
|
||||
agentProvider={agent?.provider}
|
||||
agentModel={agent?.model}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Play, Loader2, ChevronDown, Heart, Clock, Send, FileText } from "lucide-react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Play, Loader2, ChevronDown, Heart, Clock, Send, FileText, Cpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useChannels } from "@/pages/channels/hooks/use-channels";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat";
|
||||
|
||||
interface HeartbeatConfigDialogProps {
|
||||
@@ -23,19 +25,25 @@ interface HeartbeatConfigDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: HeartbeatConfig | null;
|
||||
saving: boolean;
|
||||
update: (params: Partial<HeartbeatConfig>) => Promise<void>;
|
||||
update: (params: Partial<HeartbeatConfig> & { providerName?: string }) => Promise<void>;
|
||||
test: () => Promise<void>;
|
||||
getChecklist: () => Promise<string>;
|
||||
setChecklist: (content: string) => Promise<void>;
|
||||
fetchTargets: () => Promise<DeliveryTarget[]>;
|
||||
refresh: () => Promise<void>;
|
||||
/** Agent's current provider name (used as default for heartbeat). */
|
||||
agentProvider?: string;
|
||||
/** Agent's current model (used as default for heartbeat). */
|
||||
agentModel?: string;
|
||||
}
|
||||
|
||||
export function HeartbeatConfigDialog({
|
||||
open, onOpenChange, config, saving, update, test, getChecklist, setChecklist, fetchTargets, refresh,
|
||||
agentProvider, agentModel,
|
||||
}: HeartbeatConfigDialogProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const { channels: availableChannels } = useChannels();
|
||||
const { providers } = useProviders();
|
||||
const channelNames = Object.keys(availableChannels);
|
||||
const userTz = useUiStore((s) => s.timezone);
|
||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
@@ -53,6 +61,8 @@ export function HeartbeatConfigDialog({
|
||||
const [timezone, setTimezone] = useState("");
|
||||
const [channel, setChannel] = useState("");
|
||||
const [chatId, setChatId] = useState("");
|
||||
const [hbProvider, setHbProvider] = useState("");
|
||||
const [hbModel, setHbModel] = useState("");
|
||||
const [checklist, setChecklistState] = useState("");
|
||||
const [originalChecklist, setOriginalChecklist] = useState("");
|
||||
const [checklistLoading, setChecklistLoading] = useState(false);
|
||||
@@ -73,6 +83,13 @@ export function HeartbeatConfigDialog({
|
||||
}
|
||||
}, [getChecklist]);
|
||||
|
||||
// Map providerId UUID → provider name for the select component.
|
||||
const providerNameById = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of providers) map[p.id] = p.name;
|
||||
return map;
|
||||
}, [providers]);
|
||||
|
||||
// Sync form state only when dialog opens (false→true).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -88,9 +105,14 @@ export function HeartbeatConfigDialog({
|
||||
setTimezone(config.timezone || defaultTz);
|
||||
setChannel(config.channel ?? "");
|
||||
setChatId(config.chatId ?? "");
|
||||
// Map stored providerId UUID → name for the select
|
||||
setHbProvider(config.providerId ? (providerNameById[config.providerId] ?? "") : "");
|
||||
setHbModel(config.model ?? "");
|
||||
} else {
|
||||
// First-time setup defaults
|
||||
setTimezone(defaultTz);
|
||||
setHbProvider("");
|
||||
setHbModel("");
|
||||
}
|
||||
setShowAdvanced(false);
|
||||
loadChecklist();
|
||||
@@ -117,6 +139,8 @@ export function HeartbeatConfigDialog({
|
||||
timezone: timezone || undefined,
|
||||
channel: channel || undefined,
|
||||
chatId: chatId || undefined,
|
||||
model: hbModel || undefined,
|
||||
providerName: hbProvider || undefined,
|
||||
});
|
||||
if (checklist !== originalChecklist) {
|
||||
await setChecklist(checklist);
|
||||
@@ -164,6 +188,27 @@ export function HeartbeatConfigDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Provider / Model override ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-3.5 w-3.5 text-violet-500" />
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("heartbeat.sectionModel")}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("heartbeat.modelHint")}</p>
|
||||
<ProviderModelSelect
|
||||
provider={hbProvider}
|
||||
onProviderChange={setHbProvider}
|
||||
model={hbModel}
|
||||
onModelChange={setHbModel}
|
||||
allowEmpty
|
||||
showVerify={!!(hbProvider && hbModel)}
|
||||
providerPlaceholder={agentProvider ? `(${agentProvider})` : "(agent default)"}
|
||||
modelPlaceholder={agentModel ? `(${agentModel})` : "(agent default)"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Delivery — WHERE it sends ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface HeartbeatConfig {
|
||||
intervalSec: number;
|
||||
ackMaxChars: number;
|
||||
maxRetries: number;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
isolatedSession: boolean;
|
||||
lightContext: boolean;
|
||||
activeHoursStart?: string;
|
||||
|
||||
Reference in new issue
Block a user