Version 0.0.1 #2
@@ -114,13 +114,13 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land
|
||||
|
||||
**Memory namespace (blueprint §5).** `memory_docs` (accessor `db/memory_docs.rs` — `get`/`upsert`/`list`/`search`(FTS)/`delete`) backs a virtual note store surfaced through the fs-tools, **not** the disk. Two sibling roots (not the blueprint's nested `memory/{userid}` + `memory/shared`): `user-memory/…` routes to the caller's own pool (`ToolContext::pool`), `shared-memory/…` to the system pool (a singleton captured in `fs::register_all`). `tools/fs/classify_memory()` decides on the raw first path component (a `..` in the tail clamps inside the store, never escapes to disk); `read_file`/`write_file`/`list_files`/`edit_file`/`insert_at_line`/`replace_lines`/`search_file` override `run_with` to route memory paths (each extracting a pure transform shared with its on-disk `execute`) and leave every other path on disk. Approval (seeded in `seed_fs_path_rules`): `user-memory/*` is `@fs_any allow` (private, frictionless); `shared-memory/*` is `@fs_read allow` + `@fs_write require` — reads free, **writes need approval** so the agent can't silently push one person's data into shared memory. `grep_files` stays disk-only (regex-across-tree ≠ FTS); ranked full-text recall over notes is a separate tool, `memory_search` (`tools/fs/memory_search.rs`), over the `memory_docs` FTS index — allowed by a path-less rule (it takes `query`, not `path`).
|
||||
|
||||
**Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `main` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`.
|
||||
**Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `assistant` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`.
|
||||
|
||||
**Prompt substitutions**: an `AGENT.md` may carry `<!-- KEY -->` placeholders; `agents::resolve_includes` turns each into a `__KEY__` sentinel, replaced at request time. Two are **builder-side** — `MessageBuilder` resolves them itself from the session owner (`user_id`) + registry (`shared_pool`), so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: `__SHARED_FOLDERS__` (the user's shared-folders table) and `__USER_PROFILE__` (the owner's directory profile: `Name`, `Date of birth` with age computed at build time, `Sex`, `Preferred language`, admin `Notes` — unset values render as explicit `unknown` / `not specified`, the `Notes` line is omitted when empty). Any other key comes from the per-call `SendMessageOptions::system_substitutions` map.
|
||||
|
||||
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration.
|
||||
|
||||
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model.
|
||||
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`, `chat_agent`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The role's **default entry (chat) agent** is `attrs.chat_agent` — the neutral `chat`-type agent members of the role land on (§0.1: data, not an enum). Resolved by `roles::default_chat_agent_for_user(registry_pool, user_id)` — the single seam behind both the per-user `ChatHub`'s `default_agent` (snapshotted at login in `UserContextFactory::build`, like fs/MCP access, so **every** session-creation path — explicit `provision_session`, lazy WS `get_or_create_session`, notify — honors it) and `provisioning_for_source`'s non-project branch. Falls back to `agents::DEFAULT_CHAT_AGENT` (`"assistant"`, the renamed former `main`) when unset. Seeded: `admin`/`member` → `assistant`, `children` → `kid` (Companion). A per-user override is future work, layering on top in the same resolver. The stack **root frame** is created with the session's own `agent_id` (not a literal) — `config.agent_id` (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model.
|
||||
|
||||
## Filesystem & containers (blueprint §6)
|
||||
|
||||
@@ -196,7 +196,7 @@ At context-build time (`MessageBuilder`), attachments of the **current turn** (t
|
||||
- **Restart recovery of a parallel batch** is intentionally lossy (single-user app): `resume_turn` first calls `reap_interrupted_parallel_batches`, which detects a batch by ≥2 active `chat_sessions_stack` frames at the same depth (impossible for a linear stack), fails their spawning tool calls and terminates the frames, then lets the normal linear cascade resume the parent. A lone interrupted sub-agent is untouched and still recovers via the cascade.
|
||||
- Client resolution order: `args.client` → `meta.json client` → AUTO selection by scope/strength.
|
||||
- **The parent's resolved client is NOT inherited.** Passing a concrete model name to `resolve()` bypasses strength/scope checks; sub-agents always auto-select unless overridden explicitly.
|
||||
- `list_agents` is a plain tool; returns JSON excluding `main`.
|
||||
- `list_agents` is a plain tool; returns JSON of **task** agents only (excludes `chat`/`system` agents like the `assistant` entry agent).
|
||||
- `resume_turn` (+ its cascade) is kept only for: app-restart recovery of an active child stack, async task result injection (`inject_async_result`), and the WS resume message — not for the normal sync dispatch.
|
||||
|
||||
## Cancellation (stop)
|
||||
@@ -274,7 +274,7 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/
|
||||
|
||||
**Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it via `RoleAttrs` (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`.
|
||||
|
||||
**Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged. The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`).
|
||||
**Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged. The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`) + a **default-assistant** select (→ `attrs.chat_agent`) fed by `GET /api/agents` filtered to `type:chat` minus `project-coordinator` (source-driven); the same exclusion is enforced server-side in the roles API (`validate_chat_agent`).
|
||||
|
||||
| File | Element | Notes |
|
||||
| ---- | ------- | ----- |
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "Main Assistant",
|
||||
"name": "Assistant",
|
||||
"description": "General-purpose assistant: helps the user with any task using tools, and persists all relevant information in memory",
|
||||
"friendly_description": "Your general-purpose assistant — helps with any task and remembers what matters in memory.",
|
||||
"i18n": {
|
||||
"it": {
|
||||
"name": "Assistente Principale",
|
||||
"name": "Assistente",
|
||||
"friendly_description": "Il tuo assistente tuttofare — ti aiuta in qualsiasi attività e ricorda ciò che conta nella memoria."
|
||||
},
|
||||
"fr": {
|
||||
"name": "Assistant Principal",
|
||||
"name": "Assistant",
|
||||
"friendly_description": "Votre assistant polyvalent — vous aide dans toutes vos tâches et retient l'essentiel en mémoire."
|
||||
}
|
||||
},
|
||||
@@ -12,8 +12,10 @@ use crate::message_meta::MessageMetadata;
|
||||
/// Optional parameters for a [`ChatHubApi::send_message`] call.
|
||||
#[derive(Default)]
|
||||
pub struct SendMessageOptions {
|
||||
/// Agent to use for this source's session. Defaults to `"main"` if not set.
|
||||
/// Only takes effect when a new session is created — ignored for existing sessions.
|
||||
/// Agent to use for this source's session. When unset, falls back to the
|
||||
/// hub's owner-resolved default entry agent (the caller's role `attrs.chat_agent`,
|
||||
/// else `DEFAULT_CHAT_AGENT`). Only takes effect when a new session is created —
|
||||
/// ignored for existing sessions.
|
||||
pub agent_id: Option<String>,
|
||||
/// Named substitutions applied to the agent's system prompt.
|
||||
/// Each entry replaces the sentinel `__KEY__` in the loaded prompt text.
|
||||
|
||||
@@ -8,6 +8,13 @@ use core_api::provider::LlmStrength;
|
||||
|
||||
const AGENTS_DIR: &str = "agents";
|
||||
|
||||
/// The neutral, instance-wide fallback chat agent (§0.1: a stable technical id,
|
||||
/// never surfaced to the user — the display name lives in its `meta.json`). Used
|
||||
/// as the last-resort default when a role carries no `attrs.chat_agent` (or its
|
||||
/// attrs are unreadable). The per-user entry agent is normally resolved from the
|
||||
/// caller's role — see `db::roles::default_chat_agent_for_user`.
|
||||
pub const DEFAULT_CHAT_AGENT: &str = "assistant";
|
||||
|
||||
/// The role an agent plays, declared by the required `type` field in `meta.json`.
|
||||
///
|
||||
/// - `Chat`: a conversational entry-point the user talks to directly (e.g. `main`,
|
||||
|
||||
@@ -1185,7 +1185,7 @@ mod tests {
|
||||
|
||||
// Gate decisions through the real check() path.
|
||||
async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult {
|
||||
mgr.check(1, None, "main", "web", tool, &json!({ "path": path }), Some("default")).await
|
||||
mgr.check(1, None, "assistant", "web", tool, &json!({ "path": path }), Some("default")).await
|
||||
}
|
||||
// user-memory auto-allows reads and writes; the old `memory/*` no longer matches.
|
||||
assert!(matches!(decide(&mgr, "write_file", "user-memory/notes.md").await, GateResult::Allow));
|
||||
@@ -1209,7 +1209,7 @@ mod tests {
|
||||
assert!(matches!(decide(&mgr, "write_file", "src/main.rs").await, GateResult::Require));
|
||||
// Non-filesystem tool: unaffected by @fs_* rules, gated by catch-all.
|
||||
let cmd = mgr
|
||||
.check(1, None, "main", "web", "execute_cmd", &json!({ "command": "ls" }), Some("default"))
|
||||
.check(1, None, "assistant", "web", "execute_cmd", &json!({ "command": "ls" }), Some("default"))
|
||||
.await;
|
||||
assert!(matches!(cmd, GateResult::Require));
|
||||
|
||||
|
||||
@@ -78,6 +78,14 @@ pub struct ChatHub {
|
||||
/// model name). When absent the caller AUTO-resolves. In-memory only: a
|
||||
/// server restart clears all pins (intentional for the MVP).
|
||||
selected_clients: Mutex<HashMap<String, String>>,
|
||||
/// The entry agent used when a source has no session yet and the caller did
|
||||
/// not specify one. Resolved once, at login, from the owner's role
|
||||
/// (`attrs.chat_agent`, else `DEFAULT_CHAT_AGENT`) — this hub is owner-bound,
|
||||
/// so its default is the owner's default. Every lazy `get_or_create_session`
|
||||
/// path (WS connect, notify, synthetic turns) routes through it, so a member's
|
||||
/// role-assigned assistant is honored regardless of which path creates the
|
||||
/// first session.
|
||||
default_agent: String,
|
||||
}
|
||||
|
||||
impl ChatHub {
|
||||
@@ -87,6 +95,7 @@ impl ChatHub {
|
||||
approval: Arc<ApprovalManager>,
|
||||
global_tx: broadcast::Sender<GlobalEvent>,
|
||||
shutdown: CancellationToken,
|
||||
default_agent: String,
|
||||
) -> Arc<Self> {
|
||||
let (notify_tx, notify_rx) = mpsc::channel::<Notification>(NOTIFY_CAPACITY);
|
||||
|
||||
@@ -101,6 +110,7 @@ impl ChatHub {
|
||||
me: OnceLock::new(),
|
||||
shutdown: shutdown.clone(),
|
||||
selected_clients: Mutex::new(HashMap::new()),
|
||||
default_agent,
|
||||
});
|
||||
// Store a weak self-reference for lazily-spawned source consumers.
|
||||
let _ = hub.me.set(Arc::downgrade(&hub));
|
||||
@@ -179,7 +189,7 @@ impl ChatHub {
|
||||
// was busy. `None` for synthetic turns, which never inject.
|
||||
pending_input: Option<Arc<dyn PendingUserInput>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let agent_id = opts.agent_id.as_deref().unwrap_or("main");
|
||||
let agent_id = opts.agent_id.as_deref().unwrap_or(&self.default_agent);
|
||||
let session_id = self.get_or_create_session(source_id, agent_id).await?;
|
||||
let source_tag = source_id.to_string();
|
||||
|
||||
@@ -220,7 +230,7 @@ impl ChatHub {
|
||||
|
||||
/// Returns the session handler for the source's active session, creating one lazily if needed.
|
||||
pub async fn session_handler(&self, source_id: &str) -> anyhow::Result<Arc<ChatSessionHandler>> {
|
||||
let session_id = self.get_or_create_session(source_id, "main").await?;
|
||||
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
|
||||
self.session_mgr.get_or_create_handler(session_id).await
|
||||
}
|
||||
|
||||
@@ -273,10 +283,10 @@ impl ChatHub {
|
||||
}
|
||||
|
||||
/// Create a new session for the source, discarding the previous one.
|
||||
/// Thin wrapper over `provision_session` preserving the default `main` agent
|
||||
/// Thin wrapper over `provision_session` using the owner's default entry agent
|
||||
/// (kept for the `ChatHubApi` trait and generic callers).
|
||||
pub async fn clear(&self, source_id: &str) -> anyhow::Result<i64> {
|
||||
self.provision_session(source_id, "main", None, true).await
|
||||
self.provision_session(source_id, &self.default_agent, None, true).await
|
||||
}
|
||||
|
||||
/// Subscribe to the global event bus. The `source_id` parameter is accepted
|
||||
@@ -308,7 +318,7 @@ impl ChatHub {
|
||||
/// Returns `(input_tokens, output_tokens)` — both are `None` when no
|
||||
/// messages exist or the provider did not report usage.
|
||||
pub async fn context_info(&self, source_id: &str) -> anyhow::Result<(Option<i64>, Option<i64>)> {
|
||||
let session_id = self.get_or_create_session(source_id, "main").await?;
|
||||
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
|
||||
let stack = match chat_sessions_stack::active_for_session(&self.db, session_id).await? {
|
||||
Some(s) => s,
|
||||
None => return Ok((None, None)),
|
||||
@@ -321,7 +331,7 @@ impl ChatHub {
|
||||
/// sub-agent frames and excluding asynchronous tasks (which run in their own
|
||||
/// session). `None` when no provider reported a cost.
|
||||
pub async fn cost_info(&self, source_id: &str) -> anyhow::Result<Option<f64>> {
|
||||
let session_id = self.get_or_create_session(source_id, "main").await?;
|
||||
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
|
||||
chat_history::total_cost_for_session(&self.db, session_id).await
|
||||
}
|
||||
|
||||
@@ -414,7 +424,7 @@ impl ChatHub {
|
||||
/// Revoke all session-scoped MCP grants for a source's active session.
|
||||
/// The next LLM turn will start with no MCP servers activated.
|
||||
pub async fn reset_mcp(&self, source_id: &str) -> anyhow::Result<()> {
|
||||
let session_id = self.get_or_create_session(source_id, "main").await?;
|
||||
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
|
||||
crate::db::session_mcp_grants::revoke_all(&self.db, session_id).await?;
|
||||
info!(source_id, session_id, "ChatHub: MCP grants reset");
|
||||
Ok(())
|
||||
@@ -695,7 +705,7 @@ impl ChatHub {
|
||||
// the last assistant message and runs the LLM loop so the agent can respond.
|
||||
let result_json = serde_json::to_string(¬es).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
let session_id = match hub.get_or_create_session(&home, "main").await {
|
||||
let session_id = match hub.get_or_create_session(&home, &hub.default_agent).await {
|
||||
Ok(sid) => sid,
|
||||
Err(e) => { error!(error = %e, "notification consumer: get_or_create_session failed"); continue; }
|
||||
};
|
||||
|
||||
@@ -170,7 +170,7 @@ mod tests {
|
||||
sqlx::query("INSERT INTO chat_sessions (id) VALUES (?)")
|
||||
.bind(sid).execute(&pool).await.unwrap();
|
||||
|
||||
create(&pool, sid, "main", None, 0, None).await.unwrap();
|
||||
create(&pool, sid, "assistant", None, 0, None).await.unwrap();
|
||||
let a = create(&pool, sid, "task", Some("A"), 1, Some(101)).await.unwrap();
|
||||
create(&pool, sid, "task", Some("B"), 1, Some(102)).await.unwrap();
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ pub struct RoleAttrs {
|
||||
/// to its default `permission_group`. The default is always implicitly allowed; the
|
||||
/// effective set is `unique({permission_group} ∪ permission_groups)`.
|
||||
pub permission_groups: Vec<String>,
|
||||
/// The entry (`type:chat`) agent members of this role talk to by default — e.g.
|
||||
/// `children` → `kid` (Companion), `member`/`admin` → `assistant`. Resolved into the
|
||||
/// per-user runtime at login (see [`default_chat_agent_for_user`]). `None` (or absent
|
||||
/// attrs) falls back to [`crate::agents::DEFAULT_CHAT_AGENT`]. Data-driven, not an
|
||||
/// enum (§0.1): a future per-user override layers on top of this.
|
||||
pub chat_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl RoleAttrs {
|
||||
@@ -83,6 +89,23 @@ impl Role {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry chat agent for a user: their role's `attrs.chat_agent`, else the neutral
|
||||
/// [`crate::agents::DEFAULT_CHAT_AGENT`]. The single resolver behind the per-user hub
|
||||
/// default and `provisioning_for_source`, so every session-creation path agrees on which
|
||||
/// agent a member lands on. Tolerant by construction — a missing user, missing role, or
|
||||
/// unset `chat_agent` all collapse to the default rather than erroring (a chat must open).
|
||||
///
|
||||
/// A future per-user override would slot in here, checked before the role default.
|
||||
pub async fn default_chat_agent_for_user(pool: &SqlitePool, user_id: &str) -> String {
|
||||
let role_agent = async {
|
||||
let user = super::users::get(pool, user_id).await.ok()??;
|
||||
let role = get(pool, &user.role_id).await.ok()??;
|
||||
role.attrs_parsed().chat_agent.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
.await;
|
||||
role_agent.unwrap_or_else(|| crate::agents::DEFAULT_CHAT_AGENT.to_string())
|
||||
}
|
||||
|
||||
/// Whether a role may use `group_id` as its session security-group. `admin` holds
|
||||
/// every group by construction; a missing role allows nothing.
|
||||
pub async fn role_allows_group(pool: &SqlitePool, role_id: &str, group_id: &str) -> Result<bool> {
|
||||
@@ -189,9 +212,11 @@ pub async fn user_count(pool: &SqlitePool, role_id: &str) -> Result<i64> {
|
||||
|
||||
/// Inserts the built-in `admin` role. Idempotent.
|
||||
pub async fn seed_admin(pool: &SqlitePool) -> Result<()> {
|
||||
// `chat_agent` is explicit so the role editor shows admin's default rather than an
|
||||
// empty pill; the fallback in `default_chat_agent_for_user` would resolve the same.
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO roles (id, label, permission_group)
|
||||
VALUES ('admin', 'Administrator', 'default')",
|
||||
r#"INSERT OR IGNORE INTO roles (id, label, permission_group, attrs)
|
||||
VALUES ('admin', 'Administrator', 'default', '{"chat_agent":"assistant"}')"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
@@ -224,13 +249,15 @@ mod tests {
|
||||
let a = RoleAttrs::from_opt(&None);
|
||||
assert_eq!(a.ui_mode, UiMode::Full);
|
||||
assert!(a.permission_groups.is_empty());
|
||||
assert!(a.chat_agent.is_none());
|
||||
|
||||
// Populated.
|
||||
let a = RoleAttrs::from_opt(&Some(
|
||||
r#"{"ui_mode":"simple","permission_groups":["ops","research"]}"#.into(),
|
||||
r#"{"ui_mode":"simple","permission_groups":["ops","research"],"chat_agent":"kid"}"#.into(),
|
||||
));
|
||||
assert_eq!(a.ui_mode, UiMode::Simple);
|
||||
assert_eq!(a.permission_groups, vec!["ops", "research"]);
|
||||
assert_eq!(a.chat_agent.as_deref(), Some("kid"));
|
||||
|
||||
// Malformed JSON → defaults, never an error.
|
||||
let a = RoleAttrs::from_opt(&Some("not json".into()));
|
||||
@@ -265,4 +292,27 @@ mod tests {
|
||||
// An unknown role allows nothing.
|
||||
assert!(!role_allows_group(&pool, "ghost", "default").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_chat_agent_resolves_role_then_falls_back() {
|
||||
let pool = crate::db::init_system_pool(&tmp_db("chatagent")).await.unwrap();
|
||||
|
||||
// A role with an explicit chat_agent, and one without.
|
||||
insert(&pool, "children", "Children", "default", Some(r#"{"chat_agent":"kid"}"#))
|
||||
.await.unwrap();
|
||||
insert(&pool, "member", "Member", "default", Some(r#"{"ui_mode":"full"}"#))
|
||||
.await.unwrap();
|
||||
|
||||
// Minimal cleartext user rows (encrypted=0, no credentials) — bypasses Argon2.
|
||||
for (id, uname, role) in [("u_kid", "kid1", "children"), ("u_adult", "adult1", "member")] {
|
||||
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, ?, 0)")
|
||||
.bind(id).bind(uname).bind(role).execute(&pool).await.unwrap();
|
||||
}
|
||||
|
||||
// Role's chat_agent wins; a role without one falls back to the neutral default;
|
||||
// an unknown user also falls back (never errors — a chat must be able to open).
|
||||
assert_eq!(default_chat_agent_for_user(&pool, "u_kid").await, "kid");
|
||||
assert_eq!(default_chat_agent_for_user(&pool, "u_adult").await, crate::agents::DEFAULT_CHAT_AGENT);
|
||||
assert_eq!(default_chat_agent_for_user(&pool, "ghost").await, crate::agents::DEFAULT_CHAT_AGENT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,7 +585,9 @@ impl ChatSessionHandler {
|
||||
let stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
chat_sessions_stack::create(pool, self.session_id, "main", None, 0, None).await?
|
||||
// Lazy root frame: run this session's own entry agent (the same id
|
||||
// `build_agent_config` resolves the prompt from), never a hardcoded default.
|
||||
chat_sessions_stack::create(pool, self.session_id, &self.agent_id, None, 0, None).await?
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -132,8 +132,12 @@ impl ChatSessionManager {
|
||||
if let Some(rc) = run_context {
|
||||
chat_sessions::set_run_context(&self.db, session.id, Some(&rc.to_db())).await?;
|
||||
}
|
||||
// The root stack frame runs the session's own entry agent — not a hardcoded
|
||||
// default. Using the wrong id here would silently run that agent's prompt
|
||||
// regardless of what the session was created with (llm_loop resolves the
|
||||
// prompt from `config.agent_id`, which comes from the stack frame).
|
||||
let stack = chat_sessions_stack::create(
|
||||
&self.db, session.id, "main", None, 0, None,
|
||||
&self.db, session.id, agent_id, None, 0, None,
|
||||
).await?;
|
||||
Ok((session.id, stack.id))
|
||||
}
|
||||
|
||||
@@ -45,13 +45,14 @@ pub fn seed_profiles() -> Vec<SeedProfile> {
|
||||
id: "member",
|
||||
label: "Member",
|
||||
permission_group: "default",
|
||||
attrs: Some(r#"{"ui_mode":"full"}"#),
|
||||
attrs: Some(r#"{"ui_mode":"full","chat_agent":"assistant"}"#),
|
||||
},
|
||||
RoleSeed {
|
||||
id: "children",
|
||||
label: "Children",
|
||||
permission_group: "default",
|
||||
attrs: Some(r#"{"ui_mode":"simple"}"#),
|
||||
// `kid` = the Companion agent (its display name is copy, §0.1).
|
||||
attrs: Some(r#"{"ui_mode":"simple","chat_agent":"kid"}"#),
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
@@ -379,6 +379,9 @@ impl Conversation {
|
||||
Arc::clone(&interaction.approval),
|
||||
rt.global_tx.clone(),
|
||||
rt.shutdown_token.clone(),
|
||||
// Inert ownerless bundle (§19): no owner to resolve a role default from, so
|
||||
// the neutral fallback. Nothing consumes this hub's sessions.
|
||||
crate::agents::DEFAULT_CHAT_AGENT.to_string(),
|
||||
);
|
||||
chat_hub.register("web").await;
|
||||
chat_hub.register("talk").await;
|
||||
|
||||
@@ -293,12 +293,20 @@ impl UserContextFactory {
|
||||
Arc::new(ToolDiscovery::new(Arc::clone(&self.registry_pool))),
|
||||
));
|
||||
|
||||
// The owner's default entry agent, snapshotted at login from their role
|
||||
// (like fs membership / MCP access above): every lazy session-creation path
|
||||
// on this owner-bound hub routes through it, so a member's role-assigned
|
||||
// assistant is honored no matter which path opens their first session.
|
||||
let default_agent =
|
||||
crate::db::roles::default_chat_agent_for_user(&self.registry_pool, user_id).await;
|
||||
|
||||
let chat_hub = ChatHub::new(
|
||||
Arc::clone(&pool),
|
||||
Arc::clone(&manager),
|
||||
Arc::clone(&approval),
|
||||
global_tx.clone(),
|
||||
self.shutdown_token.clone(),
|
||||
default_agent,
|
||||
);
|
||||
chat_hub.register("web").await;
|
||||
chat_hub.register("talk").await;
|
||||
|
||||
@@ -30,7 +30,7 @@ use super::{ApiError, guard::AuthUser, require_context};
|
||||
pub const PROJECT_SOURCE_PREFIX: &str = "project-";
|
||||
|
||||
/// Agent that drives interactive project-chat sessions.
|
||||
const PROJECT_COORDINATOR_AGENT: &str = "project-coordinator";
|
||||
pub(crate) const PROJECT_COORDINATOR_AGENT: &str = "project-coordinator";
|
||||
|
||||
// ── Request/Response types ────────────────────────────────────────────────────
|
||||
|
||||
@@ -339,7 +339,10 @@ pub async fn provisioning_for_source(
|
||||
.strip_prefix(PROJECT_SOURCE_PREFIX)
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
else {
|
||||
return Ok(("main".to_string(), None));
|
||||
// Non-project source → the caller's role-assigned entry agent (same resolver
|
||||
// the per-user hub uses, so explicit-create and lazy-create never diverge).
|
||||
let agent = skald_core::db::roles::default_chat_agent_for_user(skald.db(), user_id).await;
|
||||
return Ok((agent, None));
|
||||
};
|
||||
|
||||
let (project, _can_write) = require_member(skald, id, user_id).await?;
|
||||
|
||||
@@ -3,16 +3,38 @@ use std::sync::Arc;
|
||||
use axum::{Json, extract::{Path, State}};
|
||||
use serde::Deserialize;
|
||||
|
||||
use skald_core::db::roles::{self, ADMIN_ROLE_ID, Role};
|
||||
use skald_core::agents::{self, AgentType};
|
||||
use skald_core::db::roles::{self, ADMIN_ROLE_ID, RoleAttrs, Role};
|
||||
use skald_core::skald::Skald;
|
||||
|
||||
use super::ApiError;
|
||||
use super::projects::PROJECT_COORDINATOR_AGENT;
|
||||
|
||||
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<Role>>, ApiError> {
|
||||
let roles = roles::list(skald.db()).await?;
|
||||
Ok(Json(roles))
|
||||
}
|
||||
|
||||
/// If the role's `attrs.chat_agent` is set, it must name an existing **chat** agent
|
||||
/// other than the source-bound `project-coordinator` (which needs a project run-context
|
||||
/// and is never a sensible personal default). Empty/absent is fine — the resolver falls
|
||||
/// back to `DEFAULT_CHAT_AGENT`. Shared by create + update so the two can't drift.
|
||||
fn validate_chat_agent(attrs: &Option<String>) -> Result<(), ApiError> {
|
||||
let Some(agent) = RoleAttrs::from_opt(attrs).chat_agent.filter(|s| !s.trim().is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if agent == PROJECT_COORDINATOR_AGENT {
|
||||
return Err(ApiError::bad_request(
|
||||
"project-coordinator is source-driven and cannot be a role's default assistant",
|
||||
));
|
||||
}
|
||||
match agents::load_meta(&agent) {
|
||||
Ok(meta) if meta.agent_type == AgentType::Chat => Ok(()),
|
||||
Ok(_) => Err(ApiError::bad_request(format!("agent '{agent}' is not a chat agent"))),
|
||||
Err(_) => Err(ApiError::bad_request(format!("unknown agent '{agent}'"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateBody {
|
||||
pub id: String,
|
||||
@@ -35,6 +57,7 @@ pub async fn create(
|
||||
if body.permission_group.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("permission group must not be empty"));
|
||||
}
|
||||
validate_chat_agent(&body.attrs)?;
|
||||
roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref())
|
||||
.await?;
|
||||
// Seed the standard self-service Connector capabilities (§14): a new role can
|
||||
@@ -66,6 +89,7 @@ pub async fn update(
|
||||
if body.permission_group.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("permission group must not be empty"));
|
||||
}
|
||||
validate_chat_agent(&body.attrs)?;
|
||||
let ok = roles::update(skald.db(), &id, body.label.trim(), &body.permission_group, body.attrs.as_deref())
|
||||
.await?;
|
||||
if !ok {
|
||||
|
||||
@@ -313,7 +313,7 @@ export function renderAgent(msg) {
|
||||
<div class="copilot-agent-header">
|
||||
<i class="bi bi-${icon}"></i>
|
||||
<span>
|
||||
<strong>${msg.parent_agent_id ?? 'main'}</strong>
|
||||
<strong>${msg.parent_agent_id ?? 'assistant'}</strong>
|
||||
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
|
||||
<strong>${msg.agent_id}</strong>
|
||||
</span>
|
||||
@@ -334,7 +334,7 @@ export function renderAgentEnd(msg) {
|
||||
<span>
|
||||
<strong>${msg.agent_id}</strong>
|
||||
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
|
||||
<strong>${msg.parent_agent_id ?? 'main'}</strong>
|
||||
<strong>${msg.parent_agent_id ?? 'assistant'}</strong>
|
||||
</span>
|
||||
<span class="copilot-agent-badge done">${t('copilot.agent_finished')}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const ADMIN_ID = 'admin';
|
||||
// Source-driven chat agent (needs a project run-context): never a personal default,
|
||||
// so it is excluded from the role assistant picker (mirrors the server-side guard).
|
||||
const PROJECT_COORDINATOR_ID = 'project-coordinator';
|
||||
|
||||
export class RolesPage extends LightElement {
|
||||
|
||||
@@ -12,6 +15,7 @@ export class RolesPage extends LightElement {
|
||||
_open: { state: true },
|
||||
_roles: { state: true },
|
||||
_groups: { state: true },
|
||||
_agents: { state: true },
|
||||
_error: { state: true },
|
||||
_modal: { state: true }, // null | { mode: 'create'|'edit', role?, form }
|
||||
};
|
||||
@@ -22,6 +26,7 @@ export class RolesPage extends LightElement {
|
||||
this._open = false;
|
||||
this._roles = null;
|
||||
this._groups = null;
|
||||
this._agents = null;
|
||||
this._error = null;
|
||||
this._modal = null;
|
||||
}
|
||||
@@ -45,14 +50,19 @@ export class RolesPage extends LightElement {
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const [rRes, gRes] = await Promise.all([
|
||||
const [rRes, gRes, aRes] = await Promise.all([
|
||||
fetch('/api/roles'),
|
||||
fetch('/api/tool-permission-groups'),
|
||||
fetch('/api/agents'),
|
||||
]);
|
||||
if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`);
|
||||
if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`);
|
||||
if (!aRes.ok) throw new Error(`HTTP ${aRes.status}`);
|
||||
this._roles = await rRes.json();
|
||||
this._groups = await gRes.json();
|
||||
// Only `type:chat` agents are entry agents; project-coordinator is source-driven.
|
||||
this._agents = (await aRes.json())
|
||||
.filter(a => a.type === 'chat' && a.id !== PROJECT_COORDINATOR_ID);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
@@ -76,12 +86,26 @@ export class RolesPage extends LightElement {
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
_mergeAttrs(attrs, uiMode, allowedGroups) {
|
||||
// The role's default entry agent (data-driven, §0.1). Empty means "fall back to the
|
||||
// instance default assistant" — the server resolver handles it, so we store nothing.
|
||||
_attrsChatAgent(attrs) {
|
||||
try { const a = JSON.parse(attrs || '{}').chat_agent; return typeof a === 'string' ? a : ''; }
|
||||
catch { return ''; }
|
||||
}
|
||||
|
||||
// Display name for a chat-agent id (falls back to the id, or the default label when unset).
|
||||
_agentName(id) {
|
||||
if (!id) return t('roles.form.assistant_default');
|
||||
return this._agents?.find(a => a.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
_mergeAttrs(attrs, uiMode, allowedGroups, chatAgent) {
|
||||
let o = {};
|
||||
try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; }
|
||||
if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode;
|
||||
const extras = Array.isArray(allowedGroups) ? allowedGroups.filter(Boolean) : [];
|
||||
if (extras.length) o.permission_groups = extras; else delete o.permission_groups;
|
||||
if (chatAgent) o.chat_agent = chatAgent; else delete o.chat_agent;
|
||||
const keys = Object.keys(o);
|
||||
return keys.length ? JSON.stringify(o) : null;
|
||||
}
|
||||
@@ -89,7 +113,7 @@ export class RolesPage extends LightElement {
|
||||
_openCreate() {
|
||||
this._modal = {
|
||||
mode: 'create',
|
||||
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [] },
|
||||
form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [], chat_agent: '' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +121,7 @@ export class RolesPage extends LightElement {
|
||||
this._modal = {
|
||||
mode: 'edit',
|
||||
role,
|
||||
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs) },
|
||||
form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs), chat_agent: this._attrsChatAgent(role.attrs) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +153,7 @@ export class RolesPage extends LightElement {
|
||||
id: form.id.trim(),
|
||||
label: form.label.trim(),
|
||||
permission_group: form.permission_group,
|
||||
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups),
|
||||
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -146,7 +170,7 @@ export class RolesPage extends LightElement {
|
||||
body: JSON.stringify({
|
||||
label: form.label.trim(),
|
||||
permission_group: form.permission_group,
|
||||
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups),
|
||||
attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -226,6 +250,14 @@ export class RolesPage extends LightElement {
|
||||
</select>
|
||||
<div class="form-text" style="font-size:.75rem">${unsafeHTML(t('roles.form.interface_hint'))}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('roles.form.assistant')}</label>
|
||||
<select class="form-select" @change=${e => this._patch('chat_agent', e.target.value)}>
|
||||
<option value="" ?selected=${!form.chat_agent}>${t('roles.form.assistant_default')}</option>
|
||||
${(this._agents ?? []).map(a => html`<option value=${a.id} ?selected=${form.chat_agent === a.id}>${a.name}</option>`)}
|
||||
</select>
|
||||
<div class="form-text" style="font-size:.75rem">${t('roles.form.assistant_hint')}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">${t('roles.form.attrs')} <span class="text-muted">${t('roles.form.attrs_hint')}</span></label>
|
||||
<input class="form-control font-monospace" placeholder=${t('roles.form.attrs_ph')} .value=${form.attrs}
|
||||
@@ -275,6 +307,7 @@ export class RolesPage extends LightElement {
|
||||
<th>${t('roles.col.label')}</th>
|
||||
<th>${t('roles.col.group')}</th>
|
||||
<th>${t('roles.col.interface')}</th>
|
||||
<th>${t('roles.col.assistant')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -289,6 +322,7 @@ export class RolesPage extends LightElement {
|
||||
<td>${this._attrsUiMode(r.attrs) === 'simple'
|
||||
? html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.simple')}</span>`
|
||||
: html`<span class="badge bg-secondary">${t('roles.badge.full')}</span>`}</td>
|
||||
<td>${this._agentName(this._attrsChatAgent(r.attrs))}</td>
|
||||
<td>
|
||||
<div class="um-actions">
|
||||
<button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.edit')}
|
||||
|
||||
+6
-2
@@ -118,7 +118,7 @@ export default {
|
||||
'llmr.empty': 'No requests found.',
|
||||
'llmr.total': '{n} rows',
|
||||
'llmr.filter.agent_id': 'Agent ID',
|
||||
'llmr.filter.agent_ph': 'e.g. main',
|
||||
'llmr.filter.agent_ph': 'e.g. assistant',
|
||||
'llmr.filter.source': 'Source',
|
||||
'llmr.filter.source_ph': 'e.g. web, tic, cron',
|
||||
'llmr.filter.from': 'From',
|
||||
@@ -556,7 +556,7 @@ export default {
|
||||
'approval.form.source': 'Source',
|
||||
'approval.form.source_any': 'Any',
|
||||
'approval.form.agent_id': 'Agent ID',
|
||||
'approval.form.agent_id_ph': 'main (empty = any)',
|
||||
'approval.form.agent_id_ph': 'assistant (empty = any)',
|
||||
'approval.form.note': 'Note',
|
||||
'approval.form.note_ph': 'Short description…',
|
||||
'approval.form.cancel': 'Cancel',
|
||||
@@ -656,6 +656,7 @@ export default {
|
||||
'roles.col.label': 'Label',
|
||||
'roles.col.group': 'Permission group',
|
||||
'roles.col.interface': 'Interface',
|
||||
'roles.col.assistant': 'Assistant',
|
||||
|
||||
'roles.badge.simple': 'Simple',
|
||||
'roles.badge.full': 'Full',
|
||||
@@ -674,6 +675,9 @@ export default {
|
||||
'roles.form.interface_full': 'Full — all pages',
|
||||
'roles.form.interface_simple': 'Simple — chat only',
|
||||
'roles.form.interface_hint': 'Members with the simple interface see only chat and inbox. Stored as <code>ui_mode</code> in the attrs JSON below.',
|
||||
'roles.form.assistant': 'Default assistant',
|
||||
'roles.form.assistant_default': 'Default (Assistant)',
|
||||
'roles.form.assistant_hint': 'The agent members of this role chat with by default. Overridable per person later.',
|
||||
'roles.form.attrs': 'Attrs',
|
||||
'roles.form.attrs_hint': '(JSON, optional)',
|
||||
'roles.form.attrs_ph': '{}',
|
||||
|
||||
+6
-2
@@ -118,7 +118,7 @@ export default {
|
||||
'llmr.empty': 'Aucune requête trouvée.',
|
||||
'llmr.total': '{n} lignes',
|
||||
'llmr.filter.agent_id': 'ID de l\'agent',
|
||||
'llmr.filter.agent_ph': 'ex. main',
|
||||
'llmr.filter.agent_ph': 'ex. assistant',
|
||||
'llmr.filter.source': 'Source',
|
||||
'llmr.filter.source_ph': 'ex. web, tic, cron',
|
||||
'llmr.filter.from': 'De',
|
||||
@@ -556,7 +556,7 @@ export default {
|
||||
'approval.form.source': 'Source',
|
||||
'approval.form.source_any': 'Toute',
|
||||
'approval.form.agent_id': 'ID de l\'agent',
|
||||
'approval.form.agent_id_ph': 'main (vide = toute)',
|
||||
'approval.form.agent_id_ph': 'assistant (vide = toute)',
|
||||
'approval.form.note': 'Note',
|
||||
'approval.form.note_ph': 'Brève description…',
|
||||
'approval.form.cancel': 'Annuler',
|
||||
@@ -656,6 +656,7 @@ export default {
|
||||
'roles.col.label': 'Libellé',
|
||||
'roles.col.group': 'Groupe de permissions',
|
||||
'roles.col.interface': 'Interface',
|
||||
'roles.col.assistant': 'Assistant',
|
||||
|
||||
'roles.badge.simple': 'Simple',
|
||||
'roles.badge.full': 'Complet',
|
||||
@@ -674,6 +675,9 @@ export default {
|
||||
'roles.form.interface_full': 'Complet — toutes les pages',
|
||||
'roles.form.interface_simple': 'Simple — discussion uniquement',
|
||||
'roles.form.interface_hint': 'Les membres avec l\'interface simple voient seulement la discussion et la boîte de réception. Stocké comme <code>ui_mode</code> dans le JSON d\'attributs ci-dessous.',
|
||||
'roles.form.assistant': 'Assistant par défaut',
|
||||
'roles.form.assistant_default': 'Par défaut (Assistant)',
|
||||
'roles.form.assistant_hint': 'L\'agent avec lequel les membres de ce rôle discutent par défaut. Remplaçable par personne plus tard.',
|
||||
'roles.form.attrs': 'Attributs',
|
||||
'roles.form.attrs_hint': '(JSON, facultatif)',
|
||||
'roles.form.attrs_ph': '{}',
|
||||
|
||||
+6
-2
@@ -118,7 +118,7 @@ export default {
|
||||
'llmr.empty': 'Nessuna richiesta trovata.',
|
||||
'llmr.total': '{n} righe',
|
||||
'llmr.filter.agent_id': 'ID Agente',
|
||||
'llmr.filter.agent_ph': 'es. main',
|
||||
'llmr.filter.agent_ph': 'es. assistant',
|
||||
'llmr.filter.source': 'Sorgente',
|
||||
'llmr.filter.source_ph': 'es. web, tic, cron',
|
||||
'llmr.filter.from': 'Da',
|
||||
@@ -556,7 +556,7 @@ export default {
|
||||
'approval.form.source': 'Origine',
|
||||
'approval.form.source_any': 'Qualsiasi',
|
||||
'approval.form.agent_id': 'ID agente',
|
||||
'approval.form.agent_id_ph': 'main (vuoto = qualsiasi)',
|
||||
'approval.form.agent_id_ph': 'assistant (vuoto = qualsiasi)',
|
||||
'approval.form.note': 'Nota',
|
||||
'approval.form.note_ph': 'Breve descrizione…',
|
||||
'approval.form.cancel': 'Annulla',
|
||||
@@ -656,6 +656,7 @@ export default {
|
||||
'roles.col.label': 'Etichetta',
|
||||
'roles.col.group': 'Gruppo di permessi',
|
||||
'roles.col.interface': 'Interfaccia',
|
||||
'roles.col.assistant': 'Assistente',
|
||||
|
||||
'roles.badge.simple': 'Semplice',
|
||||
'roles.badge.full': 'Completa',
|
||||
@@ -674,6 +675,9 @@ export default {
|
||||
'roles.form.interface_full': 'Completa — tutte le pagine',
|
||||
'roles.form.interface_simple': 'Semplice — solo chat',
|
||||
'roles.form.interface_hint': 'I membri con interfaccia semplice vedono solo chat e richieste. Memorizzato come <code>ui_mode</code> nell\'attrs JSON qui sotto.',
|
||||
'roles.form.assistant': 'Assistente predefinito',
|
||||
'roles.form.assistant_default': 'Predefinito (Assistente)',
|
||||
'roles.form.assistant_hint': 'L\'agente con cui i membri di questo ruolo parlano di default. In futuro sovrascrivibile per persona.',
|
||||
'roles.form.attrs': 'Attrs',
|
||||
'roles.form.attrs_hint': '(JSON, opzionale)',
|
||||
'roles.form.attrs_ph': '{}',
|
||||
|
||||
Reference in New Issue
Block a user