Read-Tool wird jetzt immer erlaubt (nicht nur mit Workspace), und /tmp/gott-cli-images/ wird als --add-dir hinzugefügt, damit gesendete Bilder von der CLI gelesen werden können. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
649 lines
26 KiB
C#
649 lines
26 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Gott.Services;
|
|
|
|
/// <summary>
|
|
/// Service für die Kommunikation mit der Claude Code CLI.
|
|
/// Verwendet die CLI als Backend für OAuth-basierte Chats (kein API-Key nötig).
|
|
/// </summary>
|
|
public class ClaudeCliService
|
|
{
|
|
private readonly ILogger<ClaudeCliService> _logger;
|
|
private readonly string _cliPath;
|
|
private readonly bool _isAvailable;
|
|
|
|
public ClaudeCliService(ILogger<ClaudeCliService> logger)
|
|
{
|
|
_logger = logger;
|
|
_cliPath = FindCliPath();
|
|
_isAvailable = !string.IsNullOrEmpty(_cliPath) && File.Exists(_cliPath);
|
|
|
|
if (_isAvailable)
|
|
_logger.LogInformation("Claude CLI gefunden: {Path}", _cliPath);
|
|
else
|
|
_logger.LogWarning("Claude CLI nicht gefunden. CLI-basierte Chats sind nicht verfügbar.");
|
|
}
|
|
|
|
public bool IsAvailable => _isAvailable;
|
|
public string CliPath => _cliPath;
|
|
|
|
private static string FindCliPath()
|
|
{
|
|
var possiblePaths = new[]
|
|
{
|
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude"),
|
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude.exe"),
|
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude-code", "claude.exe"),
|
|
"/usr/local/bin/claude",
|
|
"/usr/bin/claude"
|
|
};
|
|
|
|
foreach (var path in possiblePaths)
|
|
{
|
|
if (File.Exists(path))
|
|
return path;
|
|
}
|
|
|
|
try
|
|
{
|
|
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
|
foreach (var dir in pathEnv.Split(Path.PathSeparator))
|
|
{
|
|
var exePath = Path.Combine(dir, "claude");
|
|
if (File.Exists(exePath)) return exePath;
|
|
|
|
exePath = Path.Combine(dir, "claude.exe");
|
|
if (File.Exists(exePath)) return exePath;
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streaming-Chat über die Claude CLI mit Event-basiertem Callback.
|
|
/// Unterstützt Text-Streaming und Tool-Use Events.
|
|
/// </summary>
|
|
public async Task<CliChatResult> ChatStreamingAsync(
|
|
string prompt,
|
|
Func<CliStreamEvent, Task> onEvent,
|
|
string modelId = null,
|
|
string systemPrompt = null,
|
|
string workspace = null,
|
|
bool hasWorkspace = false,
|
|
bool planMode = false,
|
|
bool webSearch = false,
|
|
string mcpConfigPath = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!_isAvailable)
|
|
return new CliChatResult { Success = false, Error = "Claude CLI nicht verfügbar" };
|
|
|
|
var args = new List<string>
|
|
{
|
|
"-p",
|
|
"--verbose",
|
|
"--output-format", "stream-json",
|
|
"--include-partial-messages",
|
|
"--no-session-persistence",
|
|
"--permission-mode", "acceptEdits",
|
|
"--disallowed-tools", "AskUserQuestion"
|
|
};
|
|
|
|
// Tools konfigurieren
|
|
var tools = new List<string>();
|
|
if (hasWorkspace && planMode)
|
|
{
|
|
// Plan-Modus: nur read-only Tools
|
|
tools.AddRange(new[] { "Read", "Glob", "Grep" });
|
|
}
|
|
else if (hasWorkspace)
|
|
{
|
|
// Normaler Modus mit Workspace: alle File-Tools
|
|
tools.AddRange(new[] { "Bash(*)", "Read", "Write", "Edit", "Glob", "Grep" });
|
|
}
|
|
else
|
|
{
|
|
// Kein Workspace: Read für Temp-Dateien (z.B. gesendete Bilder)
|
|
tools.Add("Read");
|
|
}
|
|
if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); }
|
|
// MCP-Tools erlauben (wenn MCP-Config vorhanden)
|
|
if (!string.IsNullOrEmpty(mcpConfigPath))
|
|
{
|
|
tools.Add("mcp__gott-remote__remote_execute");
|
|
tools.Add("mcp__gott-remote__ask_user_choice");
|
|
tools.Add("mcp__gott-remote__memory_read");
|
|
tools.Add("mcp__gott-remote__memory_write");
|
|
tools.Add("mcp__gott-remote__memory_list");
|
|
}
|
|
args.Add("--allowed-tools");
|
|
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
|
|
|
|
if (!string.IsNullOrEmpty(workspace))
|
|
{
|
|
args.Add("--add-dir");
|
|
args.Add(workspace);
|
|
}
|
|
|
|
// Temp-Ordner immer zugänglich machen (für gesendete Bilder etc.)
|
|
var tempImageDir = Path.Combine(Path.GetTempPath(), "gott-cli-images");
|
|
if (Directory.Exists(tempImageDir))
|
|
{
|
|
args.Add("--add-dir");
|
|
args.Add(tempImageDir);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(modelId))
|
|
{
|
|
args.Add("--model");
|
|
args.Add(modelId);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(systemPrompt))
|
|
{
|
|
args.Add("--system-prompt");
|
|
args.Add(systemPrompt);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(mcpConfigPath))
|
|
{
|
|
args.Add("--mcp-config");
|
|
args.Add(mcpConfigPath);
|
|
}
|
|
|
|
return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken);
|
|
}
|
|
|
|
private async Task<CliChatResult> RunCliStreamingAsync(
|
|
List<string> args,
|
|
string stdinPrompt,
|
|
Func<CliStreamEvent, Task> onEvent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = new CliChatResult();
|
|
var fullOutput = new StringBuilder();
|
|
var error = new StringBuilder();
|
|
|
|
// Tracking für content blocks
|
|
string currentBlockType = null; // "text" oder "tool_use"
|
|
string currentToolName = null;
|
|
string currentToolId = null;
|
|
var currentToolInput = new StringBuilder();
|
|
bool inTextBlock = false;
|
|
|
|
var lineBuffer = new StringBuilder();
|
|
|
|
try
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = _cliPath,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8
|
|
};
|
|
|
|
foreach (var arg in args)
|
|
startInfo.ArgumentList.Add(arg);
|
|
|
|
using var process = new Process { StartInfo = startInfo };
|
|
|
|
process.ErrorDataReceived += (s, e) =>
|
|
{
|
|
if (e.Data != null)
|
|
{
|
|
error.AppendLine(e.Data);
|
|
_logger.LogDebug("CLI stderr: {Line}", e.Data);
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.StandardInput.WriteAsync(stdinPrompt);
|
|
await process.StandardInput.FlushAsync();
|
|
process.StandardInput.Close();
|
|
|
|
using var reader = process.StandardOutput;
|
|
string line;
|
|
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
try { process.Kill(entireProcessTree: true); } catch { }
|
|
throw new OperationCanceledException();
|
|
}
|
|
|
|
if (!line.StartsWith("{"))
|
|
continue;
|
|
|
|
try
|
|
{
|
|
var json = JsonSerializer.Deserialize<JsonElement>(line);
|
|
var type = json.TryGetProperty("type", out var t) ? t.GetString() : null;
|
|
|
|
if (type == "stream_event" && json.TryGetProperty("event", out var evt))
|
|
{
|
|
var evtType = evt.TryGetProperty("type", out var et) ? et.GetString() : null;
|
|
|
|
switch (evtType)
|
|
{
|
|
case "content_block_start":
|
|
{
|
|
if (evt.TryGetProperty("content_block", out var cb))
|
|
{
|
|
var cbType = cb.TryGetProperty("type", out var cbt) ? cbt.GetString() : null;
|
|
|
|
if (cbType == "text")
|
|
{
|
|
currentBlockType = "text";
|
|
if (!inTextBlock)
|
|
{
|
|
inTextBlock = true;
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextStart });
|
|
}
|
|
}
|
|
else if (cbType == "tool_use")
|
|
{
|
|
// Text-Block vorher abschließen + lineBuffer flushen
|
|
if (inTextBlock)
|
|
{
|
|
// Restlichen Text im lineBuffer emittieren bevor Tool startet
|
|
if (lineBuffer.Length > 0)
|
|
{
|
|
var remaining = lineBuffer.ToString();
|
|
lineBuffer.Clear();
|
|
if (remaining.Trim().Length > 0)
|
|
{
|
|
fullOutput.Append(remaining);
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
|
}
|
|
}
|
|
inTextBlock = false;
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd });
|
|
}
|
|
|
|
currentBlockType = "tool_use";
|
|
currentToolName = cb.TryGetProperty("name", out var n) ? n.GetString() : "unknown";
|
|
currentToolId = cb.TryGetProperty("id", out var id) ? id.GetString() : "";
|
|
currentToolInput.Clear();
|
|
|
|
await onEvent(new CliStreamEvent
|
|
{
|
|
Type = CliEventType.ToolStart,
|
|
ToolName = currentToolName,
|
|
ToolId = currentToolId
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "content_block_delta":
|
|
{
|
|
if (evt.TryGetProperty("delta", out var delta))
|
|
{
|
|
var deltaType = delta.TryGetProperty("type", out var dt) ? dt.GetString() : null;
|
|
|
|
if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt))
|
|
{
|
|
var text = txt.GetString();
|
|
if (!string.IsNullOrEmpty(text))
|
|
{
|
|
for (int ci = 0; ci < text.Length; ci++)
|
|
{
|
|
var ch = text[ci];
|
|
if (ch == '\n')
|
|
{
|
|
var completedLine = lineBuffer.ToString().Trim();
|
|
lineBuffer.Clear();
|
|
|
|
if (completedLine.Length > 0 || fullOutput.Length > 0)
|
|
{
|
|
var lineText = completedLine + "\n";
|
|
fullOutput.Append(lineText);
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText });
|
|
}
|
|
}
|
|
else
|
|
{
|
|
lineBuffer.Append(ch);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (deltaType == "input_json_delta" && delta.TryGetProperty("partial_json", out var pj))
|
|
{
|
|
currentToolInput.Append(pj.GetString());
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "content_block_stop":
|
|
{
|
|
if (currentBlockType == "tool_use")
|
|
{
|
|
await onEvent(new CliStreamEvent
|
|
{
|
|
Type = CliEventType.ToolEnd,
|
|
ToolName = currentToolName,
|
|
ToolId = currentToolId,
|
|
ToolInput = currentToolInput.ToString()
|
|
});
|
|
currentBlockType = null;
|
|
currentToolName = null;
|
|
currentToolId = null;
|
|
currentToolInput.Clear();
|
|
}
|
|
else if (currentBlockType == "text")
|
|
{
|
|
// lineBuffer flushen (Text ohne trailing \n)
|
|
if (lineBuffer.Length > 0)
|
|
{
|
|
var remaining = lineBuffer.ToString();
|
|
lineBuffer.Clear();
|
|
if (remaining.Trim().Length > 0)
|
|
{
|
|
fullOutput.Append(remaining);
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
|
}
|
|
}
|
|
currentBlockType = null;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else if (type == "result")
|
|
{
|
|
// Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile)
|
|
if (lineBuffer.Length > 0)
|
|
{
|
|
var remaining = lineBuffer.ToString().Trim();
|
|
lineBuffer.Clear();
|
|
|
|
if (remaining.Length > 0)
|
|
{
|
|
fullOutput.Append(remaining);
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
|
}
|
|
}
|
|
|
|
// Offenen Text-Block abschließen
|
|
if (inTextBlock)
|
|
{
|
|
inTextBlock = false;
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd });
|
|
}
|
|
|
|
if (json.TryGetProperty("usage", out var usage))
|
|
{
|
|
result.InputTokens = usage.TryGetProperty("input_tokens", out var it) ? it.GetInt64() : 0;
|
|
result.OutputTokens = usage.TryGetProperty("output_tokens", out var ot) ? ot.GetInt64() : 0;
|
|
result.CacheReadTokens = usage.TryGetProperty("cache_read_input_tokens", out var cr) ? cr.GetInt64() : 0;
|
|
result.CacheCreationTokens = usage.TryGetProperty("cache_creation_input_tokens", out var cc) ? cc.GetInt64() : 0;
|
|
}
|
|
if (json.TryGetProperty("total_cost_usd", out var cost))
|
|
result.CostUsd = cost.GetDouble();
|
|
if (json.TryGetProperty("duration_api_ms", out var dur))
|
|
result.DurationApiMs = dur.GetInt64();
|
|
if (json.TryGetProperty("session_id", out var sid))
|
|
result.SessionId = sid.GetString();
|
|
if (json.TryGetProperty("num_turns", out var nt))
|
|
result.NumTurns = nt.GetInt32();
|
|
|
|
var isError = json.TryGetProperty("is_error", out var ie) && ie.GetBoolean();
|
|
if (isError)
|
|
result.Error = json.TryGetProperty("result", out var errMsg) ? errMsg.GetString() : "CLI error";
|
|
}
|
|
else if (type == "user" && json.TryGetProperty("message", out var userMsg))
|
|
{
|
|
// CLI sendet tool_result als type:"user" message
|
|
if (userMsg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in content.EnumerateArray())
|
|
{
|
|
var itemType = item.TryGetProperty("type", out var iType) ? iType.GetString() : null;
|
|
if (itemType == "tool_result")
|
|
{
|
|
var toolUseId = item.TryGetProperty("tool_use_id", out var tuId) ? tuId.GetString() : "";
|
|
var resultIsError = item.TryGetProperty("is_error", out var isErr) && isErr.GetBoolean();
|
|
|
|
// Content kann string oder array sein
|
|
var resultContent = "";
|
|
if (item.TryGetProperty("content", out var rc))
|
|
{
|
|
if (rc.ValueKind == JsonValueKind.String)
|
|
resultContent = rc.GetString();
|
|
else if (rc.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var part in rc.EnumerateArray())
|
|
{
|
|
if (part.TryGetProperty("text", out var partText))
|
|
sb.Append(partText.GetString());
|
|
}
|
|
resultContent = sb.ToString();
|
|
}
|
|
}
|
|
|
|
await onEvent(new CliStreamEvent
|
|
{
|
|
Type = CliEventType.ToolResult,
|
|
ToolId = toolUseId,
|
|
ToolResultContent = resultContent,
|
|
ToolResultIsError = resultIsError
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogDebug("CLI JSON parse error: {Error} for line: {Line}", ex.Message, line.Length > 200 ? line[..200] : line);
|
|
}
|
|
}
|
|
|
|
// Restlichen lineBuffer flushen
|
|
if (lineBuffer.Length > 0)
|
|
{
|
|
var remaining = lineBuffer.ToString().Trim();
|
|
lineBuffer.Clear();
|
|
|
|
if (remaining.Length > 0)
|
|
{
|
|
fullOutput.Append(remaining);
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
|
}
|
|
}
|
|
|
|
// Sicherstellen, dass offene Blöcke geschlossen werden
|
|
if (currentBlockType == "tool_use" && currentToolName != null)
|
|
{
|
|
await onEvent(new CliStreamEvent
|
|
{
|
|
Type = CliEventType.ToolEnd,
|
|
ToolName = currentToolName,
|
|
ToolId = currentToolId,
|
|
ToolInput = currentToolInput.ToString()
|
|
});
|
|
currentBlockType = null;
|
|
}
|
|
if (inTextBlock)
|
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd });
|
|
|
|
await process.WaitForExitAsync(cancellationToken);
|
|
|
|
result.ExitCode = process.ExitCode;
|
|
result.Output = fullOutput.ToString().Trim();
|
|
result.Success = process.ExitCode == 0 && fullOutput.Length > 0;
|
|
|
|
if (!result.Success && string.IsNullOrEmpty(result.Error))
|
|
result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}";
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
result.Success = false;
|
|
result.Error = "Abgebrochen";
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Success = false;
|
|
result.Error = ex.Message;
|
|
_logger.LogError(ex, "Fehler beim Streaming der Claude CLI");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prüft ob die CLI funktioniert und authentifiziert ist.
|
|
/// </summary>
|
|
public async Task<CliStatusResult> GetStatusAsync()
|
|
{
|
|
var result = new CliStatusResult { IsAvailable = _isAvailable };
|
|
|
|
if (!_isAvailable)
|
|
{
|
|
result.Error = "Claude CLI nicht gefunden";
|
|
return result;
|
|
}
|
|
|
|
try
|
|
{
|
|
var versionResult = await RunSimpleAsync(["--version"], CancellationToken.None);
|
|
if (versionResult.Success)
|
|
result.Version = versionResult.Output.Trim();
|
|
|
|
var doctorResult = await RunSimpleAsync(["doctor"], CancellationToken.None);
|
|
result.IsAuthenticated = doctorResult.Success;
|
|
|
|
if (!doctorResult.Success)
|
|
result.Error = doctorResult.Error ?? doctorResult.Output;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Error = ex.Message;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task<CliChatResult> RunSimpleAsync(List<string> args, CancellationToken cancellationToken)
|
|
{
|
|
var result = new CliChatResult();
|
|
var output = new StringBuilder();
|
|
var error = new StringBuilder();
|
|
|
|
try
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = _cliPath,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8
|
|
};
|
|
|
|
foreach (var arg in args)
|
|
startInfo.ArgumentList.Add(arg);
|
|
|
|
using var process = new Process { StartInfo = startInfo };
|
|
process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); };
|
|
process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); };
|
|
|
|
process.Start();
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.WaitForExitAsync(cancellationToken);
|
|
|
|
result.ExitCode = process.ExitCode;
|
|
result.Output = output.ToString().Trim();
|
|
result.Success = process.ExitCode == 0;
|
|
|
|
if (!result.Success)
|
|
result.Error = error.Length > 0 ? error.ToString().Trim() : $"Exit code: {process.ExitCode}";
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
result.Success = false;
|
|
result.Error = "Abgebrochen";
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Success = false;
|
|
result.Error = ex.Message;
|
|
_logger.LogError(ex, "Fehler beim Ausführen der Claude CLI");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
public enum CliEventType
|
|
{
|
|
TextStart,
|
|
TextDelta,
|
|
TextEnd,
|
|
ToolStart,
|
|
ToolEnd,
|
|
ToolResult
|
|
}
|
|
|
|
public class CliStreamEvent
|
|
{
|
|
public CliEventType Type { get; set; }
|
|
public string Text { get; set; }
|
|
public string ToolName { get; set; }
|
|
public string ToolId { get; set; }
|
|
public string ToolInput { get; set; }
|
|
public string ToolResultContent { get; set; }
|
|
public bool ToolResultIsError { get; set; }
|
|
}
|
|
|
|
public class CliChatResult
|
|
{
|
|
public bool Success { get; set; }
|
|
public string Output { get; set; }
|
|
public string Error { get; set; }
|
|
public int ExitCode { get; set; }
|
|
public long InputTokens { get; set; }
|
|
public long OutputTokens { get; set; }
|
|
public long CacheReadTokens { get; set; }
|
|
public long CacheCreationTokens { get; set; }
|
|
public double CostUsd { get; set; }
|
|
public long DurationApiMs { get; set; }
|
|
public string SessionId { get; set; }
|
|
public int NumTurns { get; set; }
|
|
}
|
|
|
|
public class CliStatusResult
|
|
{
|
|
public bool IsAvailable { get; set; }
|
|
public bool IsAuthenticated { get; set; }
|
|
public string Version { get; set; }
|
|
public string Error { get; set; }
|
|
}
|