641 lines
19 KiB
Rust
641 lines
19 KiB
Rust
use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent};
|
|
use serde::Deserialize;
|
|
use std::ffi::OsString;
|
|
use std::io::{BufRead, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, ExitStatus};
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct AgentStreamRequest {
|
|
pub message: String,
|
|
pub system_prompt: Option<String>,
|
|
pub vault_path: String,
|
|
#[serde(default)]
|
|
pub vault_paths: Vec<String>,
|
|
pub permission_mode: AiAgentPermissionMode,
|
|
}
|
|
|
|
pub(crate) struct JsonLineRun {
|
|
pub session_id: String,
|
|
pub stderr_output: String,
|
|
pub status: ExitStatus,
|
|
}
|
|
|
|
pub(crate) struct AgentCommandTarget {
|
|
pub program: PathBuf,
|
|
pub first_arg: Option<PathBuf>,
|
|
}
|
|
|
|
pub(crate) struct JsonLineProcess<'a> {
|
|
command: Command,
|
|
process_name: &'static str,
|
|
stdin_input: Option<&'a str>,
|
|
}
|
|
|
|
impl<'a> JsonLineProcess<'a> {
|
|
pub(crate) fn new(command: Command, process_name: &'static str) -> Self {
|
|
Self {
|
|
command,
|
|
process_name,
|
|
stdin_input: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn with_stdin(mut self, stdin_input: Option<&'a str>) -> Self {
|
|
self.stdin_input = stdin_input;
|
|
self
|
|
}
|
|
}
|
|
|
|
pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String {
|
|
match system_prompt
|
|
.map(str::trim)
|
|
.filter(|prompt| !prompt.is_empty())
|
|
{
|
|
Some(system_prompt) => {
|
|
format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}")
|
|
}
|
|
None => message.to_string(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn mcp_server_path_string() -> Result<String, String> {
|
|
Ok(crate::mcp::mcp_server_dir()?
|
|
.join("index.js")
|
|
.to_str()
|
|
.ok_or("Invalid MCP server path")?
|
|
.to_string())
|
|
}
|
|
|
|
pub(crate) fn active_vault_paths(primary_vault_path: &str, vault_paths: &[String]) -> Vec<String> {
|
|
let mut paths = Vec::new();
|
|
push_unique_vault_path(&mut paths, primary_vault_path);
|
|
for path in vault_paths {
|
|
push_unique_vault_path(&mut paths, path);
|
|
}
|
|
paths
|
|
}
|
|
|
|
pub(crate) fn active_vault_paths_json(primary_vault_path: &str, vault_paths: &[String]) -> String {
|
|
serde_json::to_string(&active_vault_paths(primary_vault_path, vault_paths))
|
|
.unwrap_or_else(|_| format!("[{}]", serde_json::json!(primary_vault_path)))
|
|
}
|
|
|
|
fn push_unique_vault_path(paths: &mut Vec<String>, path: &str) {
|
|
let trimmed = path.trim();
|
|
if trimmed.is_empty() || paths.iter().any(|existing| existing == trimmed) {
|
|
return;
|
|
}
|
|
paths.push(trimmed.to_string());
|
|
}
|
|
|
|
pub(crate) fn version_for_binary(binary: &Path) -> Option<String> {
|
|
let target = command_target_avoiding_windows_cmd_shim(binary).ok()?;
|
|
let mut command = crate::hidden_command(&target.program);
|
|
configure_agent_command_environment(&mut command, binary);
|
|
if let Some(first_arg) = target.first_arg {
|
|
command.arg(first_arg);
|
|
}
|
|
command
|
|
.arg("--version")
|
|
.output()
|
|
.ok()
|
|
.filter(|output| output.status.success())
|
|
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
}
|
|
|
|
pub(crate) fn command_target_avoiding_windows_cmd_shim(
|
|
binary: &Path,
|
|
) -> Result<AgentCommandTarget, String> {
|
|
if is_windows_batch_shim(binary) {
|
|
if let Some(script) = node_script_from_windows_cmd_shim(binary) {
|
|
return Ok(AgentCommandTarget {
|
|
program: crate::mcp::find_node()?,
|
|
first_arg: Some(script),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(AgentCommandTarget {
|
|
program: binary.to_path_buf(),
|
|
first_arg: None,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn configure_agent_command_environment(command: &mut Command, binary: &Path) {
|
|
if let Some(path) = expanded_agent_path(binary) {
|
|
command.env("PATH", path);
|
|
}
|
|
}
|
|
|
|
fn expanded_agent_path(binary: &Path) -> Option<OsString> {
|
|
let mut paths = std::env::var_os("PATH")
|
|
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
|
|
.unwrap_or_default();
|
|
|
|
for candidate in agent_path_candidates(binary) {
|
|
push_unique_path(&mut paths, candidate);
|
|
}
|
|
|
|
std::env::join_paths(paths).ok()
|
|
}
|
|
|
|
fn agent_path_candidates(binary: &Path) -> Vec<PathBuf> {
|
|
let mut candidates = Vec::new();
|
|
|
|
if let Some(parent) = binary
|
|
.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
{
|
|
candidates.push(parent.to_path_buf());
|
|
}
|
|
|
|
if let Some(home) = dirs::home_dir() {
|
|
candidates.extend([
|
|
home.join(".local/bin"),
|
|
home.join(".local/share/mise/shims"),
|
|
home.join(".asdf/shims"),
|
|
home.join(".npm-global/bin"),
|
|
home.join(".npm/bin"),
|
|
home.join(".bun/bin"),
|
|
home.join(".linuxbrew/bin"),
|
|
home.join("AppData/Roaming/npm"),
|
|
home.join("AppData/Local/pnpm"),
|
|
home.join("scoop/shims"),
|
|
]);
|
|
}
|
|
|
|
candidates.extend([
|
|
PathBuf::from("/opt/homebrew/bin"),
|
|
PathBuf::from("/usr/local/bin"),
|
|
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
|
|
]);
|
|
|
|
candidates
|
|
}
|
|
|
|
fn push_unique_path(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
|
|
if candidate.as_os_str().is_empty() {
|
|
return;
|
|
}
|
|
if paths.iter().any(|path| path == &candidate) {
|
|
return;
|
|
}
|
|
paths.push(candidate);
|
|
}
|
|
|
|
pub(crate) fn find_executable_binary_candidate(
|
|
candidates: Vec<PathBuf>,
|
|
agent_label: &str,
|
|
) -> Result<Option<PathBuf>, String> {
|
|
let mut first_unusable_candidate = None;
|
|
|
|
for candidate in candidates {
|
|
if !candidate.exists() {
|
|
continue;
|
|
}
|
|
|
|
if is_executable_file(&candidate) {
|
|
return Ok(Some(candidate));
|
|
}
|
|
|
|
if first_unusable_candidate.is_none() {
|
|
first_unusable_candidate = Some(candidate);
|
|
}
|
|
}
|
|
|
|
match first_unusable_candidate {
|
|
Some(candidate) => Err(format!(
|
|
"{agent_label} binary found at {} but it is not executable. Fix the file permissions or reinstall the CLI.",
|
|
candidate.display()
|
|
)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn is_executable_file(path: &Path) -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
std::fs::metadata(path)
|
|
.map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
path.is_file() && has_windows_cli_extension(path)
|
|
}
|
|
|
|
#[cfg(all(not(unix), not(windows)))]
|
|
{
|
|
path.is_file()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn has_windows_cli_extension(path: &Path) -> bool {
|
|
path.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.is_some_and(|extension| {
|
|
["bat", "cmd", "com", "exe"]
|
|
.iter()
|
|
.any(|expected| extension.eq_ignore_ascii_case(expected))
|
|
})
|
|
}
|
|
|
|
fn is_windows_batch_shim(binary: &Path) -> bool {
|
|
binary
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.is_some_and(|extension| {
|
|
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
|
|
})
|
|
}
|
|
|
|
fn node_script_from_windows_cmd_shim(binary: &Path) -> Option<PathBuf> {
|
|
let contents = std::fs::read_to_string(binary).ok()?;
|
|
contents
|
|
.split('"')
|
|
.skip(1)
|
|
.step_by(2)
|
|
.filter(|token| is_node_script_token(token))
|
|
.find_map(|token| resolve_cmd_shim_script_path(binary, token))
|
|
}
|
|
|
|
fn is_node_script_token(token: &str) -> bool {
|
|
let lower = token.to_ascii_lowercase();
|
|
(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"))
|
|
&& (lower.starts_with("%dp0%") || lower.starts_with("%~dp0"))
|
|
}
|
|
|
|
fn resolve_cmd_shim_script_path(binary: &Path, token: &str) -> Option<PathBuf> {
|
|
let relative = token
|
|
.strip_prefix("%dp0%")
|
|
.or_else(|| token.strip_prefix("%~dp0"))?
|
|
.trim_start_matches(['\\', '/']);
|
|
let mut script = binary.parent()?.to_path_buf();
|
|
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
|
|
script.push(part);
|
|
}
|
|
script.is_file().then_some(script)
|
|
}
|
|
|
|
pub(crate) fn parse_json_line(
|
|
line: Result<String, std::io::Error>,
|
|
) -> Result<Option<serde_json::Value>, String> {
|
|
let line = line.map_err(|error| format!("Read error: {error}"))?;
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(serde_json::from_str::<serde_json::Value>(trimmed).ok())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn parse_ai_agent_json_line<F>(
|
|
line: Result<String, std::io::Error>,
|
|
emit: &mut F,
|
|
) -> Option<serde_json::Value>
|
|
where
|
|
F: FnMut(AiAgentStreamEvent),
|
|
{
|
|
match parse_json_line(line) {
|
|
Ok(json) => json,
|
|
Err(message) => {
|
|
emit(AiAgentStreamEvent::Error { message });
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn run_json_line_process<Event, F, H>(
|
|
command: Command,
|
|
process_name: &'static str,
|
|
emit: &mut F,
|
|
error_event: impl Fn(String) -> Event,
|
|
handle_json: H,
|
|
) -> Result<JsonLineRun, String>
|
|
where
|
|
F: FnMut(Event),
|
|
H: FnMut(&serde_json::Value, &mut F, &mut String),
|
|
{
|
|
run_json_line_process_with_stdin(
|
|
JsonLineProcess::new(command, process_name),
|
|
emit,
|
|
error_event,
|
|
handle_json,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn run_json_line_process_with_stdin<Event, F, H>(
|
|
mut process: JsonLineProcess<'_>,
|
|
emit: &mut F,
|
|
error_event: impl Fn(String) -> Event,
|
|
mut handle_json: H,
|
|
) -> Result<JsonLineRun, String>
|
|
where
|
|
F: FnMut(Event),
|
|
H: FnMut(&serde_json::Value, &mut F, &mut String),
|
|
{
|
|
if process.stdin_input.is_some() {
|
|
process.command.stdin(std::process::Stdio::piped());
|
|
}
|
|
|
|
let mut child = process
|
|
.command
|
|
.spawn()
|
|
.map_err(|error| format_spawn_error(process.process_name, &error))?;
|
|
let stdin_write_error =
|
|
write_stdin_input(&mut child, process.process_name, process.stdin_input);
|
|
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
|
let reader = std::io::BufReader::new(stdout);
|
|
let mut session_id = String::new();
|
|
|
|
for line in reader.lines() {
|
|
match parse_json_line(line) {
|
|
Ok(Some(json)) => handle_json(&json, emit, &mut session_id),
|
|
Ok(None) => {}
|
|
Err(message) => {
|
|
emit(error_event(message));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let stderr_output = child
|
|
.stderr
|
|
.take()
|
|
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
|
.unwrap_or_default();
|
|
let status = child
|
|
.wait()
|
|
.map_err(|error| format!("Wait failed: {error}"))?;
|
|
let stderr_output = with_stdin_write_error(stderr_output, stdin_write_error);
|
|
|
|
Ok(JsonLineRun {
|
|
session_id,
|
|
stderr_output,
|
|
status,
|
|
})
|
|
}
|
|
|
|
fn write_stdin_input(
|
|
child: &mut std::process::Child,
|
|
process_name: &str,
|
|
stdin_input: Option<&str>,
|
|
) -> Option<String> {
|
|
let input = stdin_input?;
|
|
let Some(mut stdin) = child.stdin.take() else {
|
|
return Some(format!(
|
|
"Failed to write {process_name} stdin: no stdin handle"
|
|
));
|
|
};
|
|
|
|
stdin
|
|
.write_all(input.as_bytes())
|
|
.err()
|
|
.map(|error| format!("Failed to write {process_name} stdin: {error}"))
|
|
}
|
|
|
|
fn with_stdin_write_error(mut stderr_output: String, stdin_write_error: Option<String>) -> String {
|
|
let Some(error) = stdin_write_error else {
|
|
return stderr_output;
|
|
};
|
|
|
|
if !stderr_output.is_empty() {
|
|
stderr_output.push('\n');
|
|
}
|
|
stderr_output.push_str(&error);
|
|
stderr_output
|
|
}
|
|
|
|
fn format_spawn_error(process_name: &str, error: &std::io::Error) -> String {
|
|
if error.kind() == std::io::ErrorKind::NotFound {
|
|
return format!(
|
|
"Failed to start {process_name}: the CLI or one of its runtime dependencies was not found. If it was installed with Homebrew, make sure /opt/homebrew/bin or /usr/local/bin contains the CLI and Node.js, then restart Tolaria. Details: {error}"
|
|
);
|
|
}
|
|
|
|
format!("Failed to spawn {process_name}: {error}")
|
|
}
|
|
|
|
pub(crate) fn run_ai_agent_json_stream<F>(
|
|
command: Command,
|
|
process_name: &'static str,
|
|
mut emit: F,
|
|
session_id: impl Fn(&serde_json::Value) -> Option<&str>,
|
|
dispatch_event: impl Fn(&serde_json::Value, &mut F),
|
|
format_error: impl Fn(String, String) -> String,
|
|
) -> Result<String, String>
|
|
where
|
|
F: FnMut(AiAgentStreamEvent),
|
|
{
|
|
let run = run_json_line_process(
|
|
command,
|
|
process_name,
|
|
&mut emit,
|
|
|message| AiAgentStreamEvent::Error { message },
|
|
|json, emit, active_session_id| {
|
|
if let Some(id) = session_id(json) {
|
|
*active_session_id = id.to_string();
|
|
}
|
|
dispatch_event(json, emit);
|
|
},
|
|
)?;
|
|
|
|
if !run.status.success() {
|
|
emit(AiAgentStreamEvent::Error {
|
|
message: format_error(run.stderr_output, run.status.to_string()),
|
|
});
|
|
}
|
|
|
|
emit(AiAgentStreamEvent::Done);
|
|
Ok(run.session_id)
|
|
}
|
|
|
|
/// Shared binary discovery: look up a CLI command by name using PATH, login shell, then candidates.
|
|
pub(crate) fn find_cli_binary(
|
|
name: &str,
|
|
candidates: Vec<PathBuf>,
|
|
label: &str,
|
|
install_hint: &str,
|
|
) -> Result<PathBuf, String> {
|
|
if let Some(binary) = find_binary_on_path(name) {
|
|
return Ok(binary);
|
|
}
|
|
if let Some(binary) = find_binary_in_user_shell(name) {
|
|
return Ok(binary);
|
|
}
|
|
if let Some(binary) = find_executable_binary_candidate(candidates, label)? {
|
|
return Ok(binary);
|
|
}
|
|
Err(format!("{label} not found. Install it: {install_hint}"))
|
|
}
|
|
|
|
fn find_binary_on_path(name: &str) -> Option<PathBuf> {
|
|
let cmd = if cfg!(windows) { "where" } else { "which" };
|
|
crate::hidden_command(cmd)
|
|
.arg(name)
|
|
.output()
|
|
.ok()
|
|
.and_then(|output| path_from_successful_output(&output))
|
|
}
|
|
|
|
fn find_binary_in_user_shell(name: &str) -> Option<PathBuf> {
|
|
shell_candidates()
|
|
.into_iter()
|
|
.filter(|shell| shell.exists())
|
|
.find_map(|shell| command_path_from_shell(&shell, name))
|
|
}
|
|
|
|
fn shell_candidates() -> Vec<PathBuf> {
|
|
let mut shells = Vec::new();
|
|
if let Some(shell) = std::env::var_os("SHELL") {
|
|
if !shell.is_empty() {
|
|
shells.push(PathBuf::from(shell));
|
|
}
|
|
}
|
|
shells.push(PathBuf::from("/bin/zsh"));
|
|
shells.push(PathBuf::from("/bin/bash"));
|
|
shells
|
|
}
|
|
|
|
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
|
crate::hidden_command(shell)
|
|
.arg("-lc")
|
|
.arg(format!("command -v {command}"))
|
|
.output()
|
|
.ok()
|
|
.and_then(|output| path_from_successful_output(&output))
|
|
}
|
|
|
|
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
|
if output.status.success() {
|
|
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub(crate) fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
|
stdout.lines().find_map(|line| {
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let candidate = PathBuf::from(trimmed);
|
|
candidate.exists().then_some(candidate)
|
|
})
|
|
}
|
|
|
|
/// Shared check_cli pattern for CLI agents.
|
|
pub(crate) fn check_cli_availability(
|
|
find_binary: impl FnOnce() -> Result<PathBuf, String>,
|
|
) -> crate::ai_agents::AiAgentAvailability {
|
|
match find_binary() {
|
|
Ok(binary) => crate::ai_agents::AiAgentAvailability {
|
|
installed: true,
|
|
version: version_for_binary(&binary),
|
|
},
|
|
Err(_) => crate::ai_agents::AiAgentAvailability {
|
|
installed: false,
|
|
version: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn build_prompt_keeps_system_prompt_first() {
|
|
let prompt = build_prompt("Rename the note", Some("Be concise"));
|
|
|
|
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
|
assert!(prompt.contains("User request:\nRename the note"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_prompt_skips_blank_system_prompt() {
|
|
assert_eq!(
|
|
build_prompt("Rename the note", Some(" ")),
|
|
"Rename the note"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
|
assert!(parse_json_line(Ok(" ".into())).unwrap().is_none());
|
|
assert!(parse_json_line(Ok("not json".into())).unwrap().is_none());
|
|
|
|
let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err();
|
|
assert!(error.contains("broken pipe"));
|
|
}
|
|
|
|
#[test]
|
|
fn agent_command_environment_keeps_homebrew_shims_available() {
|
|
let mut command = Command::new("/opt/homebrew/bin/codex");
|
|
configure_agent_command_environment(&mut command, Path::new("/opt/homebrew/bin/codex"));
|
|
let path = command
|
|
.get_envs()
|
|
.find(|(key, _)| *key == std::ffi::OsStr::new("PATH"))
|
|
.and_then(|(_, value)| value)
|
|
.expect("PATH should be set");
|
|
let paths = std::env::split_paths(path).collect::<Vec<_>>();
|
|
|
|
assert!(paths.contains(&PathBuf::from("/opt/homebrew/bin")));
|
|
assert!(paths.contains(&PathBuf::from("/usr/local/bin")));
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_not_found_errors_explain_gui_path_runtime_dependencies() {
|
|
let error = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
|
|
let message = format_spawn_error("codex", &error);
|
|
|
|
assert!(message.contains("Failed to start codex"));
|
|
assert!(message.contains("/opt/homebrew/bin"));
|
|
assert!(message.contains("Node.js"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let unusable = dir.path().join("codex-unusable");
|
|
let executable = dir.path().join("codex");
|
|
std::fs::write(&unusable, "#!/bin/sh\n").unwrap();
|
|
std::fs::write(&executable, "#!/bin/sh\n").unwrap();
|
|
std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap();
|
|
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
let found =
|
|
find_executable_binary_candidate(vec![unusable, executable.clone()], "Codex CLI")
|
|
.unwrap();
|
|
|
|
assert_eq!(found, Some(executable));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn executable_binary_candidate_reports_unusable_file_when_no_candidate_works() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let unusable = dir.path().join("opencode");
|
|
std::fs::write(&unusable, "#!/bin/sh\n").unwrap();
|
|
std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap();
|
|
|
|
let error =
|
|
find_executable_binary_candidate(vec![unusable.clone()], "OpenCode CLI").unwrap_err();
|
|
|
|
assert!(error.contains("OpenCode CLI binary found"));
|
|
assert!(error.contains(&unusable.display().to_string()));
|
|
assert!(error.contains("not executable"));
|
|
}
|
|
}
|