routing: a group's rule can hold hours of the day (Discord: send DeepSeek's or GLM's peak-price hours to another provider) — a rule takes time: {from, to, days} in this computer's local time, HH:MM to HH:MM; hours that end before they begin run past midnight and belong to the day they begin on (Friday's 22:00–08:00 takes Saturday's early hours), days are optional, and the hours are checked as a turn begins, so a turn that began inside them finishes where it went. The rule reads "time 09:00–18:00 Mon–Fri" on the group and in the trace; the editor types the hours into one pill (从 … 到 …), notes past midnight, marks a time that isn't one and picks days like the agents (every day, weekdays, weekends, or each); magpie group rule add … time=14:00-18:00 days=mon-fri and the TUI's rule line take it too. Older versions ignore the field, and a rule with only hours then matches nothing there. TestRuleTimeMatches and TestRuleTimeWindowAtTurnStart (injected clock) and routing-time.test.cjs fail without the change. A sandbox magpie with two fake vendors sent a request inside the hours to the rule's member and one outside them, or on another day, to the first. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; routing-time, routing-compact, routing-manual, routing-steady, settings-groups, click-scroll, old-webkit pass.
This commit is contained in:
12 files changed
+773
-15
No files matched your search
+7
-1
@@ -15,7 +15,8 @@ import (
|
||||
const ruleUsage = `usage:
|
||||
magpie group rule <group> the group's rules
|
||||
magpie group rule add <group> use=<model> [tokens=<n>] [images] [effort=on|low|medium|high|xhigh|max] [agents=a,b…]
|
||||
[intent="<what the message asks for>"] [compact] [classifier=<model>] [at=<n>]
|
||||
[intent="<what the message asks for>"] [compact] [time=HH:MM-HH:MM] [days=mon-fri]
|
||||
[classifier=<model>] [at=<n>]
|
||||
a rule: a turn that matches it goes to <model>, one of the group's
|
||||
(or group/<id>, a group in it), first; a model in the group at an
|
||||
effort of its own is named with it (use=glm/glm-5.3-flash:high)
|
||||
@@ -42,11 +43,16 @@ const ruleUsage = `usage:
|
||||
OpenCode's, Pi's, Gemini CLI's, Qwen Code's, Kimi's): a cheaper, faster model can write the
|
||||
summary. The request is as long as the conversation, so a model known to take less is passed
|
||||
over; it is not the turn's model, and the requests after it go on as before
|
||||
time the turn begins within these hours, on this computer's clock (09:00-18:00; 22:00-08:00
|
||||
runs past midnight) — a provider's peak-price hours sent to another, say
|
||||
days only on these days (mon-fri, sat,sun); with time, a window past midnight is of the day it
|
||||
begins on. Days alone hold all day
|
||||
|
||||
e.g. magpie group rule add opus-anywhere use=openrouter/google/gemini-3-pro tokens=200k
|
||||
magpie group rule add opus-anywhere use=a/vision-model images
|
||||
magpie group rule add fast use=codex/gpt-5.6-sol:xhigh effort=high
|
||||
magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact
|
||||
magpie group rule add cheap use=glm/glm-5.3 time=14:00-18:00 days=mon-fri
|
||||
magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash intent="a quick question" classifier=groq/llama-3.1-8b-instant`
|
||||
|
||||
// parseTokens reads 200000, 200k, 1.5m.
|
||||
|
||||
@@ -18,6 +18,10 @@ import (
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// ruleClock is the time a turn's rules look at (a rule's hours), and its
|
||||
// decision is kept from; tests set it.
|
||||
var ruleClock = time.Now
|
||||
|
||||
// RuleHit is what the trace tells of a group's rules for a request.
|
||||
type RuleHit struct {
|
||||
N int `json:"n"` // the rule, from 1; 0 when none matched
|
||||
@@ -131,14 +135,14 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
|
||||
return nil
|
||||
}
|
||||
turn, within := turnIn(req)
|
||||
q := provider.RuleRequest{Tokens: estimate(req), Thinking: req.Thinking, Effort: req.Effort, Agent: agent}
|
||||
now := ruleClock()
|
||||
q := provider.RuleRequest{Tokens: estimate(req), Thinking: req.Thinking, Effort: req.Effort, Agent: agent, At: now}
|
||||
for _, m := range req.Messages {
|
||||
if slices.ContainsFunc(m.Parts, func(p Part) bool { return p.Kind == Image }) {
|
||||
q.Images = true
|
||||
break
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
turnRules.Lock()
|
||||
tr, had := turnRules.m[key]
|
||||
turnRules.Unlock()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// A rule's hours are looked at as a turn begins, on the clock then: a turn
|
||||
// begun inside them goes to its member and stays there for its tool
|
||||
// rounds after they end; a turn begun outside them isn't the rule's (where
|
||||
// the same conversation goes then is the group's Stays to say).
|
||||
func TestRuleTimeWindowAtTurnStart(t *testing.T) {
|
||||
now := time.Date(2026, 9, 29, 10, 0, 0, 0, time.Local) // a Tuesday
|
||||
ruleClock = func() time.Time { return now }
|
||||
t.Cleanup(func() { ruleClock = time.Now })
|
||||
s, a, b := ruled(t, provider.Rule{Use: "b/big", Time: &provider.TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}}})
|
||||
post := func(body string, session string) (string, Route) {
|
||||
t.Helper()
|
||||
code, out := postAs(t, s, session, body)
|
||||
if code != 200 {
|
||||
t.Fatalf("%d %s", code, out)
|
||||
}
|
||||
return out, lastRoute(s)
|
||||
}
|
||||
// turn 1 begins inside the hours: the rule's member, the trace says why
|
||||
out, r := post(chat("hello", nil, 0, ""), "sess")
|
||||
if !strings.Contains(out, "from kb") || r.Rule == nil || r.Rule.N != 1 || r.Rule.Use != "b/big" || strings.Join(r.Rule.When, " · ") != "time 09:00–18:00 Mon–Fri" {
|
||||
t.Fatalf("turn 1: %s %+v", out, r.Rule)
|
||||
}
|
||||
// its tool rounds after the hours end stay where it began
|
||||
now = time.Date(2026, 9, 29, 18, 30, 0, 0, time.Local)
|
||||
out, r = post(chat("hello", nil, 1, ""), "sess")
|
||||
if !strings.Contains(out, "from kb") || !r.Rule.Held || r.Rule.N != 1 {
|
||||
t.Fatalf("turn 1 tool round: %s %+v", out, r.Rule)
|
||||
}
|
||||
// turn 2 begins after them: no rule
|
||||
if _, r = post(chat("hello", []string{"more"}, 0, ""), "sess"); r.Rule.N != 0 || r.Rule.Held {
|
||||
t.Fatalf("turn 2: %+v", r.Rule)
|
||||
}
|
||||
// nor another conversation's: the group's order
|
||||
if out, r = post(chat("hi", nil, 0, ""), "s2"); !strings.Contains(out, "from ka") || r.Rule.N != 0 {
|
||||
t.Fatalf("after hours: %s %+v", out, r.Rule)
|
||||
}
|
||||
// a turn begun before them stays off the rule when they open mid-turn
|
||||
now = time.Date(2026, 9, 30, 8, 59, 0, 0, time.Local)
|
||||
if out, r = post(chat("hello", nil, 0, ""), "s3"); !strings.Contains(out, "from ka") || r.Rule.N != 0 {
|
||||
t.Fatalf("before hours: %s %+v", out, r.Rule)
|
||||
}
|
||||
now = time.Date(2026, 9, 30, 9, 1, 0, 0, time.Local)
|
||||
if out, r = post(chat("hello", nil, 1, ""), "s3"); !strings.Contains(out, "from ka") || !r.Rule.Held || r.Rule.N != 0 {
|
||||
t.Fatalf("before hours, tool round: %s %+v", out, r.Rule)
|
||||
}
|
||||
// on a Saturday, the hours don't hold
|
||||
now = time.Date(2026, 10, 3, 10, 0, 0, 0, time.Local)
|
||||
if out, r = post(chat("hello", nil, 0, ""), "s4"); !strings.Contains(out, "from ka") || r.Rule.N != 0 {
|
||||
t.Fatalf("saturday: %s %+v", out, r.Rule)
|
||||
}
|
||||
if a.n() != 4 || b.n() != 3 {
|
||||
t.Fatalf("a %d b %d", a.n(), b.n())
|
||||
}
|
||||
}
|
||||
@@ -1248,6 +1248,26 @@ const I18N = {
|
||||
"Time": "时间",
|
||||
"has an image": "带图片",
|
||||
"compacting": "压缩上下文时",
|
||||
"from": "从",
|
||||
"to": "到",
|
||||
"The turn begins within these hours, on this computer's clock — a provider's peak-price hours sent to another, say": "这一轮在这个时段内开始时(按本机时间)——比如把某家服务商高峰计价的时段转给另一家",
|
||||
"every day": "每天",
|
||||
"days": "星期",
|
||||
"weekdays": "工作日",
|
||||
"weekends": "周末",
|
||||
"Mon": "周一",
|
||||
"Tue": "周二",
|
||||
"Wed": "周三",
|
||||
"Thu": "周四",
|
||||
"Fri": "周五",
|
||||
"Sat": "周六",
|
||||
"Sun": "周日",
|
||||
"{from}–{to}": "{from}–{to}",
|
||||
"all day": "全天",
|
||||
"{hours} {days}": "{days} {hours}",
|
||||
"runs past midnight, into the next day": "跨过午夜,到第二天",
|
||||
"Rule {n}: the hours are two times like 09:00 and 18:00": "第 {n} 条规则:时段要填两个时间,例如 09:00 和 18:00",
|
||||
"Rule {n}: its hours are the whole day — pick days, or other hours": "第 {n} 条规则:这个时段是全天——请选择星期,或换个时段",
|
||||
"The agent summarizes the conversation to go on in less room (Claude Code's /compact, Codex, OpenCode…): a cheaper, faster model can do it": "Agent 总结对话以腾出上下文(Claude Code 的 /compact、Codex、OpenCode…):可交给更便宜、更快的模型来做",
|
||||
"longer conversations skip it: it takes {n} tokens": "更长的对话会跳过它:它的上下文只有 {n} tokens",
|
||||
"The agent is compacting the conversation, so rule {n} sends the summary to {use}; the conversation stays on the model it was on.": "Agent 正在压缩上下文,规则 {n} 把这次总结交给 {use};对话本身仍留在原来的模型上。",
|
||||
|
||||
@@ -326,6 +326,11 @@
|
||||
.rt-cond.tk { cursor: text; }
|
||||
.rt-cond.in { cursor: text; }
|
||||
.rt-cond.in input, .editor .rt-cond.in input[type="text"] { width: 24ch; }
|
||||
/* a rule's hours: two times in the pill, typed; one that isn't a time is marked */
|
||||
.rt-cond.tm { cursor: text; }
|
||||
.rt-cond.tm input.clock, .editor .rt-cond.tm input.clock[type="text"] { width: 5.5ch; text-align: center; }
|
||||
.rt-cond.tm.bad { border-style: solid; border-color: var(--amber, var(--muted)); }
|
||||
.rt-cond[hidden] { display: none; }
|
||||
.rt-classifier { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; }
|
||||
.rt-classifier[hidden] { display: none; }
|
||||
.rt-classifier .w { color: var(--muted); font-size: 12px; }
|
||||
|
||||
@@ -415,8 +415,43 @@
|
||||
if (c === "compacting") return t("compacting");
|
||||
if ((m = /^effort ≥ (\w+)$/.exec(c))) return t("reasoning ≥ {level}", { level: m[1] });
|
||||
if ((m = /^agent (.+)$/.exec(c))) return m[1].split("|").map(agentName).join(" / ");
|
||||
if ((m = /^time (all day|(\d\d:\d\d)–(\d\d:\d\d))(?: (.+))?$/.exec(c))) {
|
||||
// the days as the gateway writes them: Mon–Fri, Sat,Sun, Mon,Wed–Fri
|
||||
const days = [];
|
||||
for (const part of (m[4] || "").split(",").filter(Boolean)) {
|
||||
const [a, b] = part.split("–").map((d) => WEEK.indexOf(d.toLowerCase()));
|
||||
for (let i = a; i >= 0 && i <= (b >= 0 ? b : a); i++) days.push(WEEK[i]);
|
||||
}
|
||||
return timeText({ from: m[2] || "00:00", to: m[3] || "00:00", days });
|
||||
}
|
||||
return c;
|
||||
}
|
||||
// a rule's hours (provider.TimeWindow): local time, past midnight when
|
||||
// they end before they begin, on the days named or every day
|
||||
const WEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||
const DAY_NAMES = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" };
|
||||
function daysText(days) {
|
||||
const on = WEEK.map((d) => (days || []).includes(d)), out = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (!on[i]) continue;
|
||||
let j = i;
|
||||
while (j + 1 < 7 && on[j + 1]) j++;
|
||||
if (j - i >= 2) out.push(t("{from}–{to}", { from: t(DAY_NAMES[WEEK[i]]), to: t(DAY_NAMES[WEEK[j]]) }));
|
||||
else for (let k = i; k <= j; k++) out.push(t(DAY_NAMES[WEEK[k]]));
|
||||
i = j;
|
||||
}
|
||||
return out.length === 7 || !out.length ? "" : out.join(t(", "));
|
||||
}
|
||||
function timeText(w) {
|
||||
const hours = w.from === w.to ? t("all day") : t("{from}–{to}", { from: w.from, to: w.to });
|
||||
const days = daysText(w.days);
|
||||
return days ? t("{hours} {days}", { hours, days }) : hours;
|
||||
}
|
||||
// "9:5" is no time; "9:05" is 09:05
|
||||
const clockOf = (s) => {
|
||||
const m = /^\s*(\d{1,2}):(\d{2})\s*$/.exec(s || "");
|
||||
return m && +m[1] < 24 && +m[2] < 60 ? m[1].padStart(2, "0") + ":" + m[2] : "";
|
||||
};
|
||||
|
||||
// how the reasoning a try was sent at came to be
|
||||
function effortNote(r, tr) {
|
||||
@@ -1870,6 +1905,7 @@
|
||||
if (r.agents?.length) bits.push(r.agents.map((id) => (state.clients || state.agents || []).find((a) => a.id === id)?.name || id).join(" / "));
|
||||
if (r.intent) bits.push(t("asks for “{intent}”", { intent: r.intent }));
|
||||
if (r.compact) bits.push(t("compacting"));
|
||||
if (r.time) bits.push(timeText(r.time));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
@@ -1963,7 +1999,7 @@
|
||||
if (!g.ready) tags.append(el("span", "tag bad", t("no member ready")));
|
||||
const edit = el("button", "text", t("Edit"));
|
||||
edit.onclick = (e) => { e.stopPropagation(); open(); };
|
||||
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])] })) } }; renderGroups(); };
|
||||
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); };
|
||||
row.onclick = open;
|
||||
row.append(ics, main, tags, edit);
|
||||
return row;
|
||||
@@ -2173,7 +2209,47 @@
|
||||
const cp = el("button", "rt-cond" + (r.compact ? " on" : ""), t("compacting"));
|
||||
cp.title = t("The agent summarizes the conversation to go on in less room (Claude Code's /compact, Codex, OpenCode…): a cheaper, faster model can do it");
|
||||
cp.onclick = () => { r.compact = !r.compact; cp.classList.toggle("on", r.compact); warn(); };
|
||||
when.append(tk, im, ef, ag, it, cp);
|
||||
// hours of the day, on this computer's clock (a vendor's peak
|
||||
// hours, say): typed as 09:00 and 18:00, past midnight when the
|
||||
// second comes first; the days are picked like the agents
|
||||
const tm = el("span", "rt-cond tm");
|
||||
const tw = () => r.time || (r.time = { from: "", to: "", days: [] });
|
||||
const tFrom = input(r.time?.from || "", "09:00"), tTo = input(r.time?.to || "", "18:00");
|
||||
tm.title = t("The turn begins within these hours, on this computer's clock — a provider's peak-price hours sent to another, say");
|
||||
tm.onclick = (e) => { if (e.target === tm || e.target.tagName === "SPAN") (tFrom.value ? tTo : tFrom).focus({ preventScroll: true }); };
|
||||
const dayBtn = el("button", "rt-cond");
|
||||
const drawTime = () => {
|
||||
const w = r.time, from = clockOf(w?.from), to = clockOf(w?.to);
|
||||
const typed = !!(w && (w.from || w.to));
|
||||
tm.classList.toggle("on", !!(from && to));
|
||||
// a time typed that isn't one, once its field is left
|
||||
tm.classList.toggle("bad", [[tFrom, w?.from], [tTo, w?.to]].some(([b, v]) => v && !clockOf(v) && document.activeElement !== b));
|
||||
dayBtn.hidden = !typed && !w?.days?.length;
|
||||
dayBtn.textContent = daysText(w?.days) || t("every day");
|
||||
dayBtn.classList.toggle("on", !!daysText(w?.days));
|
||||
};
|
||||
for (const [box, key] of [[tFrom, "from"], [tTo, "to"]]) {
|
||||
box.className = "clock";
|
||||
box.maxLength = 5;
|
||||
box.inputMode = "numeric";
|
||||
box.oninput = () => { tw()[key] = box.value.trim(); drawTime(); warn(); };
|
||||
box.onblur = () => { const v = clockOf(box.value); if (v) { box.value = v; tw()[key] = v; } drawTime(); warn(); };
|
||||
box.onkeydown = (e) => e.stopPropagation();
|
||||
}
|
||||
tm.append(el("span", "", t("from")), tFrom, el("span", "", t("to")), tTo);
|
||||
const presets = [["", "every day", []], ["weekdays", "weekdays", WEEK.slice(0, 5)], ["weekends", "weekends", WEEK.slice(5)]];
|
||||
dayBtn.onclick = (ev) => pickFrom("days", dayBtn, ev, [
|
||||
...presets.map(([v, label, ds]) => ({ value: "=" + v, label: t(label), note: ds.length ? daysText(ds) : t("not a condition") })),
|
||||
...WEEK.map((d) => ({ value: d, label: t(DAY_NAMES[d]), note: r.time?.days?.includes(d) ? "✓" : "" })),
|
||||
], (v) => {
|
||||
const w = tw();
|
||||
if (v.startsWith("=")) w.days = [...presets.find(([id]) => "=" + id === v)[2]];
|
||||
else w.days = w.days.includes(v) ? w.days.filter((x) => x !== v) : WEEK.filter((x) => x === v || w.days.includes(x));
|
||||
if (w.days.length === 7) w.days = [];
|
||||
drawTime(); warn();
|
||||
});
|
||||
drawTime();
|
||||
when.append(tk, im, ef, ag, it, cp, tm, dayBtn);
|
||||
// the member it sends to
|
||||
const use = el("div", "rt-use");
|
||||
const ub = el("button", "rt-cond on");
|
||||
@@ -2188,6 +2264,8 @@
|
||||
if (r.tokens && m?.context && m.context < r.tokens) bits.push(t("it takes {n} tokens", { n: m.context.toLocaleString() }));
|
||||
// a summary longer than it takes goes by the next rule, or the group
|
||||
if (r.compact && m?.context && d.members.some((id) => (infoOf(id)?.context || 0) > m.context)) bits.push(t("longer conversations skip it: it takes {n} tokens", { n: m.context.toLocaleString() }));
|
||||
const from = clockOf(r.time?.from), to = clockOf(r.time?.to);
|
||||
if (from && to && to < from) bits.push(t("runs past midnight, into the next day"));
|
||||
hint.textContent = bits.join(" · ");
|
||||
};
|
||||
warn();
|
||||
@@ -2202,7 +2280,7 @@
|
||||
});
|
||||
drawClassifier();
|
||||
};
|
||||
rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false }); drawRules(); };
|
||||
rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false, time: null }); drawRules(); };
|
||||
// the classifier, once a rule has an intent or the effort is picked
|
||||
// per turn: the model asked which intent a turn's message is and how
|
||||
// hard it is. Jev (a decision provider's model) answers both in one
|
||||
@@ -2314,7 +2392,23 @@
|
||||
const save = () => {
|
||||
if (!d.members.length) { addBtn.focus({ preventScroll: true }); return status(t("A group needs a model in it"), "warn"); }
|
||||
d.rules.forEach((r) => { r.intent = (r.intent || "").trim(); });
|
||||
const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent && !r.compact);
|
||||
// hours: both times, or none and no days; days alone are all day
|
||||
const badTime = d.rules.findIndex((r) => {
|
||||
const w = r.time;
|
||||
if (!w || !w.from && !w.to && !w.days?.length) return r.time = null, false;
|
||||
if (!w.from && !w.to) { w.from = w.to = "00:00"; return false; }
|
||||
const from = clockOf(w.from), to = clockOf(w.to);
|
||||
if (!from || !to) return true;
|
||||
w.from = from; w.to = to;
|
||||
return from === to && !w.days?.length;
|
||||
});
|
||||
if (badTime >= 0) {
|
||||
const w = d.rules[badTime].time;
|
||||
return status(clockOf(w.from) && clockOf(w.from) === clockOf(w.to)
|
||||
? t("Rule {n}: its hours are the whole day — pick days, or other hours", { n: badTime + 1 })
|
||||
: t("Rule {n}: the hours are two times like 09:00 and 18:00", { n: badTime + 1 }), "warn");
|
||||
}
|
||||
const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent && !r.compact && !r.time);
|
||||
if (bare >= 0) return status(t("Rule {n} needs a condition", { n: bare + 1 }), "warn");
|
||||
if (d.rules.some((r) => r.intent) && !d.classifier) return status(t("Choose the model that tells which intent a message is"), "warn");
|
||||
if (d.effort === "auto" && !d.classifier) return status(t("Choose the model that rates how hard a turn is"), "warn");
|
||||
|
||||
@@ -651,10 +651,20 @@ container's own and to set `MAGPIE_PUBLIC_URL`. Outside a container nothing
|
||||
changes. No coloured left border; English and Chinese, Chromium and WebKit,
|
||||
with `/api/plugins` faked.
|
||||
|
||||
`routing-time.test.cjs` checks a routing group's rule for hours of the day
|
||||
(Discord: send a provider's peak-price hours, DeepSeek's or GLM's, to
|
||||
another). The group's rule and the trace read it as its hours and days
|
||||
(22:00–08:00 Mon–Fri; 周一–周五 22:00–08:00); in the editor the hours are two
|
||||
times typed into one pill, a click on it types the first and moves nothing,
|
||||
past midnight is noted, a time that isn't one is marked once left, the days
|
||||
are picked like the agents (weekdays, then Saturday too: Mon–Sat), and the
|
||||
rule is saved as `time: {from, to, days}`. English and Chinese, Chromium and
|
||||
WebKit, with `/api/plugins` faked.
|
||||
|
||||
With Node.js and Playwright available:
|
||||
|
||||
```sh
|
||||
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs
|
||||
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs
|
||||
```
|
||||
|
||||
If Playwright is installed outside the repository, set `NODE_PATH` to the
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A routing group's rule may hold only at some hours of the day (a Discord
|
||||
// user: send a provider's peak-price hours — DeepSeek's, GLM's — to
|
||||
// another). A group's rule for them reads as its hours and days; the trace
|
||||
// tells the rule that matched by them; in the editor the hours are two
|
||||
// times typed into one pill, past midnight noted, the days picked like the
|
||||
// agents, a time that isn't one marked, nothing moved by a click, and they
|
||||
// are saved as time:{from,to,days}. In English and Chinese.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const models = [
|
||||
{ id: "a/glm", name: "glm", providerName: "A", icon: "generic", context: 200000, ready: true },
|
||||
{ id: "b/other", name: "other", providerName: "B", icon: "generic", context: 200000, ready: true },
|
||||
];
|
||||
const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri"];
|
||||
const groups = () => ({
|
||||
models,
|
||||
groups: [{ id: "main", name: "Main", members: ["a/glm", "b/other"], routing: "order", ready: true,
|
||||
memberInfo: [{ id: "a/glm", ready: true, context: 200000 }, { id: "b/other", ready: true, context: 200000 }],
|
||||
rules: [{ use: "b/other", time: { from: "22:00", to: "08:00", days: WEEKDAYS } }] }],
|
||||
pools: [],
|
||||
});
|
||||
const now = Date.now();
|
||||
const route = {
|
||||
id: 7, seq: 7, time: new Date(now - 2000).toISOString(), agent: "claude", model: "main", provider: "b", done: true, status: 200, ms: 900, tokens: 9000,
|
||||
group: { id: "main", name: "Main", members: ["a/glm", "b/other"] },
|
||||
order: [{ id: "b", provider: "b", name: "B", kind: "key", model: "other", known: true }, { id: "a", provider: "a", name: "A", kind: "key", model: "glm", known: true }],
|
||||
tries: [{ id: "b", model: "other", start: new Date(now - 2000).toISOString(), done: true, ms: 800, status: 200 }],
|
||||
rule: { n: 1, use: "b/other", when: ["time 22:00–08:00 Mon–Fri"], turn: 2, tokens: 9000 },
|
||||
};
|
||||
|
||||
const words = {
|
||||
en: { cond: "22:00–08:00 Mon–Fri", story: "rule 1 matches — 22:00–08:00 Mon–Fri —", night: "runs past midnight, into the next day", every: "every day", weekdays: "weekdays", sat: "Sat", monSat: "Mon–Sat", edit: "Edit", add: "Add a rule", save: "Save" },
|
||||
zh: { cond: "周一–周五 22:00–08:00", story: "规则 1", night: "跨过午夜,到第二天", every: "每天", weekdays: "工作日", sat: "周六", monSat: "周一–周六", edit: "编辑", add: "添加规则", save: "保存" },
|
||||
};
|
||||
|
||||
function serve(lang, posts) {
|
||||
const state = { agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
|
||||
let first = true;
|
||||
return async (r) => {
|
||||
const url = new URL(r.request().url());
|
||||
const json = (data) => r.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json(state);
|
||||
if (url.pathname === "/api/gateway/trace") {
|
||||
if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes
|
||||
const routes = first ? [route] : [];
|
||||
first = false;
|
||||
return json({ mine: true, now: new Date().toISOString(), seq: 7, totals: { requests: 1, rerouted: 0, errors: 0 }, routes });
|
||||
}
|
||||
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/groups") return json(groups());
|
||||
if (url.pathname.startsWith("/api/groups/")) {
|
||||
posts.push({ path: url.pathname, body: JSON.parse(r.request().postData() || "{}") });
|
||||
return json(groups());
|
||||
}
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } });
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await r.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: a rule for hours of the day`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 } })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [], posts = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, posts));
|
||||
await page.goto("http://magpie.test/?view=routing");
|
||||
const group = page.locator(".rt-group").first();
|
||||
await group.waitFor();
|
||||
|
||||
// the trace: the rule that matched, told by its hours
|
||||
await page.waitForFunction((s) => document.documentElement.innerText.includes(s), w.cond);
|
||||
const said = await page.evaluate(() => document.documentElement.innerText);
|
||||
assert(said.includes(w.story), `the rule that matched is told: ${said.slice(0, 400)}`);
|
||||
|
||||
// the group's rule, in words
|
||||
const title = await group.evaluate((g) => [...g.querySelectorAll("[title]")].map((e) => e.title).join("\n"));
|
||||
assert(title.includes(`1. ${w.cond} →`), `the rule reads as its hours: ${title}`);
|
||||
|
||||
// the editor: the hours in their pill, the days on theirs, past midnight noted
|
||||
await group.locator("button", { hasText: w.edit }).click();
|
||||
const ed = page.locator(".rt-gedit");
|
||||
const rows = ed.locator(".rt-rule");
|
||||
await rows.first().waitFor();
|
||||
const tm1 = rows.first().locator(".rt-cond.tm");
|
||||
assert.equal(await tm1.getAttribute("class"), "rt-cond tm on");
|
||||
assert.deepEqual(await tm1.locator("input").evaluateAll((xs) => xs.map((x) => x.value)), ["22:00", "08:00"]);
|
||||
const days1 = rows.first().locator("button.rt-cond", { hasText: lang === "en" ? "Mon–Fri" : "周一–周五" });
|
||||
assert.equal(await days1.getAttribute("class"), "rt-cond on");
|
||||
assert.equal(await rows.first().locator(".rt-rwarn").textContent(), w.night);
|
||||
|
||||
// a second rule: its hours typed, nothing moved by a click
|
||||
await ed.locator("button", { hasText: w.add }).click();
|
||||
const row2 = rows.nth(1);
|
||||
const tm2 = row2.locator(".rt-cond.tm");
|
||||
await tm2.waitFor();
|
||||
const dayBtn2 = row2.locator("button.rt-cond", { hasText: w.every });
|
||||
assert.equal(await dayBtn2.isVisible(), false, "no days until hours are typed");
|
||||
const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";") + "|" + Math.round(document.querySelector(".rt-gedit").getBoundingClientRect().top));
|
||||
await tm2.scrollIntoViewIfNeeded(); // by the test, as the reader would
|
||||
await page.waitForTimeout(200);
|
||||
const before = await at();
|
||||
const label = tm2.locator("span").first();
|
||||
await label.click();
|
||||
assert.equal(await at(), before, "the click moved nothing");
|
||||
const [from2, to2] = [tm2.locator("input").nth(0), tm2.locator("input").nth(1)];
|
||||
assert.equal(await from2.evaluate((x) => x === document.activeElement), true, "a click on the pill types its first time");
|
||||
await page.keyboard.type("25:00");
|
||||
await to2.click();
|
||||
assert.match(await tm2.getAttribute("class"), /\bbad\b/, "a time that isn't one is marked once left");
|
||||
assert.equal(await at(), before, "the click moved nothing");
|
||||
await from2.fill("9:00");
|
||||
await to2.fill("18:00");
|
||||
await to2.blur();
|
||||
assert.equal(await from2.inputValue(), "09:00");
|
||||
assert.equal(await tm2.getAttribute("class"), "rt-cond tm on");
|
||||
assert.equal(await row2.locator(".rt-rwarn").textContent(), "");
|
||||
|
||||
// the days: weekdays, and Saturday too
|
||||
await dayBtn2.waitFor();
|
||||
await dayBtn2.click();
|
||||
assert.equal(await at(), before, "the click moved nothing");
|
||||
await page.locator("#list li").filter({ hasText: w.weekdays }).first().click();
|
||||
const days2 = row2.locator(".when > button.rt-cond").last();
|
||||
assert.equal(await days2.textContent(), lang === "en" ? "Mon–Fri" : "周一–周五");
|
||||
await days2.click();
|
||||
await page.locator("#list li").filter({ hasText: w.sat }).last().click(); // the day's own, after weekends
|
||||
assert.equal(await days2.textContent(), w.monSat);
|
||||
assert.equal(await days2.getAttribute("class"), "rt-cond on");
|
||||
|
||||
// saved: time:{from,to,days}, neither rule refused as having no condition
|
||||
const saveBtn = ed.locator("button.primary", { hasText: w.save });
|
||||
await page.mouse.move(550, 600);
|
||||
await page.mouse.wheel(0, 600);
|
||||
await page.waitForTimeout(300);
|
||||
const sb = await saveBtn.boundingBox();
|
||||
await page.mouse.click(sb.x + sb.width / 2, sb.y + sb.height / 2);
|
||||
await page.waitForFunction(() => !document.querySelector(".rt-gedit"));
|
||||
const save = posts.find((p) => p.path === "/api/groups/save");
|
||||
assert(save, `saved: ${JSON.stringify(posts)}`);
|
||||
assert.deepEqual(save.body.rules.map((r) => [r.use, r.time]), [
|
||||
["b/other", { from: "22:00", to: "08:00", days: WEEKDAYS }],
|
||||
["b/other", { from: "09:00", to: "18:00", days: [...WEEKDAYS, "sat"] }],
|
||||
]);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ package provider
|
||||
// seen in the request itself — how long it is, whether it carries an
|
||||
// image, how hard the agent asked the model to think, which agent sent it.
|
||||
// Those are never guessed: the same request is always routed the same way,
|
||||
// and the trace tells which rule did it. The one exception is a rule with
|
||||
// and the trace tells which rule did it. A rule may also hold only at some
|
||||
// hours of the day (a vendor's peak-price hours, say), on magpie's clock. The one exception is a rule with
|
||||
// an intent (what the user asks for, in the user's words): as a turn
|
||||
// begins, a model the group names (its Classifier) is asked which of the
|
||||
// intents the user's message is, once, and the trace tells what it said.
|
||||
@@ -18,7 +19,9 @@ package provider
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Efforts are the reasoning levels a rule can ask for, lowest first.
|
||||
@@ -48,6 +51,151 @@ type Rule struct {
|
||||
// Codex's, OpenCode's, Pi's…) — which a cheaper, faster model can do
|
||||
// in place of the one the conversation is on.
|
||||
Compact bool `json:"compact,omitempty"`
|
||||
// Time: the turn begins within these hours of the day, local time — a
|
||||
// vendor's peak-price hours sent to another, say.
|
||||
Time *TimeWindow `json:"time,omitempty"`
|
||||
}
|
||||
|
||||
// TimeWindow is hours of the day, local time: From until To, "HH:MM" each,
|
||||
// past midnight when To comes before From (22:00–08:00), the whole day
|
||||
// when they are the same. Days, when set, are the days it holds on ("mon"
|
||||
// … "sun"); a window past midnight is of the day it begins on, so Friday's
|
||||
// 22:00–08:00 takes Saturday's early hours too.
|
||||
type TimeWindow struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Days []string `json:"days,omitempty"`
|
||||
}
|
||||
|
||||
// Weekdays are the days a TimeWindow names, as time.Weekday counts them.
|
||||
var Weekdays = []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}
|
||||
|
||||
// dayOrder is the week as a window shows it, Monday first.
|
||||
var dayOrder = []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
|
||||
|
||||
// clockMinutes reads "HH:MM" (or "H:MM") as minutes past midnight.
|
||||
func clockMinutes(s string) (int, bool) {
|
||||
h, m, ok := strings.Cut(strings.TrimSpace(s), ":")
|
||||
if !ok || len(h) < 1 || len(h) > 2 || len(m) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
hh, err1 := strconv.Atoi(h)
|
||||
mm, err2 := strconv.Atoi(m)
|
||||
if err1 != nil || err2 != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 || h[0] == '+' || m[0] == '+' {
|
||||
return 0, false
|
||||
}
|
||||
return hh*60 + mm, true
|
||||
}
|
||||
|
||||
// ParseDay reads a day of the week, "mon" or "Monday", as Weekdays has it.
|
||||
func ParseDay(s string) (string, bool) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if len(s) < 3 {
|
||||
return "", false
|
||||
}
|
||||
for i, d := range Weekdays {
|
||||
if strings.HasPrefix(strings.ToLower(time.Weekday(i).String()), s) {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// clean checks a window and puts it the one way: "09:00", days known,
|
||||
// each once in the week's order, none when it is every day.
|
||||
func (w *TimeWindow) clean() error {
|
||||
from, ok := clockMinutes(w.From)
|
||||
if !ok {
|
||||
return fmt.Errorf("the time %q is not HH:MM (09:00)", w.From)
|
||||
}
|
||||
to, ok := clockMinutes(w.To)
|
||||
if !ok {
|
||||
return fmt.Errorf("the time %q is not HH:MM (18:00)", w.To)
|
||||
}
|
||||
w.From, w.To = fmt.Sprintf("%02d:%02d", from/60, from%60), fmt.Sprintf("%02d:%02d", to/60, to%60)
|
||||
var days []string
|
||||
for _, d := range w.Days {
|
||||
v, ok := ParseDay(d)
|
||||
if !ok {
|
||||
return fmt.Errorf("%q is not a day of the week (mon … sun)", d)
|
||||
}
|
||||
days = append(days, v)
|
||||
}
|
||||
w.Days = nil
|
||||
for _, d := range dayOrder {
|
||||
if slices.Contains(days, d) {
|
||||
w.Days = append(w.Days, d)
|
||||
}
|
||||
}
|
||||
if len(w.Days) == len(dayOrder) {
|
||||
w.Days = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Holds reports whether at is within the window, on the machine's clock.
|
||||
func (w TimeWindow) Holds(at time.Time) bool {
|
||||
from, ok1 := clockMinutes(w.From)
|
||||
to, ok2 := clockMinutes(w.To)
|
||||
if at.IsZero() || !ok1 || !ok2 {
|
||||
return false
|
||||
}
|
||||
at = at.Local()
|
||||
m, day := at.Hour()*60+at.Minute(), at.Weekday()
|
||||
switch {
|
||||
case from == to: // the whole day
|
||||
case from < to:
|
||||
if m < from || m >= to {
|
||||
return false
|
||||
}
|
||||
default: // past midnight
|
||||
if m >= to && m < from {
|
||||
return false
|
||||
}
|
||||
if m < to {
|
||||
day = (day + 6) % 7 // the window began the day before
|
||||
}
|
||||
}
|
||||
return len(w.Days) == 0 || slices.Contains(w.Days, Weekdays[day])
|
||||
}
|
||||
|
||||
// DaysText is the window's days for a list: "Mon–Fri", "Sat,Sun",
|
||||
// "Mon,Wed–Fri"; "" for every day.
|
||||
func (w TimeWindow) DaysText() string {
|
||||
name := func(d string) string { return strings.ToUpper(d[:1]) + d[1:] }
|
||||
var out []string
|
||||
for i := 0; i < len(dayOrder); {
|
||||
if !slices.Contains(w.Days, dayOrder[i]) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j+1 < len(dayOrder) && slices.Contains(w.Days, dayOrder[j+1]) {
|
||||
j++
|
||||
}
|
||||
switch j - i {
|
||||
case 0:
|
||||
out = append(out, name(dayOrder[i]))
|
||||
case 1:
|
||||
out = append(out, name(dayOrder[i]), name(dayOrder[j]))
|
||||
default:
|
||||
out = append(out, name(dayOrder[i])+"–"+name(dayOrder[j]))
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// Text is the window as a rule's conditions show it: "09:00–18:00 Mon–Fri".
|
||||
func (w TimeWindow) Text() string {
|
||||
s := w.From + "–" + w.To
|
||||
if w.From == w.To {
|
||||
s = "all day"
|
||||
}
|
||||
if d := w.DaysText(); d != "" {
|
||||
s += " " + d
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// MaxIntent is how long an intent may be, in characters.
|
||||
@@ -78,6 +226,9 @@ func (r Rule) Conditions() []string {
|
||||
if r.Compact {
|
||||
out = append(out, "compacting")
|
||||
}
|
||||
if r.Time != nil {
|
||||
out = append(out, "time "+r.Time.Text())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -92,6 +243,16 @@ func cleanRules(rules []Rule, members []string) ([]Rule, error) {
|
||||
r.Agents[j] = strings.ToLower(r.Agents[j])
|
||||
}
|
||||
n := i + 1
|
||||
if r.Time != nil {
|
||||
w := *r.Time
|
||||
if err := w.clean(); err != nil {
|
||||
return nil, fmt.Errorf("rule %d: %w", n, err)
|
||||
}
|
||||
if w.From == w.To && len(w.Days) == 0 {
|
||||
return nil, fmt.Errorf("rule %d: its hours are the whole day, every day: give days or other hours", n)
|
||||
}
|
||||
r.Time = &w
|
||||
}
|
||||
switch {
|
||||
case r.Use == "":
|
||||
return nil, fmt.Errorf("rule %d: which model it sends to is missing", n)
|
||||
@@ -104,7 +265,7 @@ func cleanRules(rules []Rule, members []string) ([]Rule, error) {
|
||||
case len([]rune(r.Intent)) > MaxIntent:
|
||||
return nil, fmt.Errorf("rule %d: an intent is at most %d characters", n, MaxIntent)
|
||||
case len(r.Conditions()) == 0:
|
||||
return nil, fmt.Errorf("rule %d: it needs a condition (tokens, images, effort, agents, intent or compacting)", n)
|
||||
return nil, fmt.Errorf("rule %d: it needs a condition (tokens, images, effort, agents, intent, compacting or time)", n)
|
||||
}
|
||||
if len(r.Agents) == 0 {
|
||||
r.Agents = nil
|
||||
@@ -126,6 +287,9 @@ type RuleRequest struct {
|
||||
Intent string
|
||||
// Compact: the request is the agent compacting its conversation.
|
||||
Compact bool
|
||||
// At is when the request came, for a rule's hours; a zero time is in
|
||||
// none.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Matches reports whether the request is one the rule is for.
|
||||
@@ -148,6 +312,9 @@ func (r Rule) MatchesBesidesIntent(q RuleRequest) bool {
|
||||
if r.Compact && !q.Compact {
|
||||
return false
|
||||
}
|
||||
if r.Time != nil && !r.Time.Holds(q.At) {
|
||||
return false
|
||||
}
|
||||
switch r.Effort {
|
||||
case "":
|
||||
case "on":
|
||||
@@ -244,11 +411,11 @@ func ruledEntry(e *Entry, g Group, ms []Member, entries []Entry) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if r.Images && r.Tokens == 0 && r.Effort == "" && len(r.Agents) == 0 && r.Intent == "" && !r.Compact && sees(x) && !e.Images &&
|
||||
if r.Images && r.Tokens == 0 && r.Effort == "" && len(r.Agents) == 0 && r.Intent == "" && !r.Compact && r.Time == nil && sees(x) && !e.Images &&
|
||||
!slices.ContainsFunc(g.Rules[:i], func(b Rule) bool { y, ok := of(b.Use); return !ok || !sees(y) }) {
|
||||
e.Images, e.ImageInput = true, x.ImageInput
|
||||
}
|
||||
if r.Tokens == 0 || r.Images || r.Effort != "" || len(r.Agents) > 0 || r.Intent != "" || r.Compact || x.Context <= e.Context {
|
||||
if r.Tokens == 0 || r.Images || r.Effort != "" || len(r.Agents) > 0 || r.Intent != "" || r.Compact || r.Time != nil || x.Context <= e.Context {
|
||||
continue // only a rule of length alone takes every long request
|
||||
}
|
||||
// every request up to the rule's length must fit whoever may get
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// at is a local time on the week of Monday 2026-09-28.
|
||||
func at(day, hour, min int) time.Time {
|
||||
return time.Date(2026, 9, 27+day, hour, min, 0, 0, time.Local) // day 0 is Sunday
|
||||
}
|
||||
|
||||
// A rule's hours hold on the machine's clock, past midnight when they end
|
||||
// before they begin, and on the day they begin on.
|
||||
func TestRuleTimeWindow(t *testing.T) {
|
||||
day := TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}}
|
||||
night := TimeWindow{From: "22:00", To: "08:00", Days: []string{"fri"}}
|
||||
whole := TimeWindow{From: "00:00", To: "00:00", Days: []string{"sat", "sun"}}
|
||||
for _, c := range []struct {
|
||||
w TimeWindow
|
||||
at time.Time
|
||||
want bool
|
||||
}{
|
||||
{day, at(1, 9, 0), true}, // Monday, as it opens
|
||||
{day, at(1, 17, 59), true}, // Monday, before it ends
|
||||
{day, at(1, 18, 0), false}, // as it ends
|
||||
{day, at(1, 8, 59), false}, // before
|
||||
{day, at(6, 12, 0), false}, // Saturday
|
||||
{night, at(5, 23, 0), true}, // Friday night
|
||||
{night, at(6, 3, 0), true}, // Saturday's early hours: Friday's window
|
||||
{night, at(6, 8, 0), false}, // as it ends
|
||||
{night, at(5, 3, 0), false}, // Friday's early hours: Thursday's window
|
||||
{night, at(6, 23, 0), false},
|
||||
{whole, at(0, 0, 0), true},
|
||||
{whole, at(6, 23, 59), true},
|
||||
{whole, at(1, 12, 0), false},
|
||||
{TimeWindow{From: "22:00", To: "08:00"}, at(3, 7, 0), true},
|
||||
{TimeWindow{From: "22:00", To: "08:00"}, at(3, 12, 0), false},
|
||||
{day, time.Time{}, false}, // no time is in no hours
|
||||
} {
|
||||
if got := c.w.Holds(c.at); got != c.want {
|
||||
t.Errorf("%s at %s: %v, want %v", c.w.Text(), c.at.Format("Mon 15:04"), got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleTimeCleanAndConditions(t *testing.T) {
|
||||
rules, err := cleanRules([]Rule{
|
||||
{Use: "a/x", Time: &TimeWindow{From: "9:00", To: "18:00", Days: []string{"Friday", "mon", "tue", "wed", "thu", "mon"}}},
|
||||
{Use: "a/x", Time: &TimeWindow{From: "22:00", To: "08:00"}},
|
||||
{Use: "a/x", Time: &TimeWindow{From: "00:00", To: "00:00", Days: []string{"sun", "sat"}}},
|
||||
{Use: "a/x", Time: &TimeWindow{From: "08:00", To: "12:00", Days: []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}}},
|
||||
{Use: "a/x", Tokens: 100, Time: &TimeWindow{From: "08:00", To: "12:00", Days: []string{"mon", "wed", "thu", "fri"}}},
|
||||
}, []string{"a/x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"time 09:00–18:00 Mon–Fri", "time 22:00–08:00", "time all day Sat,Sun", "time 08:00–12:00", "tokens ≥ 100 · time 08:00–12:00 Mon,Wed–Fri"}
|
||||
for i, r := range rules {
|
||||
if got := strings.Join(r.Conditions(), " · "); got != want[i] {
|
||||
t.Errorf("rule %d: %q, want %q", i+1, got, want[i])
|
||||
}
|
||||
}
|
||||
if d := rules[0].Time.Days; strings.Join(d, ",") != "mon,tue,wed,thu,fri" || rules[0].Time.From != "09:00" {
|
||||
t.Errorf("cleaned: %+v", rules[0].Time)
|
||||
}
|
||||
if rules[3].Time.Days != nil {
|
||||
t.Errorf("every day is no days: %+v", rules[3].Time)
|
||||
}
|
||||
for _, bad := range []TimeWindow{
|
||||
{From: "25:00", To: "08:00"},
|
||||
{From: "09:00", To: "8"},
|
||||
{From: "", To: "08:00"},
|
||||
{From: "09:60", To: "10:00"},
|
||||
{From: "09:00", To: "18:00", Days: []string{"funday"}},
|
||||
{From: "10:00", To: "10:00"}, // the whole day, every day: no condition
|
||||
} {
|
||||
if _, err := cleanRules([]Rule{{Use: "a/x", Time: &bad}}, []string{"a/x"}); err == nil {
|
||||
t.Errorf("%+v: no error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A rule with hours matches a request inside them only; one without a
|
||||
// time (an older magpie's) is in none.
|
||||
func TestRuleTimeMatches(t *testing.T) {
|
||||
r := Rule{Use: "a/x", Time: &TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}}}
|
||||
if !r.Matches(RuleRequest{At: at(2, 10, 0)}) {
|
||||
t.Error("inside the hours: no match")
|
||||
}
|
||||
if r.Matches(RuleRequest{At: at(2, 19, 0)}) || r.Matches(RuleRequest{At: at(0, 10, 0)}) || r.Matches(RuleRequest{}) {
|
||||
t.Error("outside the hours: a match")
|
||||
}
|
||||
if i := MatchRule([]Rule{r, {Use: "a/y", Tokens: 10}}, RuleRequest{Tokens: 20, At: at(2, 20, 0)}); i != 1 {
|
||||
t.Errorf("after hours: rule %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Stored with omitempty: a rule without hours is written as before, and a
|
||||
// window round-trips.
|
||||
func TestRuleTimeJSON(t *testing.T) {
|
||||
b, _ := json.Marshal(Rule{Use: "a/x", Tokens: 5})
|
||||
if strings.Contains(string(b), "time") {
|
||||
t.Errorf("no hours written: %s", b)
|
||||
}
|
||||
b, _ = json.Marshal(Rule{Use: "a/x", Time: &TimeWindow{From: "22:00", To: "08:00"}})
|
||||
if string(b) != `{"use":"a/x","time":{"from":"22:00","to":"08:00"}}` {
|
||||
t.Errorf("%s", b)
|
||||
}
|
||||
var r Rule
|
||||
if err := json.Unmarshal(b, &r); err != nil || r.Time == nil || r.Time.From != "22:00" {
|
||||
t.Errorf("%v %+v", err, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Typed: time=… days=… — read back the same.
|
||||
func TestParseRuleTime(t *testing.T) {
|
||||
g := Group{ID: "g", Members: []string{"a/x", "b/y"}}
|
||||
for _, c := range []struct{ line, want, cond string }{
|
||||
{"use=b/y time=09:00-18:00 days=mon-fri", "use=b/y time=09:00-18:00 days=mon-fri", "time 09:00–18:00 Mon–Fri"},
|
||||
{"use=b/y days=sat,sun", "use=b/y days=sat,sun", "time all day Sat,Sun"},
|
||||
{"use=b/y days=fri-mon time=22:00–08:00", "use=b/y time=22:00-08:00 days=mon,fri-sun", "time 22:00–08:00 Mon,Fri–Sun"},
|
||||
{"use=b/y time=22:00-08:00", "use=b/y time=22:00-08:00", "time 22:00–08:00"},
|
||||
} {
|
||||
r, _, _, err := ParseRule(g, RuleWords(c.line))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.line, err)
|
||||
}
|
||||
rs, err := cleanRules([]Rule{r}, g.Members)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.line, err)
|
||||
}
|
||||
if got := rs[0].Line(); got != c.want {
|
||||
t.Errorf("%s: line %q, want %q", c.line, got, c.want)
|
||||
}
|
||||
if got := strings.Join(rs[0].Conditions(), " · "); got != c.cond {
|
||||
t.Errorf("%s: %q, want %q", c.line, got, c.cond)
|
||||
}
|
||||
again, _, _, err := ParseRule(g, RuleWords(rs[0].Line()))
|
||||
if err != nil || again.Time == nil || again.Time.From != rs[0].Time.From || again.Time.To != rs[0].Time.To {
|
||||
t.Errorf("%s: read back %v %+v", c.line, err, again.Time)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"use=b/y time=9-18", "use=b/y time=09:00", "use=b/y days=someday", "use=b/y days="} {
|
||||
if _, _, _, err := ParseRule(g, RuleWords(bad)); err == nil {
|
||||
t.Errorf("%s: no error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package provider
|
||||
|
||||
// A rule as it is typed: use=<model> tokens=200k images effort=high
|
||||
// agents=a,b intent="…" compact — by magpie group rule and the TUI's routing page.
|
||||
// agents=a,b intent="…" compact time=09:00-18:00 days=mon-fri — by magpie
|
||||
// group rule and the TUI's routing page.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -26,6 +28,50 @@ func ParseTokens(v string) (int, error) {
|
||||
return int(f * mul), nil
|
||||
}
|
||||
|
||||
// ParseDays reads days of the week: "mon-fri", "sat,sun", "mon,wed-fri";
|
||||
// a range may run past Sunday (fri-mon).
|
||||
func ParseDays(v string) ([]string, error) {
|
||||
var out []string
|
||||
for _, part := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) {
|
||||
a, b, isRange := strings.Cut(strings.ReplaceAll(part, "–", "-"), "-")
|
||||
from, ok := ParseDay(a)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("days: %q is not a day of the week (mon … sun)", a)
|
||||
}
|
||||
to := from
|
||||
if isRange {
|
||||
if to, ok = ParseDay(b); !ok {
|
||||
return nil, fmt.Errorf("days: %q is not a day of the week (mon … sun)", b)
|
||||
}
|
||||
}
|
||||
for i := slices.Index(Weekdays, from); ; i = (i + 1) % 7 {
|
||||
if !slices.Contains(out, Weekdays[i]) {
|
||||
out = append(out, Weekdays[i])
|
||||
}
|
||||
if Weekdays[i] == to {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("days names the days of the week it holds on: days=mon-fri")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParseTimeWindow reads hours of the day: 09:00-18:00, 22:00-08:00.
|
||||
func ParseTimeWindow(v string) (*TimeWindow, error) {
|
||||
from, to, ok := strings.Cut(strings.ReplaceAll(strings.TrimSpace(v), "–", "-"), "-")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("time is hours of the day, local time: time=09:00-18:00 (or 22:00-08:00, past midnight), not %q", v)
|
||||
}
|
||||
w := &TimeWindow{From: strings.TrimSpace(from), To: strings.TrimSpace(to)}
|
||||
if err := w.clean(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// UnknownRuleWord starts the error for a word ParseRule doesn't know.
|
||||
const UnknownRuleWord = "unknown "
|
||||
|
||||
@@ -79,6 +125,24 @@ func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err
|
||||
if r.Intent == "" {
|
||||
return r, 0, "", fmt.Errorf(`intent says what the message asks for: intent="writing or fixing tests"`)
|
||||
}
|
||||
case "time", "hours", "between":
|
||||
w, err := ParseTimeWindow(v)
|
||||
if err != nil {
|
||||
return r, 0, "", err
|
||||
}
|
||||
if r.Time != nil {
|
||||
w.Days = r.Time.Days
|
||||
}
|
||||
r.Time = w
|
||||
case "days", "day":
|
||||
days, err := ParseDays(v)
|
||||
if err != nil {
|
||||
return r, 0, "", err
|
||||
}
|
||||
if r.Time == nil {
|
||||
r.Time = &TimeWindow{From: "00:00", To: "00:00"} // the whole day, unless time= says
|
||||
}
|
||||
r.Time.Days = days
|
||||
case "classifier", "classify", "by":
|
||||
if classifier = strings.TrimSpace(v); classifier == "" {
|
||||
return r, 0, "", fmt.Errorf("classifier=<model>: the model that tells which intent a message is")
|
||||
@@ -90,7 +154,7 @@ func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err
|
||||
}
|
||||
at = n
|
||||
default:
|
||||
return r, 0, "", fmt.Errorf(UnknownRuleWord+"%q (use, tokens, images, effort, agents, intent, compact, classifier, at)", w)
|
||||
return r, 0, "", fmt.Errorf(UnknownRuleWord+"%q (use, tokens, images, effort, agents, intent, compact, time, days, classifier, at)", w)
|
||||
}
|
||||
}
|
||||
if r.Use == "" {
|
||||
@@ -183,5 +247,13 @@ func (r Rule) Line() string {
|
||||
if r.Compact {
|
||||
out = append(out, "compact")
|
||||
}
|
||||
if r.Time != nil {
|
||||
if r.Time.From != r.Time.To || len(r.Time.Days) == 0 {
|
||||
out = append(out, "time="+r.Time.From+"-"+r.Time.To)
|
||||
}
|
||||
if len(r.Time.Days) > 0 {
|
||||
out = append(out, "days="+strings.ReplaceAll(strings.ToLower(r.Time.DaysText()), "–", "-"))
|
||||
}
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
@@ -263,7 +263,8 @@ func (m *model) openClassifier(g provider.Group) {
|
||||
|
||||
// ruleHint is what a rule is typed as.
|
||||
const ruleHint = `use=<model>, and any of: tokens=200k · images · effort=on|low|medium|high|xhigh|max · agents=codex,claude
|
||||
intent="a quick question" (the group's classifier=<model> tells it) · compact · at=<n> for its place`
|
||||
intent="a quick question" (the group's classifier=<model> tells it) · compact
|
||||
time=09:00-18:00 (local; 22:00-08:00 runs past midnight) · days=mon-fri · at=<n> for its place`
|
||||
|
||||
// openRule asks for a rule: a new one, or rule i (from 0) typed again.
|
||||
func (m *model) openRule(g provider.Group, i int) {
|
||||
|
||||
Reference in new issue
Block a user