session-log-decouple-item実装
This commit is contained in:
parent
913ee4764a
commit
8a9e3b4fe3
|
|
@ -28,12 +28,14 @@
|
|||
|
||||
pub mod event_trace;
|
||||
pub mod fs_store;
|
||||
pub mod logged_item;
|
||||
pub mod session;
|
||||
pub mod session_log;
|
||||
pub mod store;
|
||||
|
||||
pub use event_trace::TraceEntry;
|
||||
pub use fs_store::FsStore;
|
||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||
pub use session::{
|
||||
SessionStartState, create_compacted_session, create_session, create_session_with_id,
|
||||
ensure_head_or_fork, fork, fork_at, restore, save_config_changed, save_delta, save_extension,
|
||||
|
|
|
|||
309
crates/session-store/src/logged_item.rs
Normal file
309
crates/session-store/src/logged_item.rs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
//! Persistence-stable mirror of `llm_worker::Item`.
|
||||
//!
|
||||
//! `LogEntry` does not embed `Item` directly because that couples the on-disk
|
||||
//! schema to the LLM worker's internal type — a field rename or addition there
|
||||
//! would break every existing log. Instead, history-bearing variants serialize
|
||||
//! a [`LoggedItem`] that lives in this crate, and conversions translate at the
|
||||
//! save / replay boundaries.
|
||||
//!
|
||||
//! Fields kept here are limited to what is needed to reconstruct a worker
|
||||
//! `Item` for replay. `id` and `status` annotations are intentionally dropped
|
||||
//! (they are output-side metadata; replayed items synthesize fresh `None`).
|
||||
//! `Reasoning::encrypted_content` is preserved because OpenAI Responses ZDR
|
||||
//! requires it on stateless re-send.
|
||||
|
||||
use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LoggedItem {
|
||||
Message {
|
||||
role: LoggedRole,
|
||||
content: Vec<LoggedContentPart>,
|
||||
},
|
||||
ToolCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
ToolResult {
|
||||
call_id: String,
|
||||
summary: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
},
|
||||
Reasoning {
|
||||
text: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
summary: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
encrypted_content: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LoggedRole {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LoggedContentPart {
|
||||
Text { text: String },
|
||||
Refusal { refusal: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item ↔ LoggedItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl From<&Item> for LoggedItem {
|
||||
fn from(item: &Item) -> Self {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => Self::Message {
|
||||
role: (*role).into(),
|
||||
content: content.iter().map(LoggedContentPart::from).collect(),
|
||||
},
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => Self::ToolCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: arguments.clone(),
|
||||
},
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
..
|
||||
} => Self::ToolResult {
|
||||
call_id: call_id.clone(),
|
||||
summary: summary.clone(),
|
||||
content: content.clone(),
|
||||
},
|
||||
Item::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
..
|
||||
} => Self::Reasoning {
|
||||
text: text.clone(),
|
||||
summary: summary.clone(),
|
||||
encrypted_content: encrypted_content.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Item> for LoggedItem {
|
||||
fn from(item: Item) -> Self {
|
||||
Self::from(&item)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoggedItem> for Item {
|
||||
fn from(logged: LoggedItem) -> Self {
|
||||
match logged {
|
||||
LoggedItem::Message { role, content } => Item::Message {
|
||||
id: None,
|
||||
role: role.into(),
|
||||
content: content.into_iter().map(Into::into).collect(),
|
||||
status: None,
|
||||
},
|
||||
LoggedItem::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => Item::ToolCall {
|
||||
id: None,
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
status: None,
|
||||
},
|
||||
LoggedItem::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
} => Item::ToolResult {
|
||||
id: None,
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
},
|
||||
LoggedItem::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
} => Item::Reasoning {
|
||||
id: None,
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
status: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a slice of worker items into logged form.
|
||||
pub fn to_logged(items: &[Item]) -> Vec<LoggedItem> {
|
||||
items.iter().map(LoggedItem::from).collect()
|
||||
}
|
||||
|
||||
/// Convert logged items back into worker form.
|
||||
pub fn from_logged(items: Vec<LoggedItem>) -> Vec<Item> {
|
||||
items.into_iter().map(Item::from).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role ↔ LoggedRole
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl From<Role> for LoggedRole {
|
||||
fn from(role: Role) -> Self {
|
||||
match role {
|
||||
Role::User => Self::User,
|
||||
Role::Assistant => Self::Assistant,
|
||||
Role::System => Self::System,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoggedRole> for Role {
|
||||
fn from(role: LoggedRole) -> Self {
|
||||
match role {
|
||||
LoggedRole::User => Self::User,
|
||||
LoggedRole::Assistant => Self::Assistant,
|
||||
LoggedRole::System => Self::System,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ContentPart ↔ LoggedContentPart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl From<&ContentPart> for LoggedContentPart {
|
||||
fn from(part: &ContentPart) -> Self {
|
||||
match part {
|
||||
ContentPart::Text { text } => Self::Text { text: text.clone() },
|
||||
ContentPart::Refusal { refusal } => Self::Refusal {
|
||||
refusal: refusal.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoggedContentPart> for ContentPart {
|
||||
fn from(part: LoggedContentPart) -> Self {
|
||||
match part {
|
||||
LoggedContentPart::Text { text } => Self::Text { text },
|
||||
LoggedContentPart::Refusal { refusal } => Self::Refusal { refusal },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_user_message_text() {
|
||||
let original = Item::user_message("hello");
|
||||
let logged: LoggedItem = (&original).into();
|
||||
let restored: Item = logged.into();
|
||||
// id / status are dropped by design; compare semantically.
|
||||
match restored {
|
||||
Item::Message { role, content, .. } => {
|
||||
assert_eq!(role, Role::User);
|
||||
assert_eq!(content.len(), 1);
|
||||
match &content[0] {
|
||||
ContentPart::Text { text } => assert_eq!(text, "hello"),
|
||||
other => panic!("unexpected content: {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_tool_call() {
|
||||
let original = Item::tool_call("call_42", "get_weather", r#"{"city":"Tokyo"}"#);
|
||||
let logged: LoggedItem = (&original).into();
|
||||
let json = serde_json::to_string(&logged).unwrap();
|
||||
let parsed: LoggedItem = serde_json::from_str(&json).unwrap();
|
||||
match Item::from(parsed) {
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(call_id, "call_42");
|
||||
assert_eq!(name, "get_weather");
|
||||
assert_eq!(arguments, r#"{"city":"Tokyo"}"#);
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_reasoning_preserves_encrypted_content() {
|
||||
let original = Item::reasoning("step-by-step")
|
||||
.with_reasoning_summary(vec!["s1".into(), "s2".into()])
|
||||
.with_encrypted_content("opaque-blob");
|
||||
let logged: LoggedItem = (&original).into();
|
||||
let json = serde_json::to_string(&logged).unwrap();
|
||||
let parsed: LoggedItem = serde_json::from_str(&json).unwrap();
|
||||
match Item::from(parsed) {
|
||||
Item::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(text, "step-by-step");
|
||||
assert_eq!(summary, vec!["s1".to_string(), "s2".to_string()]);
|
||||
assert_eq!(encrypted_content.as_deref(), Some("opaque-blob"));
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_tool_result_with_content() {
|
||||
let original = Item::tool_result_with_content("call_1", "ok", "full output");
|
||||
let logged: LoggedItem = (&original).into();
|
||||
match Item::from(logged) {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(call_id, "call_1");
|
||||
assert_eq!(summary, "ok");
|
||||
assert_eq!(content.as_deref(), Some("full output"));
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_serialization_uses_kind_tag() {
|
||||
let logged: LoggedItem = (&Item::assistant_message("hi")).into();
|
||||
let value: serde_json::Value = serde_json::to_value(&logged).unwrap();
|
||||
assert_eq!(value["kind"], "message");
|
||||
assert_eq!(value["role"], "assistant");
|
||||
assert_eq!(value["content"][0]["kind"], "text");
|
||||
assert_eq!(value["content"][0]["text"], "hi");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
//! functions after state-mutating operations.
|
||||
|
||||
use crate::SessionId;
|
||||
use crate::logged_item::{LoggedItem, to_logged};
|
||||
use crate::session_log::{self, EntryHash, HashedEntry, LogEntry, SessionOrigin};
|
||||
use crate::store::{Store, StoreError};
|
||||
use llm_worker::WorkerResult;
|
||||
|
|
@ -44,7 +45,7 @@ pub async fn create_session_with_id(
|
|||
ts: session_log::now_millis(),
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history.to_vec(),
|
||||
history: to_logged(state.history),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
|
|
@ -73,7 +74,7 @@ pub async fn create_compacted_session(
|
|||
ts: session_log::now_millis(),
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history.to_vec(),
|
||||
history: to_logged(state.history),
|
||||
forked_from: None,
|
||||
compacted_from: Some(SessionOrigin {
|
||||
session_id: source_session_id,
|
||||
|
|
@ -121,7 +122,7 @@ pub async fn ensure_head_or_fork(
|
|||
ts: session_log::now_millis(),
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history.to_vec(),
|
||||
history: to_logged(state.history),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
|
|
@ -179,7 +180,7 @@ pub async fn save_delta(
|
|||
head_hash,
|
||||
LogEntry::ToolResults {
|
||||
ts,
|
||||
items: new_items[start..i].to_vec(),
|
||||
items: to_logged(&new_items[start..i]),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -198,7 +199,7 @@ pub async fn save_delta(
|
|||
head_hash,
|
||||
LogEntry::AssistantItems {
|
||||
ts,
|
||||
items: new_items[start..i].to_vec(),
|
||||
items: to_logged(&new_items[start..i]),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -209,7 +210,7 @@ pub async fn save_delta(
|
|||
head_hash,
|
||||
LogEntry::HookInjectedItems {
|
||||
ts,
|
||||
items: vec![new_items[i].clone()],
|
||||
items: vec![LoggedItem::from(&new_items[i])],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -369,7 +370,7 @@ pub async fn fork(
|
|||
ts: session_log::now_millis(),
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: state.history.to_vec(),
|
||||
history: to_logged(state.history),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
|
|
@ -402,7 +403,7 @@ pub async fn fork_at(
|
|||
ts: session_log::now_millis(),
|
||||
system_prompt: state.system_prompt,
|
||||
config: state.config,
|
||||
history: state.history,
|
||||
history: to_logged(&state.history),
|
||||
forked_from: Some(session_log::SessionOrigin {
|
||||
session_id: source_id,
|
||||
at_hash: at_hash.clone(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ use llm_worker::{UsageRecord, WorkerResult};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::logged_item::LoggedItem;
|
||||
|
||||
/// SHA-256 hash identifying a specific log entry in the chain.
|
||||
///
|
||||
/// Computed as `sha256(prev_hash_bytes || canonical_json(entry))`.
|
||||
|
|
@ -100,7 +102,7 @@ pub enum LogEntry {
|
|||
ts: u64,
|
||||
system_prompt: Option<String>,
|
||||
config: RequestConfig,
|
||||
history: Vec<Item>,
|
||||
history: Vec<LoggedItem>,
|
||||
/// Origin: forked from another session at a specific entry.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
forked_from: Option<SessionOrigin>,
|
||||
|
|
@ -113,13 +115,13 @@ pub enum LogEntry {
|
|||
UserInput { ts: u64, item: Item },
|
||||
|
||||
/// Assistant response items added to history (worker.rs:1040-1041).
|
||||
AssistantItems { ts: u64, items: Vec<Item> },
|
||||
AssistantItems { ts: u64, items: Vec<LoggedItem> },
|
||||
|
||||
/// Tool execution results added to history (worker.rs:897-900, 1072-1076).
|
||||
ToolResults { ts: u64, items: Vec<Item> },
|
||||
ToolResults { ts: u64, items: Vec<LoggedItem> },
|
||||
|
||||
/// Items injected by `on_turn_end` hook via `ContinueWithMessages` (worker.rs:1055).
|
||||
HookInjectedItems { ts: u64, items: Vec<Item> },
|
||||
HookInjectedItems { ts: u64, items: Vec<LoggedItem> },
|
||||
|
||||
/// Turn boundary. Records the turn count after increment.
|
||||
TurnEnd { ts: u64, turn_count: usize },
|
||||
|
|
@ -234,19 +236,25 @@ pub fn collect_state(entries: &[HashedEntry]) -> RestoredState {
|
|||
} => {
|
||||
state.system_prompt = system_prompt.clone();
|
||||
state.config = config.clone();
|
||||
state.history = history.clone();
|
||||
state.history = history.iter().cloned().map(Item::from).collect();
|
||||
}
|
||||
LogEntry::UserInput { item, .. } => {
|
||||
state.history.push(item.clone());
|
||||
}
|
||||
LogEntry::AssistantItems { items, .. } => {
|
||||
state.history.extend(items.iter().cloned());
|
||||
state
|
||||
.history
|
||||
.extend(items.iter().cloned().map(Item::from));
|
||||
}
|
||||
LogEntry::ToolResults { items, .. } => {
|
||||
state.history.extend(items.iter().cloned());
|
||||
state
|
||||
.history
|
||||
.extend(items.iter().cloned().map(Item::from));
|
||||
}
|
||||
LogEntry::HookInjectedItems { items, .. } => {
|
||||
state.history.extend(items.iter().cloned());
|
||||
state
|
||||
.history
|
||||
.extend(items.iter().cloned().map(Item::from));
|
||||
}
|
||||
LogEntry::TurnEnd { turn_count, .. } => {
|
||||
state.turn_count = *turn_count;
|
||||
|
|
@ -333,7 +341,7 @@ mod tests {
|
|||
ts: 1000,
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
config: RequestConfig::default().with_max_tokens(1024),
|
||||
history: vec![Item::user_message("seed")],
|
||||
history: vec![Item::user_message("seed").into()],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}]);
|
||||
|
|
@ -361,7 +369,7 @@ mod tests {
|
|||
},
|
||||
LogEntry::AssistantItems {
|
||||
ts: 3000,
|
||||
items: vec![Item::assistant_message("Hi!")],
|
||||
items: vec![Item::assistant_message("Hi!").into()],
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 3100,
|
||||
|
|
@ -396,19 +404,17 @@ mod tests {
|
|||
},
|
||||
LogEntry::AssistantItems {
|
||||
ts: 3000,
|
||||
items: vec![Item::tool_call(
|
||||
"call_1",
|
||||
"get_weather",
|
||||
r#"{"city":"Tokyo"}"#,
|
||||
)],
|
||||
items: vec![
|
||||
Item::tool_call("call_1", "get_weather", r#"{"city":"Tokyo"}"#).into(),
|
||||
],
|
||||
},
|
||||
LogEntry::ToolResults {
|
||||
ts: 3500,
|
||||
items: vec![Item::tool_result("call_1", "Sunny, 25C")],
|
||||
items: vec![Item::tool_result("call_1", "Sunny, 25C").into()],
|
||||
},
|
||||
LogEntry::AssistantItems {
|
||||
ts: 4000,
|
||||
items: vec![Item::assistant_message("It's sunny in Tokyo!")],
|
||||
items: vec![Item::assistant_message("It's sunny in Tokyo!").into()],
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 4100,
|
||||
|
|
@ -503,7 +509,7 @@ mod tests {
|
|||
},
|
||||
LogEntry::AssistantItems {
|
||||
ts: 2200,
|
||||
items: vec![Item::assistant_message("yo")],
|
||||
items: vec![Item::assistant_message("yo").into()],
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 3100,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ async fn round_trip_write_and_read() {
|
|||
},
|
||||
LogEntry::AssistantItems {
|
||||
ts: 3000,
|
||||
items: vec![Item::assistant_message("Hi there!")],
|
||||
items: vec![Item::assistant_message("Hi there!").into()],
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 3100,
|
||||
|
|
@ -74,7 +74,10 @@ async fn create_session_writes_all_entries() {
|
|||
ts: 1000,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![Item::user_message("seed"), Item::assistant_message("ok")],
|
||||
history: vec![
|
||||
Item::user_message("seed").into(),
|
||||
Item::assistant_message("ok").into(),
|
||||
],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}]);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ use ratatui::widgets::Paragraph;
|
|||
use ratatui::{Frame, TerminalOptions, Viewport};
|
||||
use pod_registry::lookup_session;
|
||||
use session_store::{
|
||||
ContentPart, FsStore, HashedEntry, Item, LogEntry, SessionId, Store,
|
||||
ContentPart, FsStore, HashedEntry, Item, LogEntry, LoggedContentPart, LoggedItem, SessionId,
|
||||
Store,
|
||||
};
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
|
|
@ -181,7 +182,7 @@ fn last_message_preview(entries: &[HashedEntry]) -> Option<String> {
|
|||
}
|
||||
}
|
||||
LogEntry::AssistantItems { items, .. } => {
|
||||
if let Some(text) = items.iter().find_map(first_text) {
|
||||
if let Some(text) = items.iter().find_map(first_text_logged) {
|
||||
return Some(format!("assistant: {}", trim_one_line(&text, 60)));
|
||||
}
|
||||
}
|
||||
|
|
@ -201,6 +202,16 @@ fn first_text(item: &Item) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
fn first_text_logged(item: &LoggedItem) -> Option<String> {
|
||||
match item {
|
||||
LoggedItem::Message { content, .. } => content.iter().find_map(|p| match p {
|
||||
LoggedContentPart::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_one_line(s: &str, max_chars: usize) -> String {
|
||||
let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
|
||||
if collapsed.chars().count() <= max_chars {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user