feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
按 design/04 §3.2(thinking 语义,showToUser 三态)+ design/01 §2.1 + 主人两条定论(心情跟角色不跟会话;mood 初值走口径 ①:由角色读端点暴露、前端加载播种)。 - 管理侧 GET /admin/personas 的 PersonaSummary 增 thinking(budgetTokens/effort/mode/showToUser)——**showToUser 保持三态**(未配置 ≠ false) - 用户侧 GET /personas 增**角色级 mood**(取自 emotion 层该角色当前状态,非从消息推断)→ 前端加载播种 currentMood,消除「刷新后心情徽标短暂为空」 - 前端 Personas 页加 thinking 四控件(预算/effort/模式/showToUser 三态):只做输入校验,**provider 是否真支持由后端 WARN 决定,前端不假装能力** - 先拆 useAppStore 选择器(原 494/500)到独立模块再做,避免越线 - Docs/api-client 版本号 +1(两处契约 + 三态与角色级 mood 语义) - 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + 前端 build 绿 + personaSession/messageSync 逻辑测试通过
This commit is contained in:
1 parent
6dc1f218d5
commit
2aa3233f84
20 files changed
+1042
-61
No files matched your search
@@ -107,9 +107,10 @@ type AdminBackthinkItem struct {
|
||||
At int64 `json:"at"`
|
||||
}
|
||||
|
||||
// adminListPersonas lists persona summaries (admin read).
|
||||
// adminListPersonas lists persona summaries (admin read). It is the admin shape:
|
||||
// the role-level mood plus the `thinking` knobs the panel edits.
|
||||
func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.collectPersonas())
|
||||
writeJSON(w, http.StatusOK, s.collectPersonas(true))
|
||||
}
|
||||
|
||||
// adminListSessions lists the persistent chat-history sessions (main + sub) when a
|
||||
|
||||
@@ -100,7 +100,7 @@ func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "persona.create", "ok")
|
||||
writeJSON(w, http.StatusCreated, personaSummaryOf(p, characterFromRequest(req)))
|
||||
writeJSON(w, http.StatusCreated, s.personaSummaryOf(p, characterFromRequest(req)))
|
||||
}
|
||||
|
||||
// adminUpdatePersona edits an existing persona, persisting the YAML and
|
||||
@@ -157,7 +157,7 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "persona.update", "ok")
|
||||
writeJSON(w, http.StatusOK, personaSummaryOf(p, characterFromRequest(req)))
|
||||
writeJSON(w, http.StatusOK, s.personaSummaryOf(p, characterFromRequest(req)))
|
||||
}
|
||||
|
||||
// adminCreateUser answers honestly: there is no persistent user table and no
|
||||
@@ -300,8 +300,11 @@ func characterFromRequest(req personaUpsertRequest) *personareg.Character {
|
||||
return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes}
|
||||
}
|
||||
|
||||
// personaSummaryOf maps a persona+character to its wire summary.
|
||||
func personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
|
||||
// personaSummaryOf maps a persona+character to its wire summary. It is the same
|
||||
// shape the admin list returns — the role-level mood plus the `thinking` block —
|
||||
// so a write response and a subsequent read describe the persona identically
|
||||
// (a client can trust either for backfill).
|
||||
func (s *Server) personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
|
||||
return PersonaSummary{
|
||||
ID: p.ID,
|
||||
Name: c.Name,
|
||||
@@ -309,6 +312,8 @@ func personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary
|
||||
IsDefault: c.Default,
|
||||
Mode: firstMode(c.Modes),
|
||||
MemoryNS: p.MemoryNamespace(),
|
||||
Mood: s.moodOf(p.ID),
|
||||
Thinking: thinkingSummaryOf(p.Thinking, true),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
@@ -74,6 +75,17 @@ type PersonaStore interface {
|
||||
Persona(id string) (*loader.Persona, bool)
|
||||
}
|
||||
|
||||
// PersonaMoodStore is the *optional* capability of a persona store that also owns
|
||||
// the per-persona emotion state (design/01 §2: emotion follows the persona). The
|
||||
// production *personareg.Registry implements it; the gateway reads a role's mood
|
||||
// through an assertion on this narrow interface so a store that only lists
|
||||
// personas (a test double, a future read-only catalog) keeps satisfying
|
||||
// PersonaStore without owning an emotion machine. When a store does not implement
|
||||
// it, a role's mood simply reads as "" rather than being invented.
|
||||
type PersonaMoodStore interface {
|
||||
Emotions() *emotion.Manager
|
||||
}
|
||||
|
||||
// MemoryStore lists memory entries. *runtime/memory.Store is the production
|
||||
// implementation. The gateway maps each MemoryItem to its wire summary.
|
||||
type MemoryStore interface {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
)
|
||||
|
||||
// moodRegistry builds a real persona registry (the production PersonaStore, which
|
||||
// also owns each role's emotion machine) from the given personas, registering the
|
||||
// matching character too.
|
||||
func moodRegistry(t *testing.T, personas ...*loader.Persona) *personareg.Registry {
|
||||
t.Helper()
|
||||
reg := personareg.New()
|
||||
for _, p := range personas {
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
|
||||
}
|
||||
if err := reg.RegisterCharacter(&personareg.Character{ID: p.ID, Name: p.Name, PersonaID: p.ID}); err != nil {
|
||||
t.Fatalf("RegisterCharacter(%s): %v", p.ID, err)
|
||||
}
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
// personaList fetches a persona list endpoint and returns each row as a raw map,
|
||||
// so the test can tell a missing key from a present null/false.
|
||||
func personaList(t *testing.T, ts *httptest.Server, jwt *auth.JWTService, path string) []map[string]any {
|
||||
t.Helper()
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, mustSign(t, jwt))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d, want 200 (body=%s)", path, resp.StatusCode, body)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// mustSign mints an owner token for the read endpoints.
|
||||
func mustSign(t *testing.T, jwt *auth.JWTService) string {
|
||||
t.Helper()
|
||||
return signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
}
|
||||
|
||||
// TestAdminListPersonasThinkingKeepShowToUserTriState pins the read-side contract
|
||||
// of the panel's `thinking` block: an unconfigured persona carries NO `thinking`
|
||||
// key (nil, not an all-zero object), an explicitly false `showToUser` stays
|
||||
// `false`, and an unset one stays `null` — never flattened into false, which is
|
||||
// the distinction the write side keeps with *bool.
|
||||
func TestAdminListPersonasThinkingKeepShowToUserTriState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
off := false
|
||||
on := true
|
||||
reg := moodRegistry(t,
|
||||
&loader.Persona{ID: "plain", Name: "素面", Emotion: loader.EmotionParams{InitialState: emotion.Calm}},
|
||||
&loader.Persona{ID: "full", Name: "全能", Thinking: loader.ThinkingParams{
|
||||
BudgetTokens: 4096, Effort: "high", ShowToUser: &off, Mode: loader.ThinkingModeOff,
|
||||
}},
|
||||
&loader.Persona{ID: "budget-only", Name: "只配预算", Thinking: loader.ThinkingParams{BudgetTokens: 2048}},
|
||||
&loader.Persona{ID: "forced-on", Name: "强制开", Thinking: loader.ThinkingParams{ShowToUser: &on, Mode: loader.ThinkingModeOn}},
|
||||
)
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Personas: reg})
|
||||
|
||||
rows := personaList(t, ts, jwt, "/api/v1/admin/personas")
|
||||
byID := map[string]map[string]any{}
|
||||
for _, row := range rows {
|
||||
byID[row["id"].(string)] = row
|
||||
}
|
||||
if len(byID) != 4 {
|
||||
t.Fatalf("rows = %d, want 4", len(byID))
|
||||
}
|
||||
|
||||
if _, present := byID["plain"]["thinking"]; present {
|
||||
t.Fatalf("unconfigured persona carries thinking = %#v, want the key absent", byID["plain"]["thinking"])
|
||||
}
|
||||
|
||||
full, ok := byID["full"]["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("full.thinking = %#v, want an object", byID["full"]["thinking"])
|
||||
}
|
||||
if full["budgetTokens"] != float64(4096) || full["effort"] != "high" || full["mode"] != "off" {
|
||||
t.Fatalf("full.thinking = %#v, want 4096/high/off", full)
|
||||
}
|
||||
if show, ok := full["showToUser"].(bool); !ok || show {
|
||||
t.Fatalf("full.thinking.showToUser = %#v, want the boolean false", full["showToUser"])
|
||||
}
|
||||
|
||||
budgetOnly, ok := byID["budget-only"]["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("budget-only.thinking = %#v, want an object", byID["budget-only"]["thinking"])
|
||||
}
|
||||
show, present := budgetOnly["showToUser"]
|
||||
if !present {
|
||||
t.Fatal("budget-only.thinking has no showToUser key, want an explicit null")
|
||||
}
|
||||
if show != nil {
|
||||
t.Fatalf("budget-only.thinking.showToUser = %#v, want null (unconfigured)", show)
|
||||
}
|
||||
|
||||
forced, ok := byID["forced-on"]["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("forced-on.thinking = %#v, want an object", byID["forced-on"]["thinking"])
|
||||
}
|
||||
if show, ok := forced["showToUser"].(bool); !ok || !show {
|
||||
t.Fatalf("forced-on.thinking.showToUser = %#v, want the boolean true", forced["showToUser"])
|
||||
}
|
||||
if forced["mode"] != "on" {
|
||||
t.Fatalf("forced-on.thinking.mode = %#v, want on", forced["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserListPersonasReportsRoleMoodOnly pins the user-side shape: the role-level
|
||||
// mood rides along (the frontend seeds its badge from it), while the admin-only
|
||||
// thinking knobs stay out of the user contract.
|
||||
func TestUserListPersonasReportsRoleMoodOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
off := false
|
||||
reg := moodRegistry(t,
|
||||
&loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Happy},
|
||||
Thinking: loader.ThinkingParams{BudgetTokens: 1024, ShowToUser: &off}},
|
||||
&loader.Persona{ID: "yuki", Name: "雪"},
|
||||
)
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Personas: reg})
|
||||
|
||||
rows := personaList(t, ts, jwt, "/api/v1/personas")
|
||||
byID := map[string]map[string]any{}
|
||||
for _, row := range rows {
|
||||
byID[row["id"].(string)] = row
|
||||
}
|
||||
if got := byID["xilian"]["mood"]; got != "happy" {
|
||||
t.Fatalf("xilian mood = %#v, want happy", got)
|
||||
}
|
||||
if got := byID["yuki"]["mood"]; got != "" {
|
||||
t.Fatalf("yuki mood = %#v, want the honest empty (no tracked state)", got)
|
||||
}
|
||||
for id, row := range byID {
|
||||
if _, present := row["thinking"]; present {
|
||||
t.Fatalf("%s carries thinking on the user-side list, want the key absent", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaMoodFollowsTheRole is the "mood is role-level, not per-session" rule:
|
||||
// it reads the live emotion machine, so a transition on one role's machine shows
|
||||
// up for that role and leaves every other role untouched, with no session input
|
||||
// anywhere in the path.
|
||||
func TestPersonaMoodFollowsTheRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := moodRegistry(t,
|
||||
&loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Calm}},
|
||||
&loader.Persona{ID: "yuki", Name: "雪", Emotion: loader.EmotionParams{InitialState: emotion.Calm}},
|
||||
)
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Personas: reg})
|
||||
|
||||
readMoods := func() (string, string) {
|
||||
rows := personaList(t, ts, jwt, "/api/v1/personas")
|
||||
byID := map[string]string{}
|
||||
for _, row := range rows {
|
||||
byID[row["id"].(string)], _ = row["mood"].(string)
|
||||
}
|
||||
return byID["xilian"], byID["yuki"]
|
||||
}
|
||||
|
||||
if x, y := readMoods(); x != "calm" || y != "calm" {
|
||||
t.Fatalf("initial moods = (%q, %q), want (calm, calm)", x, y)
|
||||
}
|
||||
// Move only 昔涟's machine (the machine a settled turn's observer would move).
|
||||
if err := reg.Emotions().Transition("xilian", emotion.Happy); err != nil {
|
||||
t.Fatalf("Transition: %v", err)
|
||||
}
|
||||
if x, y := readMoods(); x != "happy" || y != "calm" {
|
||||
t.Fatalf("moods after one role moved = (%q, %q), want (happy, calm)", x, y)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaMoodEmptyWithoutEmotionStore is the honest degrade: a persona store
|
||||
// that does not own emotion (it only lists personas) reports an empty mood rather
|
||||
// than inventing one.
|
||||
func TestPersonaMoodEmptyWithoutEmotionStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := moodRegistry(t, &loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Happy}})
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Personas: &stubPersonas{list: reg.List()}})
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/personas", nil, mustSign(t, jwt))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0]["mood"] != "" {
|
||||
t.Fatalf("rows = %#v, want one row with an empty mood", rows)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
@@ -27,6 +28,35 @@ type PersonaSummary struct {
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
// ChatBackground holds per-orientation wallpaper; a role's own, never another's.
|
||||
ChatBackground ChatBackgroundSummary `json:"chatBackground,omitempty"`
|
||||
// Mood is the role's current emotion state (design/01 §2: emotion follows the
|
||||
// role, never a session). "" means the role has no tracked emotion — an honest
|
||||
// empty, never a fabricated default. Clients seed their mood badge from it so a
|
||||
// refresh no longer flashes an empty mood.
|
||||
Mood string `json:"mood"`
|
||||
// Thinking carries the persona's optional reasoning/presentation block
|
||||
// (design/01 §2.1). It is nil — and so absent from the JSON — when the persona
|
||||
// configures none, mirroring the write side's "an omitted block keeps the
|
||||
// stored one". Admin list only: these are model-call knobs, not user-UI state
|
||||
// (the user list takes Mood and leaves the knobs to the panel).
|
||||
Thinking *PersonaThinkingSummary `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// PersonaThinkingSummary mirrors a persona's `thinking` block on the wire
|
||||
// (design/01 §2.1; provider mapping in design/04 §3.2).
|
||||
type PersonaThinkingSummary struct {
|
||||
// BudgetTokens is the reasoning budget (Anthropic thinking.budget_tokens).
|
||||
BudgetTokens int `json:"budgetTokens"`
|
||||
// Effort is the provider-specific reasoning strength (reasoning_effort). The
|
||||
// model layer validates it against the active provider's whitelist and WARNs
|
||||
// when it is dropped — this side just reports what is stored.
|
||||
Effort string `json:"effort"`
|
||||
// ShowToUser is TRI-STATE and stays that way on the wire: null = not
|
||||
// configured (thinking frames keep flowing — the pre-existing behavior),
|
||||
// true/false = an explicit choice. A plain bool would flatten "unset" onto
|
||||
// "off" and lose the distinction the write side keeps with *bool.
|
||||
ShowToUser *bool `json:"showToUser"`
|
||||
// Mode is the explicit hard switch: default | on | off ("" = default).
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// ChatBackgroundSummary mirrors the persona chatBackground on the wire.
|
||||
@@ -224,13 +254,17 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleListPersonas returns the personas (or [] when no store is wired).
|
||||
// handleListPersonas returns the personas (or [] when no store is wired). It is
|
||||
// the user-side shape: the role-level mood rides along, the admin-only thinking
|
||||
// knobs do not.
|
||||
func (s *Server) handleListPersonas(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.collectPersonas())
|
||||
writeJSON(w, http.StatusOK, s.collectPersonas(false))
|
||||
}
|
||||
|
||||
// collectPersonas returns a non-nil persona slice from the configured store.
|
||||
func (s *Server) collectPersonas() []PersonaSummary {
|
||||
// withThinking adds the admin-only `thinking` block; mood is always reported
|
||||
// because the user UI renders the badge.
|
||||
func (s *Server) collectPersonas(withThinking bool) []PersonaSummary {
|
||||
out := make([]PersonaSummary, 0)
|
||||
if s.personas == nil {
|
||||
return out
|
||||
@@ -246,6 +280,7 @@ func (s *Server) collectPersonas() []PersonaSummary {
|
||||
Character: c.Name,
|
||||
IsDefault: c.Default,
|
||||
Mode: firstMode(c.Modes),
|
||||
Mood: s.moodOf(id),
|
||||
}
|
||||
// Read this persona's own visual assets (avatar / chat background). Each
|
||||
// role carries its own; a missing persona or unset visual yields empty,
|
||||
@@ -260,12 +295,56 @@ func (s *Server) collectPersonas() []PersonaSummary {
|
||||
Portrait: p.Visual.ChatBackground.Portrait,
|
||||
}
|
||||
}
|
||||
sum.Thinking = thinkingSummaryOf(p.Thinking, withThinking)
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// moodOf reports the role's current emotion state. Emotion is owned by the
|
||||
// persona store (design/01 §2), read through the optional PersonaMoodStore
|
||||
// capability; a store that does not track emotion, or a role with no machine,
|
||||
// reports "" instead of a made-up mood.
|
||||
func (s *Server) moodOf(id string) string {
|
||||
store, ok := s.personas.(PersonaMoodStore)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
emotions := store.Emotions()
|
||||
if emotions == nil {
|
||||
return ""
|
||||
}
|
||||
machine, _, ok := emotions.Get(id)
|
||||
if !ok || machine == nil {
|
||||
return ""
|
||||
}
|
||||
return string(machine.Current())
|
||||
}
|
||||
|
||||
// thinkingSummaryOf maps a stored thinking block onto the wire. It returns nil
|
||||
// when the persona configures none — the key is then absent, so a client can tell
|
||||
// "not configured" from an explicit value (the read-side mirror of the write-side
|
||||
// rule that an omitted block keeps the stored one). include is false for the
|
||||
// user-side list, which does not carry these knobs.
|
||||
func thinkingSummaryOf(t loader.ThinkingParams, include bool) *PersonaThinkingSummary {
|
||||
if !include || !thinkingConfigured(t) {
|
||||
return nil
|
||||
}
|
||||
return &PersonaThinkingSummary{
|
||||
BudgetTokens: t.BudgetTokens,
|
||||
Effort: t.Effort,
|
||||
ShowToUser: t.ShowToUser,
|
||||
Mode: t.Mode,
|
||||
}
|
||||
}
|
||||
|
||||
// thinkingConfigured reports whether any thinking knob is set. An empty block
|
||||
// must read as "not configured" rather than as an all-zero object.
|
||||
func thinkingConfigured(t loader.ThinkingParams) bool {
|
||||
return t.BudgetTokens != 0 || t.Effort != "" || t.ShowToUser != nil || t.Mode != ""
|
||||
}
|
||||
|
||||
// firstMode returns the first declared behavior mode, or "" when none are set.
|
||||
func firstMode(modes []string) string {
|
||||
if len(modes) == 0 {
|
||||
|
||||
Reference in new issue
Block a user