1233 lines
53 KiB
C#
1233 lines
53 KiB
C#
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using System.Globalization;
|
||
using YMhut.Box.Core.Api;
|
||
|
||
namespace YMhut.Box.Core.Tools;
|
||
|
||
public static class ToolResultBuilder
|
||
{
|
||
public static ToolResultDocument FromOutput(IToolModule module, string output, string language = "zh-CN")
|
||
{
|
||
var spec = ToolPageSpecCatalog.For(module);
|
||
var lines = Lines(output);
|
||
var blocks = BuildBlocks(module, spec, output, lines, language);
|
||
if (blocks.Count == 0)
|
||
{
|
||
blocks = ToolResultDocument.FromOutput(module, output).Blocks;
|
||
}
|
||
|
||
return Document(module, spec, output, blocks, language);
|
||
}
|
||
|
||
public static ToolResultDocument FromRemote(ApiEndpoint endpoint, ApiResponse response, string output, string language = "zh-CN")
|
||
{
|
||
var lines = Lines(output);
|
||
var displayLines = RemoveSourceHeader(lines);
|
||
var blocks = new List<ToolResultBlock>();
|
||
blocks.AddRange(BuildRemoteBlocks(endpoint.Id, displayLines, language));
|
||
if (blocks.Count == 0)
|
||
{
|
||
blocks.AddRange(GenericTextBlocks(displayLines.Count > 0 ? displayLines : lines, language));
|
||
}
|
||
|
||
blocks.Add(ToolResultBlock.KeyValue(
|
||
T(language, "来源与隐私", "Source and privacy"),
|
||
[
|
||
Pair(T(language, "数据源", "Data source"), endpoint.SourceName),
|
||
Pair(T(language, "来源类型", "Source type"), SourceTypeLabel(endpoint, language)),
|
||
Pair(T(language, "获取时间", "Fetched at"), response.FetchedAt.ToString("yyyy-MM-dd HH:mm:ss zzz"))
|
||
],
|
||
SourceMetadata(endpoint)));
|
||
|
||
return new ToolResultDocument(
|
||
endpoint.Id,
|
||
GuessRemoteResultKind(endpoint.Id),
|
||
output,
|
||
blocks,
|
||
RemoteMetadata(endpoint, GuessRemoteResultKind(endpoint.Id), language),
|
||
output,
|
||
endpoint.SourceName,
|
||
response.Success ? "ok" : "error");
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildBlocks(IToolModule module, ToolPageSpec spec, string output, IReadOnlyList<string> lines, string language)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(output))
|
||
{
|
||
return [ToolResultBlock.List(ToolResultBlockKind.Status, T(language, "空结果", "Empty result"), [new("info", T(language, "暂无可展示的结果。", "There is no result to display."), string.Empty, "info", string.Empty)])];
|
||
}
|
||
|
||
if (module.Id is "qr_generator" && LooksLikeSvg(output))
|
||
{
|
||
return
|
||
[
|
||
ToolResultBlock.Media(
|
||
ToolResultBlockKind.Image,
|
||
T(language, "二维码预览", "QR code preview"),
|
||
output,
|
||
string.Empty,
|
||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["contentType"] = "image/svg+xml",
|
||
["displayMode"] = "preview",
|
||
["rawSvg"] = output
|
||
})
|
||
];
|
||
}
|
||
|
||
if (module.Id is "weather")
|
||
{
|
||
return [ToolResultBlock.Metric(T(language, "天气摘要", "Weather summary"), KeyValues(lines).ToArray())];
|
||
}
|
||
|
||
if (module.Id is "train_query")
|
||
{
|
||
return BuildTrainBlocks(lines, language);
|
||
}
|
||
|
||
if (module.Id is "dns_query")
|
||
{
|
||
return BuildDnsBlocks(lines, language);
|
||
}
|
||
|
||
if (module.Id is "ip_lookup" or "ip_info" or "rdap_ip_lookup" or "rdap_domain_lookup" or "domain_price")
|
||
{
|
||
return BuildRdapBlocks(lines, language);
|
||
}
|
||
|
||
if (module.Id is "sanguosha_skin")
|
||
{
|
||
return BuildSanguoshaSkinBlocks(lines, language);
|
||
}
|
||
|
||
if (IsReferenceTool(module.Id))
|
||
{
|
||
return BuildReferenceBlocksV2(lines, language);
|
||
}
|
||
|
||
if (module.Id is "smart_search")
|
||
{
|
||
return BuildSearchBlocks(lines, language);
|
||
}
|
||
|
||
if (module.Id is "system_tool" or "system_info" or "pc_benchmark")
|
||
{
|
||
return BuildSystemBlocks(lines, language);
|
||
}
|
||
|
||
if (spec.Result == ToolResultKind.JsonTree || LooksLikeJson(output))
|
||
{
|
||
return BuildJsonBlocks(output, language);
|
||
}
|
||
|
||
if (module.Id is "jwt_decoder")
|
||
{
|
||
return BuildJwtBlocks(output, language);
|
||
}
|
||
|
||
if (spec.Result == ToolResultKind.Diff || module.Id is "text_diff")
|
||
{
|
||
return [ToolResultBlock.List(ToolResultBlockKind.Diff, T(language, "差异结果", "Diff result"), lines.Select(line => new ToolResultListItem(DiffLeading(line), line, string.Empty, DiffStatus(line), string.Empty)).ToArray())];
|
||
}
|
||
|
||
if (module.Id is "base64_codec" or "url_codec" or "html_entity" or "unicode_codec" or "punycode_codec")
|
||
{
|
||
return BuildCodecBlocks(lines, language);
|
||
}
|
||
|
||
if (module.Id is "hash_generator" or "hmac_generator")
|
||
{
|
||
return [ToolResultBlock.Table(T(language, "摘要值", "Digest values"), DigestRows(lines, language))];
|
||
}
|
||
|
||
if (spec.Result == ToolResultKind.CodePreview || module.Id.Contains("formatter", StringComparison.OrdinalIgnoreCase) || module.Id.Contains("minifier", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return [ToolResultBlock.Code(T(language, "代码预览", "Code preview"), output, CodeLanguage(module.Id))];
|
||
}
|
||
|
||
if (spec.Result == ToolResultKind.CalculatorTable || module.Metadata.Category == ToolCategory.Calculator || spec.Layout is ToolLayoutKind.CalculatorForm or ToolLayoutKind.UnitConverter)
|
||
{
|
||
return [ToolResultBlock.Metric(T(language, "计算结果", "Calculation result"), MetricPairs(lines).ToArray())];
|
||
}
|
||
|
||
if (spec.Result is ToolResultKind.FileCards or ToolResultKind.ImagePreview or ToolResultKind.Media or ToolResultKind.ColorSwatch)
|
||
{
|
||
return ToolResultDocument.FromOutput(module, output).Blocks;
|
||
}
|
||
|
||
if (spec.Result == ToolResultKind.Table)
|
||
{
|
||
var rows = ParseTableRows(lines);
|
||
return rows.Count == 0 ? GenericTextBlocks(lines, language) : [ToolResultBlock.Table(T(language, "表格结果", "Table result"), rows)];
|
||
}
|
||
|
||
if (spec.Result is ToolResultKind.RankedList or ToolResultKind.NewsCards)
|
||
{
|
||
return spec.Result == ToolResultKind.NewsCards
|
||
? BuildNewsCards(lines, T(language, "新闻列表", "News list"))
|
||
: BuildRankedCards(lines, T(language, "榜单", "Ranked list"));
|
||
}
|
||
|
||
if (spec.Result == ToolResultKind.StatusList)
|
||
{
|
||
return [ToolResultBlock.List(ToolResultBlockKind.Status, T(language, "状态", "Status"), lines.Select(line => new ToolResultListItem(StatusLeading(line), line, string.Empty, StatusOf(line), string.Empty)).ToArray())];
|
||
}
|
||
|
||
var keyValues = KeyValues(lines).ToArray();
|
||
if (keyValues.Length >= 2)
|
||
{
|
||
return [ToolResultBlock.KeyValue(T(language, "详情", "Details"), keyValues)];
|
||
}
|
||
|
||
return GenericTextBlocks(lines, language);
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildRemoteBlocks(string id, IReadOnlyList<string> lines, string language)
|
||
{
|
||
if (id.Equals("weather", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return BuildWeatherBlocks(lines, language);
|
||
}
|
||
|
||
return id switch
|
||
{
|
||
"dns_query" => BuildDnsBlocks(lines, language),
|
||
"weather" => [ToolResultBlock.Metric(T(language, "天气摘要", "Weather summary"), KeyValues(lines).ToArray())],
|
||
"ip_info" or "ip_lookup" or "rdap_ip_lookup" or "rdap_domain_lookup" or "domain_price" => BuildRdapBlocks(lines, language),
|
||
"baidu_hot" or "hotboard" or "bili_hot" or "zhihu_hot" or "earthquake_info" or "movie_box_office" => BuildRankedCards(lines, T(language, "数据列表", "Data list")),
|
||
"history_today" => BuildHistoryBlocks(lines, language),
|
||
"ai_latest_news" or "tech_news" or "football_news" or "cctv_news" => BuildNewsCards(lines, T(language, "新闻列表", "News list")),
|
||
"gold_price" => BuildGoldPriceBlocks(lines, language),
|
||
"oil_price" or "wx_domain_check" or "http_diagnostic" => BuildHtmlSummaryBlocks(lines, language),
|
||
"city_route_query" => BuildMetricTextBlocks(lines, T(language, "城际路线", "City route"), language),
|
||
"train_query" => BuildTrainBlocks(lines, language),
|
||
_ => []
|
||
};
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildHistoryBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var blocks = new List<ToolResultBlock>();
|
||
var currentSection = string.Empty;
|
||
var items = new List<ToolResultListItem>();
|
||
|
||
void Flush()
|
||
{
|
||
if (items.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.Timeline(
|
||
string.IsNullOrWhiteSpace(currentSection) ? T(language, "历史事件", "History") : currentSection,
|
||
items.ToArray(),
|
||
BlockMeta("text/plain", "timeline", "normal")));
|
||
items.Clear();
|
||
}
|
||
}
|
||
|
||
foreach (var line in lines)
|
||
{
|
||
var trimmed = line.Trim();
|
||
if (IsRemoteHeaderOrPrivacyLine(trimmed))
|
||
{
|
||
continue;
|
||
}
|
||
if (string.IsNullOrWhiteSpace(trimmed))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!trimmed.StartsWith("-", StringComparison.Ordinal) && trimmed == trimmed.ToUpperInvariant() && trimmed.Length <= 24)
|
||
{
|
||
Flush();
|
||
currentSection = trimmed;
|
||
continue;
|
||
}
|
||
|
||
var match = Regex.Match(trimmed, @"^-?\s*(?<year>-?\d{1,4})\s*:\s*(?<text>.+)$");
|
||
if (match.Success)
|
||
{
|
||
items.Add(new ToolResultListItem(match.Groups["year"].Value, match.Groups["text"].Value, currentSection, "info", ExtractLinks([line]).FirstOrDefault() ?? string.Empty));
|
||
}
|
||
else
|
||
{
|
||
items.Add(new ToolResultListItem("•", trimmed.TrimStart('-', ' '), currentSection, "info", ExtractLinks([line]).FirstOrDefault() ?? string.Empty));
|
||
}
|
||
}
|
||
|
||
Flush();
|
||
return blocks.Count == 0 ? GenericTextBlocks(lines, language) : blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildMetricTextBlocks(IReadOnlyList<string> lines, string title, string language)
|
||
{
|
||
var pairs = KeyValues(lines).ToArray();
|
||
if (pairs.Length > 0)
|
||
{
|
||
return [ToolResultBlock.Metric(title, pairs, BlockMeta("metric", "summary", "high"))];
|
||
}
|
||
|
||
return BuildHtmlSummaryBlocks(lines, language);
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildGoldPriceBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var blocks = new List<ToolResultBlock>();
|
||
var pairs = KeyValues(lines).ToArray();
|
||
if (pairs.Length > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.Metric(T(language, "黄金价格", "Gold price"), pairs, BlockMeta("metric", "summary", "high")));
|
||
}
|
||
|
||
var rows = ParseTableRows(lines);
|
||
if (rows.Count > 1)
|
||
{
|
||
blocks.Add(ToolResultBlock.Table(T(language, "近期定盘价", "Recent fixes"), rows, BlockMeta("table", "details", "normal")));
|
||
|
||
var chartRows = rows
|
||
.Skip(1)
|
||
.Where(row => row.Length >= 2 && TryParseDecimal(row[1], out _))
|
||
.Select(row => new[] { row[0], row[1] })
|
||
.Take(60)
|
||
.ToArray();
|
||
if (chartRows.Length >= 2)
|
||
{
|
||
blocks.Add(ToolResultBlock.LineChart(
|
||
T(language, "USD/oz 趋势", "USD/oz trend"),
|
||
new[] { new[] { T(language, "日期", "Date"), "USD/oz" } }.Concat(chartRows).ToArray(),
|
||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["contentType"] = "chart/line",
|
||
["displayMode"] = "chart",
|
||
["priority"] = "high",
|
||
["chartUnit"] = "USD/oz"
|
||
}));
|
||
}
|
||
}
|
||
|
||
return blocks.Count == 0
|
||
? BuildMetricTextBlocks(lines, T(language, "黄金价格", "Gold price"), language)
|
||
: blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildHtmlSummaryBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var blocks = new List<ToolResultBlock>();
|
||
var title = lines.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line) && !Regex.IsMatch(line, @"^\d+[\.\)]")) ?? string.Empty;
|
||
if (!string.IsNullOrWhiteSpace(title))
|
||
{
|
||
blocks.Add(ToolResultBlock.Metric(
|
||
T(language, "查询摘要", "Query summary"),
|
||
[Pair(T(language, "标题", "Title"), title)],
|
||
BlockMeta("text/plain", "summary", "high")));
|
||
}
|
||
|
||
var cards = BuildRankedCards(lines.Where(line => Regex.IsMatch(line.Trim(), @"^\d+[\.\)]")).ToArray(), T(language, "详情", "Details"));
|
||
blocks.AddRange(cards);
|
||
|
||
if (blocks.Count == 0)
|
||
{
|
||
blocks.AddRange(GenericTextBlocks(lines, language));
|
||
}
|
||
|
||
return blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildJsonBlocks(string output, string language)
|
||
{
|
||
try
|
||
{
|
||
using var json = JsonDocument.Parse(output);
|
||
var root = json.RootElement;
|
||
var blocks = new List<ToolResultBlock>
|
||
{
|
||
ToolResultBlock.JsonTree(T(language, "JSON 树", "JSON tree"), JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true }))
|
||
};
|
||
|
||
if (root.ValueKind == JsonValueKind.Object)
|
||
{
|
||
blocks.Insert(0, ToolResultBlock.KeyValue(
|
||
T(language, "字段摘要", "Field summary"),
|
||
root.EnumerateObject().Take(40).Select(property => Pair(property.Name, JsonPreview(property.Value))).ToArray()));
|
||
}
|
||
else if (root.ValueKind == JsonValueKind.Array)
|
||
{
|
||
blocks.Insert(0, ToolResultBlock.Metric(T(language, "数组摘要", "Array summary"), [Pair(T(language, "项目数", "Items"), root.GetArrayLength().ToString())]));
|
||
var rows = JsonArrayRows(root).ToArray();
|
||
if (rows.Length > 0)
|
||
{
|
||
blocks.Insert(1, ToolResultBlock.Table(T(language, "数组预览", "Array preview"), rows));
|
||
}
|
||
}
|
||
|
||
return blocks;
|
||
}
|
||
catch (JsonException exception)
|
||
{
|
||
return
|
||
[
|
||
ToolResultBlock.List(ToolResultBlockKind.Status, T(language, "JSON 解析失败", "JSON parse failed"), [new("error", exception.Message, string.Empty, "error", string.Empty)]),
|
||
ToolResultBlock.Raw(T(language, "原始内容", "Raw content"), output, "json")
|
||
];
|
||
}
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildJwtBlocks(string output, string language)
|
||
{
|
||
var blocks = new List<ToolResultBlock>();
|
||
var sections = output.Replace("\r\n", "\n")
|
||
.Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||
.Where(section => section.TrimStart().StartsWith('{') || section.TrimStart().StartsWith('['))
|
||
.Take(3)
|
||
.ToArray();
|
||
foreach (var section in sections)
|
||
{
|
||
blocks.AddRange(BuildJsonBlocks(section, language));
|
||
}
|
||
|
||
return blocks.Count == 0 ? [ToolResultBlock.Code("JWT", output, "json")] : blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildDnsBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var records = new List<string[]>
|
||
{
|
||
new[] { T(language, "名称", "Name"), "TYPE", "TTL", T(language, "数据", "Data") }
|
||
};
|
||
var details = new List<KeyValuePair<string, string>>();
|
||
foreach (var line in lines)
|
||
{
|
||
var match = Regex.Match(line, @"^(?<name>.*?)\s+TYPE\s+(?<type>\S+)\s+TTL\s+(?<ttl>\S+)\s+(?<data>.*)$", RegexOptions.IgnoreCase);
|
||
if (match.Success)
|
||
{
|
||
records.Add([match.Groups["name"].Value, match.Groups["type"].Value, match.Groups["ttl"].Value, match.Groups["data"].Value]);
|
||
continue;
|
||
}
|
||
|
||
details.AddRange(KeyValues([line]));
|
||
}
|
||
|
||
var blocks = new List<ToolResultBlock>();
|
||
if (details.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.KeyValue(T(language, "查询摘要", "Query summary"), details));
|
||
}
|
||
if (records.Count > 1)
|
||
{
|
||
blocks.Add(ToolResultBlock.Table("DNS", records));
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildWeatherBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var pairs = KeyValues(lines).ToList();
|
||
var blocks = new List<ToolResultBlock>();
|
||
if (pairs.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.Metric(
|
||
T(language, "天气摘要", "Weather summary"),
|
||
pairs.Take(8).ToArray(),
|
||
BlockMeta("metric", "summary", "high")));
|
||
}
|
||
|
||
var forecastRows = new List<string[]> { new[] { T(language, "日期", "Date"), T(language, "最低", "Low"), T(language, "最高", "High"), T(language, "说明", "Note") } };
|
||
foreach (var line in lines)
|
||
{
|
||
var match = Regex.Match(line, @"^(?<date>\d{4}-\d{2}-\d{2})\s*:\s*(?<low>-?\d+(?:\.\d+)?)\s*-\s*(?<high>-?\d+(?:\.\d+)?)(?<unit>.*)$");
|
||
if (match.Success)
|
||
{
|
||
forecastRows.Add([
|
||
match.Groups["date"].Value,
|
||
match.Groups["low"].Value,
|
||
match.Groups["high"].Value,
|
||
match.Groups["unit"].Value.Trim()
|
||
]);
|
||
}
|
||
}
|
||
|
||
if (forecastRows.Count > 1)
|
||
{
|
||
blocks.Add(ToolResultBlock.Table(
|
||
T(language, "未来预报", "Forecast"),
|
||
forecastRows,
|
||
BlockMeta("table", "details", "normal")));
|
||
}
|
||
|
||
return blocks.Count > 0 ? blocks : GenericTextBlocks(lines, language);
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildRdapBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var events = new List<ToolResultListItem>();
|
||
var pairs = new List<KeyValuePair<string, string>>();
|
||
foreach (var pair in KeyValues(lines))
|
||
{
|
||
if (pair.Key.Contains("event", StringComparison.OrdinalIgnoreCase) || pair.Key.Contains("Notice", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
events.Add(new ToolResultListItem("•", pair.Key, pair.Value, "info", string.Empty));
|
||
}
|
||
else
|
||
{
|
||
pairs.Add(pair);
|
||
}
|
||
}
|
||
|
||
var blocks = new List<ToolResultBlock>();
|
||
if (pairs.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.KeyValue("RDAP", pairs));
|
||
}
|
||
if (events.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.Timeline(T(language, "事件/公告", "Events / notices"), events));
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildReferenceBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var rows = new List<string[]> { new[] { T(language, "代码", "Code"), T(language, "名称", "Name"), T(language, "说明", "Description"), T(language, "备注", "Note") } };
|
||
var sourcePairs = new List<KeyValuePair<string, string>>();
|
||
foreach (var line in lines)
|
||
{
|
||
if (line.StartsWith("数据源:", StringComparison.Ordinal) || line.StartsWith("Data source:", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
sourcePairs.Add(Pair(T(language, "数据源", "Data source"), SplitValue(line)));
|
||
continue;
|
||
}
|
||
|
||
var match = Regex.Match(line, @"^(?<code>.*?)\s+-\s+(?<name>.*?)(?:[::]\s*(?<desc>.*?))?(?:\s+/\s+(?<note>.*))?$");
|
||
if (match.Success)
|
||
{
|
||
rows.Add([match.Groups["code"].Value, match.Groups["name"].Value, match.Groups["desc"].Value, match.Groups["note"].Value]);
|
||
}
|
||
}
|
||
|
||
var blocks = new List<ToolResultBlock>();
|
||
if (sourcePairs.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.KeyValue(T(language, "来源", "Source"), sourcePairs));
|
||
}
|
||
if (rows.Count > 1)
|
||
{
|
||
blocks.Add(ToolResultBlock.Table(T(language, "参考数据", "Reference data"), rows));
|
||
}
|
||
return blocks.Count == 0 ? GenericTextBlocks(lines, language) : blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildSearchBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var pairs = KeyValues(lines).ToList();
|
||
var links = ExtractLinks(lines).Distinct(StringComparer.OrdinalIgnoreCase).Select(link => ToolResultBlock.Link(T(language, "打开入口", "Open entry"), link, link)).ToList();
|
||
var blocks = new List<ToolResultBlock>();
|
||
if (pairs.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.KeyValue(T(language, "搜索信息", "Search info"), pairs));
|
||
}
|
||
blocks.AddRange(links);
|
||
return blocks.Count == 0 ? GenericTextBlocks(lines, language) : blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildReferenceBlocksV2(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var rows = new List<string[]> { new[] { T(language, "代码", "Code"), T(language, "名称", "Name"), T(language, "说明", "Description"), T(language, "备注", "Note") } };
|
||
var sourcePairs = new List<KeyValuePair<string, string>>();
|
||
foreach (var line in lines)
|
||
{
|
||
if (IsSourceLine(line))
|
||
{
|
||
sourcePairs.Add(Pair(T(language, "数据源", "Data source"), SplitValue(line)));
|
||
continue;
|
||
}
|
||
|
||
var match = Regex.Match(line, @"^(?<code>.*?)\s+-\s+(?<name>.*?)(?:[::]\s*(?<desc>.*?))?(?:\s+/\s+(?<note>.*))?$");
|
||
if (match.Success)
|
||
{
|
||
rows.Add([match.Groups["code"].Value, match.Groups["name"].Value, match.Groups["desc"].Value, match.Groups["note"].Value]);
|
||
}
|
||
}
|
||
|
||
var blocks = new List<ToolResultBlock>();
|
||
if (rows.Count > 1)
|
||
{
|
||
blocks.Add(ToolResultBlock.Metric(
|
||
T(language, "参考摘要", "Reference summary"),
|
||
[
|
||
Pair(T(language, "命中数", "Matches"), (rows.Count - 1).ToString()),
|
||
Pair(T(language, "展示上限", "Display limit"), "80")
|
||
],
|
||
BlockMeta("metric", "summary", "high")));
|
||
blocks.Add(ToolResultBlock.Table(T(language, "参考数据", "Reference data"), rows, BlockMeta("table", "details", "normal")));
|
||
}
|
||
if (sourcePairs.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.KeyValue(T(language, "来源", "Source"), sourcePairs, BlockMeta("text/plain", "source", "normal")));
|
||
}
|
||
|
||
return blocks.Count == 0 ? GenericTextBlocks(lines, language) : blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildSanguoshaSkinBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var rows = new List<string[]>
|
||
{
|
||
new[]
|
||
{
|
||
T(language, "皮肤 ID", "Skin ID"),
|
||
T(language, "条目", "Item"),
|
||
T(language, "武将", "General"),
|
||
T(language, "皮肤", "Skin"),
|
||
T(language, "性别/品质", "Gender / quality")
|
||
}
|
||
};
|
||
var sourcePairs = new List<KeyValuePair<string, string>>();
|
||
var statuses = new List<ToolResultListItem>();
|
||
|
||
foreach (var line in lines)
|
||
{
|
||
if (IsSourceLine(line) || line.Contains("data/sanguosha", StringComparison.OrdinalIgnoreCase) || line.Contains("Assets/data/sanguosha", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
sourcePairs.Add(Pair(T(language, "配置来源", "Config source"), SplitValue(line)));
|
||
continue;
|
||
}
|
||
|
||
if (line.Contains("未找到", StringComparison.OrdinalIgnoreCase) ||
|
||
line.Contains("缺", StringComparison.OrdinalIgnoreCase) ||
|
||
line.Contains("修复", StringComparison.OrdinalIgnoreCase) ||
|
||
line.Contains("not found", StringComparison.OrdinalIgnoreCase) ||
|
||
line.Contains("missing", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
statuses.Add(new ToolResultListItem("!", line, string.Empty, "error", string.Empty));
|
||
continue;
|
||
}
|
||
|
||
var parts = line.Split(" / ", StringSplitOptions.TrimEntries);
|
||
if (parts.Length >= 4)
|
||
{
|
||
var id = parts.ElementAtOrDefault(0) ?? string.Empty;
|
||
var item = parts.ElementAtOrDefault(1) ?? string.Empty;
|
||
var general = StripLabel(parts.ElementAtOrDefault(2) ?? string.Empty);
|
||
var skin = StripLabel(parts.ElementAtOrDefault(3) ?? string.Empty);
|
||
var gender = parts.Length > 4 ? parts[4] : string.Empty;
|
||
rows.Add([id, item, general, skin, gender]);
|
||
continue;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(line))
|
||
{
|
||
statuses.Add(new ToolResultListItem("i", line, string.Empty, StatusOf(line), string.Empty));
|
||
}
|
||
}
|
||
|
||
var blocks = new List<ToolResultBlock>();
|
||
if (rows.Count > 1)
|
||
{
|
||
blocks.Add(ToolResultBlock.Metric(
|
||
T(language, "三国杀皮肤摘要", "Sanguosha skin summary"),
|
||
[
|
||
Pair(T(language, "命中皮肤", "Matches"), (rows.Count - 1).ToString()),
|
||
Pair(T(language, "资源目录", "Resource folder"), "Assets/data/sanguosha"),
|
||
Pair(T(language, "配置文件", "Config file"), "skin_config.json")
|
||
],
|
||
BlockMeta("metric", "summary", "high")));
|
||
blocks.Add(ToolResultBlock.Table(
|
||
T(language, "皮肤配置", "Skin configuration"),
|
||
rows,
|
||
BlockMeta("table", "sanguosha-skins", "high")));
|
||
}
|
||
|
||
if (sourcePairs.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.KeyValue(
|
||
T(language, "资源与路径", "Resources and paths"),
|
||
sourcePairs,
|
||
BlockMeta("text/plain", "source", "normal")));
|
||
}
|
||
|
||
if (statuses.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.List(
|
||
ToolResultBlockKind.Status,
|
||
T(language, "检查结果", "Check result"),
|
||
statuses,
|
||
BlockMeta("text/plain", "status", rows.Count > 1 ? "normal" : "high")));
|
||
}
|
||
|
||
return blocks.Count == 0 ? GenericTextBlocks(lines, language) : blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildCodecBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var blocks = new List<ToolResultBlock>();
|
||
var sections = new List<KeyValuePair<string, string>>();
|
||
var currentTitle = string.Empty;
|
||
var currentText = new List<string>();
|
||
|
||
void Flush()
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(currentTitle))
|
||
{
|
||
sections.Add(Pair(currentTitle.TrimEnd(':'), string.Join(Environment.NewLine, currentText).Trim()));
|
||
}
|
||
currentText.Clear();
|
||
}
|
||
|
||
foreach (var line in lines)
|
||
{
|
||
if (line.EndsWith(":", StringComparison.Ordinal) && line.Length < 32)
|
||
{
|
||
Flush();
|
||
currentTitle = line;
|
||
}
|
||
else
|
||
{
|
||
currentText.Add(line);
|
||
}
|
||
}
|
||
Flush();
|
||
|
||
if (sections.Count > 0)
|
||
{
|
||
blocks.Add(ToolResultBlock.KeyValue(T(language, "转换结果", "Conversion result"), sections));
|
||
}
|
||
else
|
||
{
|
||
blocks.Add(ToolResultBlock.Code(T(language, "转换文本", "Converted text"), string.Join(Environment.NewLine, lines)));
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
private static IReadOnlyList<string[]> DigestRows(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var rows = new List<string[]> { new[] { T(language, "算法", "Algorithm"), T(language, "摘要", "Digest") } };
|
||
foreach (var pair in KeyValues(lines))
|
||
{
|
||
rows.Add([pair.Key, pair.Value]);
|
||
}
|
||
if (rows.Count == 1)
|
||
{
|
||
rows.AddRange(lines.Select((line, index) => new[] { (index + 1).ToString(), line }));
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildSystemBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var metrics = KeyValues(lines).ToArray();
|
||
return metrics.Length > 0
|
||
? [ToolResultBlock.Metric(T(language, "系统摘要", "System summary"), metrics)]
|
||
: GenericTextBlocks(lines, language);
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildTrainBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
var rows = new List<string[]> { new[] { T(language, "车次/车站", "Train / station"), T(language, "信息", "Info"), T(language, "备注", "Note") } };
|
||
foreach (var line in lines.Where(line => !line.Contains("数据源", StringComparison.Ordinal) && !line.StartsWith("Fetched", StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
var parts = line.Split(" ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||
if (parts.Length >= 2)
|
||
{
|
||
rows.Add([parts[0], string.Join(" / ", parts.Skip(1)), string.Empty]);
|
||
}
|
||
else if (line.Contains(" / ", StringComparison.Ordinal))
|
||
{
|
||
var station = line.Split(" / ", StringSplitOptions.TrimEntries);
|
||
rows.Add([station.ElementAtOrDefault(0) ?? string.Empty, station.ElementAtOrDefault(1) ?? string.Empty, station.ElementAtOrDefault(2) ?? string.Empty]);
|
||
}
|
||
}
|
||
return rows.Count > 1 ? [ToolResultBlock.Table(T(language, "列车/车站", "Train / station"), rows)] : GenericTextBlocks(lines, language);
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildRankedCards(IReadOnlyList<string> lines, string title)
|
||
{
|
||
var items = new List<ToolResultListItem>();
|
||
foreach (var line in lines)
|
||
{
|
||
var trimmed = line.Trim();
|
||
if (IsRemoteHeaderOrPrivacyLine(trimmed))
|
||
{
|
||
continue;
|
||
}
|
||
var match = Regex.Match(trimmed, @"^(?<rank>\d+)[\.\)、]\s*(?<title>.*)$");
|
||
if (match.Success)
|
||
{
|
||
var visibleTitle = CleanVisibleText(match.Groups["title"].Value);
|
||
var uri = ExtractLinks([line]).FirstOrDefault() ?? string.Empty;
|
||
items.Add(new ToolResultListItem(match.Groups["rank"].Value, visibleTitle, string.Empty, "info", uri));
|
||
}
|
||
else if (items.Count > 0 && trimmed.Length > 0 && !trimmed.Contains("数据源", StringComparison.Ordinal))
|
||
{
|
||
var previous = items[^1];
|
||
var visible = CleanVisibleText(trimmed);
|
||
items[^1] = previous with { Subtitle = string.IsNullOrWhiteSpace(previous.Subtitle) ? visible : $"{previous.Subtitle}\n{visible}" };
|
||
}
|
||
}
|
||
return items.Count == 0 ? [] : [ToolResultBlock.List(ToolResultBlockKind.RankedList, title, items)];
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> BuildNewsCards(IReadOnlyList<string> lines, string title)
|
||
{
|
||
var items = new List<ToolResultListItem>();
|
||
foreach (var line in lines)
|
||
{
|
||
var trimmed = line.Trim();
|
||
if (IsRemoteHeaderOrPrivacyLine(trimmed) || string.IsNullOrWhiteSpace(trimmed))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var match = Regex.Match(trimmed, @"^(?<rank>\d+)[\.\)\u3001\uff09\s]+(?<title>.*)$");
|
||
if (match.Success)
|
||
{
|
||
var visibleTitle = CleanVisibleText(match.Groups["title"].Value);
|
||
var uri = ExtractLinks([line]).FirstOrDefault() ?? string.Empty;
|
||
items.Add(new ToolResultListItem(match.Groups["rank"].Value, visibleTitle, string.Empty, "info", uri));
|
||
continue;
|
||
}
|
||
|
||
if (items.Count > 0)
|
||
{
|
||
var previous = items[^1];
|
||
var visible = CleanVisibleText(trimmed);
|
||
items[^1] = previous with { Subtitle = string.IsNullOrWhiteSpace(previous.Subtitle) ? visible : $"{previous.Subtitle}\n{visible}" };
|
||
}
|
||
}
|
||
|
||
return items.Count == 0 ? [] : [ToolResultBlock.List(ToolResultBlockKind.NewsList, title, items)];
|
||
}
|
||
|
||
private static IReadOnlyList<ToolResultBlock> GenericTextBlocks(IReadOnlyList<string> lines, string language)
|
||
{
|
||
return [ToolResultBlock.List(ToolResultBlockKind.Text, T(language, "结果", "Result"), lines.Take(160).Select((line, index) => new ToolResultListItem((index + 1).ToString(), CleanVisibleText(line), string.Empty, "info", ExtractLinks([line]).FirstOrDefault() ?? string.Empty)).ToArray())];
|
||
}
|
||
|
||
private static IReadOnlyList<KeyValuePair<string, string>> KeyValues(IReadOnlyList<string> lines)
|
||
{
|
||
var pairs = new List<KeyValuePair<string, string>>();
|
||
foreach (var line in lines)
|
||
{
|
||
var normalized = line.Trim();
|
||
var separators = new[] { ":", ":", "=", "\t" };
|
||
foreach (var separator in separators)
|
||
{
|
||
var index = normalized.IndexOf(separator, StringComparison.Ordinal);
|
||
if (index > 0 && index < normalized.Length - separator.Length)
|
||
{
|
||
var key = normalized[..index].Trim().Trim('-', '*', ' ');
|
||
var value = normalized[(index + separator.Length)..].Trim();
|
||
if (key.Length is > 0 and < 64 && value.Length > 0)
|
||
{
|
||
pairs.Add(Pair(key, value));
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return pairs;
|
||
}
|
||
|
||
private static IEnumerable<KeyValuePair<string, string>> MetricPairs(IReadOnlyList<string> lines)
|
||
{
|
||
var pairs = KeyValues(lines).ToArray();
|
||
if (pairs.Length > 0)
|
||
{
|
||
return pairs;
|
||
}
|
||
return lines.Take(12).Select((line, index) => Pair((index + 1).ToString(), line));
|
||
}
|
||
|
||
private static IReadOnlyList<string[]> ParseTableRows(IReadOnlyList<string> lines)
|
||
{
|
||
return lines
|
||
.Where(line => line.Contains('|') || line.Contains('\t'))
|
||
.Select(line => line.Contains('|') ? line.Trim().Trim('|').Split('|', StringSplitOptions.TrimEntries) : line.Split('\t', StringSplitOptions.TrimEntries))
|
||
.Where(row => row.Length >= 2)
|
||
.Take(80)
|
||
.ToArray();
|
||
}
|
||
|
||
private static bool TryParseDecimal(string value, out decimal number)
|
||
{
|
||
var cleaned = Regex.Replace(value ?? string.Empty, @"[^\d\.\,\-]", string.Empty).Replace(",", string.Empty, StringComparison.Ordinal);
|
||
return decimal.TryParse(cleaned, NumberStyles.Float, CultureInfo.InvariantCulture, out number) ||
|
||
decimal.TryParse(cleaned, NumberStyles.Float, CultureInfo.CurrentCulture, out number);
|
||
}
|
||
|
||
private static IEnumerable<string[]> JsonArrayRows(JsonElement root)
|
||
{
|
||
if (root.ValueKind != JsonValueKind.Array)
|
||
{
|
||
yield break;
|
||
}
|
||
var items = root.EnumerateArray().Take(20).ToArray();
|
||
if (items.Length == 0)
|
||
{
|
||
yield break;
|
||
}
|
||
if (items.Any(item => item.ValueKind != JsonValueKind.Object))
|
||
{
|
||
yield return ["#", "Value"];
|
||
for (var index = 0; index < items.Length; index++)
|
||
{
|
||
yield return [(index + 1).ToString(), JsonPreview(items[index])];
|
||
}
|
||
yield break;
|
||
}
|
||
var headers = items.SelectMany(item => item.EnumerateObject().Select(property => property.Name)).Distinct(StringComparer.Ordinal).Take(8).ToArray();
|
||
yield return headers;
|
||
foreach (var item in items)
|
||
{
|
||
yield return headers.Select(header => item.TryGetProperty(header, out var value) ? JsonPreview(value) : string.Empty).ToArray();
|
||
}
|
||
}
|
||
|
||
private static string JsonPreview(JsonElement value)
|
||
{
|
||
return value.ValueKind switch
|
||
{
|
||
JsonValueKind.String => value.GetString() ?? string.Empty,
|
||
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => value.GetRawText(),
|
||
JsonValueKind.Null => "null",
|
||
JsonValueKind.Array => $"Array[{value.GetArrayLength()}]",
|
||
JsonValueKind.Object => JsonSerializer.Serialize(value),
|
||
_ => value.GetRawText()
|
||
};
|
||
}
|
||
|
||
private static bool IsReferenceTool(string id)
|
||
{
|
||
return id is "mime_lookup" or "http_status_lookup" or "port_lookup" or "dns_record_lookup" or "unicode_block_lookup" or "charset_lookup" or "timezone_abbr_lookup" or "regex_preset_lookup" or "magic_number_lookup" or "car_info" or "sanguosha_skin";
|
||
}
|
||
|
||
private static ToolResultKind GuessRemoteResultKind(string id)
|
||
{
|
||
return id is "baidu_hot" or "hotboard" or "bili_hot" or "zhihu_hot" or "movie_box_office" or "history_today"
|
||
? ToolResultKind.RankedList
|
||
: id is "tech_news" or "football_news" or "cctv_news" or "ai_latest_news"
|
||
? ToolResultKind.NewsCards
|
||
: id is "city_route_query"
|
||
? ToolResultKind.KeyValueCards
|
||
: ToolResultKind.KeyValueCards;
|
||
}
|
||
|
||
private static ToolResultDocument Document(IToolModule module, ToolPageSpec spec, string output, IReadOnlyList<ToolResultBlock> blocks, string language)
|
||
{
|
||
var experience = ToolResultExperienceCatalog.CreateExperience(module);
|
||
return new ToolResultDocument(module.Id, spec.Result, output, blocks, Metadata(module.Id, spec.Result, language, DisplayProfile(spec.Result), experience), output, string.Empty, "ok");
|
||
}
|
||
|
||
private static Dictionary<string, string> Metadata(string toolId, ToolResultKind kind, string language, string displayProfile, ToolResultExperience? experience = null)
|
||
{
|
||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["toolId"] = toolId,
|
||
["resultKind"] = kind.ToString(),
|
||
["language"] = language,
|
||
["generatedAt"] = DateTimeOffset.Now.ToString("O"),
|
||
["loadedAt"] = DateTimeOffset.Now.ToString("O"),
|
||
["dataFreshness"] = "live",
|
||
["displayProfile"] = displayProfile
|
||
};
|
||
if (experience is not null)
|
||
{
|
||
metadata["profile"] = experience.Profile;
|
||
metadata["domain"] = experience.Domain;
|
||
metadata["preferredBlocks"] = string.Join(",", experience.PreferredBlocks ?? []);
|
||
metadata["copyPolicy"] = experience.CopyPolicy;
|
||
metadata["privacyLevel"] = experience.PrivacyLevel;
|
||
metadata["defaultExpandedBlocks"] = string.Join(",", experience.DefaultExpandedBlocks ?? []);
|
||
}
|
||
|
||
var localJsProfile = LocalJsProfileFor(toolId);
|
||
if (!string.IsNullOrWhiteSpace(localJsProfile))
|
||
{
|
||
metadata["localJsProfile"] = localJsProfile;
|
||
}
|
||
|
||
var relatedActions = RelatedActionsFor(toolId, language);
|
||
if (!string.IsNullOrWhiteSpace(relatedActions))
|
||
{
|
||
metadata["relatedActions"] = relatedActions;
|
||
}
|
||
|
||
return metadata;
|
||
}
|
||
|
||
private static string LocalJsProfileFor(string toolId)
|
||
=> toolId switch
|
||
{
|
||
"json_formatter" => "json",
|
||
"yaml_json_converter" => "yaml",
|
||
"xml_formatter" => "xml",
|
||
"html_minifier" or "js_obfuscator" => "markup",
|
||
"markdown_preview" or "markdown_table_normalizer" => "markdown",
|
||
"html_entity" => "html-entity",
|
||
"punycode_codec" => "idn",
|
||
"timestamp_converter" or "date_time_calculator" => "time",
|
||
"base64_codec" or "url_codec" or "unicode_codec" => "codec",
|
||
"hash_generator" or "hmac_generator" => "hash",
|
||
"qr_generator" => "qrcode",
|
||
_ => string.Empty
|
||
};
|
||
|
||
private static string RelatedActionsFor(string toolId, string language)
|
||
{
|
||
var english = language.StartsWith("en", StringComparison.OrdinalIgnoreCase);
|
||
object Action(string kind, string labelZh, string labelEn, string target, string toolId = "", string routeTag = "")
|
||
=> new
|
||
{
|
||
kind,
|
||
label = english ? labelEn : labelZh,
|
||
target,
|
||
toolId,
|
||
routeTag
|
||
};
|
||
|
||
var actions = toolId switch
|
||
{
|
||
"json_formatter" => new[]
|
||
{
|
||
Action("navigateTool", "转到 JSONPath", "Open JSONPath", "json_path_helper", "json_path_helper"),
|
||
Action("navigateTool", "转 YAML/JSON", "Open YAML/JSON", "yaml_json_converter", "yaml_json_converter")
|
||
},
|
||
"yaml_json_converter" => new[]
|
||
{
|
||
Action("navigateTool", "转到 JSON 格式化", "Open JSON formatter", "json_formatter", "json_formatter")
|
||
},
|
||
"markdown_preview" => new[]
|
||
{
|
||
Action("navigateTool", "整理 Markdown 表格", "Normalize markdown table", "markdown_table_normalizer", "markdown_table_normalizer")
|
||
},
|
||
"markdown_table_normalizer" => new[]
|
||
{
|
||
Action("navigateTool", "预览 Markdown", "Preview markdown", "markdown_preview", "markdown_preview")
|
||
},
|
||
"base64_codec" or "url_codec" or "html_entity" or "unicode_codec" or "punycode_codec" => new[]
|
||
{
|
||
Action("navigateTool", "转到 URL 编解码", "Open URL codec", "url_codec", "url_codec"),
|
||
Action("navigateTool", "转到 Base64", "Open Base64", "base64_codec", "base64_codec")
|
||
},
|
||
"url_inspector" or "url_redirect_trace" => new[]
|
||
{
|
||
Action("navigateTool", "转到 URL 编解码", "Open URL codec", "url_codec", "url_codec"),
|
||
Action("navigateTool", "转到域名 RDAP", "Open domain RDAP", "rdap_domain_lookup", "rdap_domain_lookup")
|
||
},
|
||
"dns_query" or "ip_lookup" or "ip_info" => new[]
|
||
{
|
||
Action("navigateTool", "转到 IP 信息", "Open IP info", "ip_info", "ip_info"),
|
||
Action("navigateTool", "转到 RDAP 查询", "Open RDAP lookup", "rdap_ip_lookup", "rdap_ip_lookup")
|
||
},
|
||
"hash_generator" or "hmac_generator" => new[]
|
||
{
|
||
Action("navigateTool", "生成哈希清单", "Build hash manifest", "hash_manifest_builder", "hash_manifest_builder"),
|
||
Action("navigateTool", "校验哈希清单", "Verify hash manifest", "hash_manifest_verify", "hash_manifest_verify")
|
||
},
|
||
"hash_manifest_builder" => new[]
|
||
{
|
||
Action("navigateTool", "校验哈希清单", "Verify hash manifest", "hash_manifest_verify", "hash_manifest_verify")
|
||
},
|
||
"hash_manifest_verify" => new[]
|
||
{
|
||
Action("navigateTool", "生成哈希清单", "Build hash manifest", "hash_manifest_builder", "hash_manifest_builder")
|
||
},
|
||
"qr_generator" => new[]
|
||
{
|
||
Action("navigateTool", "扫描二维码", "Scan QR code", "qrcode_scanner", "qrcode_scanner")
|
||
},
|
||
"qrcode_scanner" => new[]
|
||
{
|
||
Action("navigateTool", "生成二维码", "Generate QR code", "qr_generator", "qr_generator")
|
||
},
|
||
"sanguosha_skin" => new[]
|
||
{
|
||
Action("navigateRoute", "查看服务状态", "Open service status", "serviceStatus", routeTag: "serviceStatus")
|
||
},
|
||
_ => []
|
||
};
|
||
|
||
return actions.Length == 0 ? string.Empty : JsonSerializer.Serialize(actions);
|
||
}
|
||
|
||
private static Dictionary<string, string> RemoteMetadata(ApiEndpoint endpoint, ToolResultKind kind, string language)
|
||
{
|
||
var metadata = Metadata(endpoint.Id, kind, language, "remote-dashboard");
|
||
metadata["sourceName"] = endpoint.SourceName;
|
||
metadata["sourceVisibility"] = endpoint.SourceVisibility.ToString();
|
||
metadata["sourceSensitivity"] = endpoint.SourceVisibility == ApiSourceVisibility.SensitivePrivate ? "sensitive" : "public";
|
||
metadata["dataFreshness"] = "remote-live";
|
||
|
||
return metadata;
|
||
}
|
||
|
||
private static string DisplayProfile(ToolResultKind kind)
|
||
{
|
||
return kind switch
|
||
{
|
||
ToolResultKind.RankedList or ToolResultKind.NewsCards => "cards",
|
||
ToolResultKind.Table or ToolResultKind.CalculatorTable or ToolResultKind.ReferenceRows => "table",
|
||
ToolResultKind.ImagePreview or ToolResultKind.Media => "preview",
|
||
ToolResultKind.JsonTree or ToolResultKind.CodePreview => "document",
|
||
_ => "summary-details"
|
||
};
|
||
}
|
||
|
||
private static IReadOnlyDictionary<string, string> BlockMeta(string contentType, string displayMode, string priority)
|
||
{
|
||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["contentType"] = contentType,
|
||
["displayMode"] = displayMode,
|
||
["priority"] = priority
|
||
};
|
||
}
|
||
|
||
private static KeyValuePair<string, string> Pair(string key, string value) => new(key, value);
|
||
|
||
private static IReadOnlyList<string> Lines(string output)
|
||
{
|
||
return output.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||
}
|
||
|
||
private static IReadOnlyList<string> RemoveSourceHeader(IReadOnlyList<string> lines)
|
||
{
|
||
return lines
|
||
.Where(line => !IsRemoteHeaderOrPrivacyLine(line))
|
||
.ToArray();
|
||
}
|
||
|
||
private static bool IsRemoteHeaderOrPrivacyLine(string line)
|
||
{
|
||
var normalized = line.Trim();
|
||
return IsSourceLine(normalized) ||
|
||
normalized.StartsWith("\u6570\u636e\u6e90", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("\u6765\u6e90", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("\u83b7\u53d6\u65f6\u95f4", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("Source note:", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("remote address is hidden", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("sanitized source name", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("\u6765\u6e90\u8bf4\u660e", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("\u5df2\u9690\u85cf\u8fdc\u7a0b\u5730\u5740", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("\u8131\u654f\u6765\u6e90", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("璇存槑", StringComparison.OrdinalIgnoreCase) && normalized.Contains("闅愯棌", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("璇存槑", StringComparison.OrdinalIgnoreCase) && normalized.Contains("宸查殣", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("鑴辨晱", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("杩滅▼鍦板潃", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.Contains("繙绋嬪湴鍧", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static bool IsSourceLine(string line)
|
||
{
|
||
var normalized = line.Trim();
|
||
return normalized.StartsWith("Data source:", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("Fetched at:", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("Source:", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("来源", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("数据源", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("获取时间", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("鏉ユ簮", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("鏁版嵁", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized.StartsWith("鑾峰彇", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static string SplitValue(string line)
|
||
{
|
||
var index = line.IndexOfAny([':', ':']);
|
||
return index >= 0 && index < line.Length - 1 ? line[(index + 1)..].Trim() : line;
|
||
}
|
||
|
||
private static string StripLabel(string value)
|
||
{
|
||
var index = value.IndexOfAny([':', ':']);
|
||
return index >= 0 && index < value.Length - 1 ? value[(index + 1)..].Trim() : value.Trim();
|
||
}
|
||
|
||
private static IEnumerable<string> ExtractLinks(IEnumerable<string> lines)
|
||
{
|
||
foreach (var line in lines)
|
||
{
|
||
foreach (Match match in Regex.Matches(line, @"https?://[^\s\]\)>'""]+", RegexOptions.IgnoreCase))
|
||
{
|
||
yield return match.Value.TrimEnd('.', ',', ';');
|
||
}
|
||
}
|
||
}
|
||
|
||
private static string CleanVisibleText(string value)
|
||
{
|
||
var text = value ?? string.Empty;
|
||
text = Regex.Replace(text, @"\s*https?://[^\s\]\)>'""]+", string.Empty, RegexOptions.IgnoreCase);
|
||
text = Regex.Replace(text, @"\s*/\s*$", string.Empty);
|
||
text = Regex.Replace(text, @"\s{2,}", " ");
|
||
return text.Trim();
|
||
}
|
||
|
||
private static bool LooksLikeJson(string output)
|
||
{
|
||
var trimmed = output.TrimStart();
|
||
return trimmed.StartsWith('{') || trimmed.StartsWith('[');
|
||
}
|
||
|
||
private static bool LooksLikeSvg(string output)
|
||
{
|
||
var trimmed = output.TrimStart();
|
||
return trimmed.StartsWith("<svg", StringComparison.OrdinalIgnoreCase) ||
|
||
(trimmed.StartsWith("<?xml", StringComparison.OrdinalIgnoreCase) &&
|
||
trimmed.Contains("<svg", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
private static string SourceTypeLabel(ApiEndpoint endpoint, string language)
|
||
{
|
||
return endpoint.SourceVisibility switch
|
||
{
|
||
ApiSourceVisibility.PublicOfficial => T(language, "官方/权威源", "Official / authoritative"),
|
||
ApiSourceVisibility.PublicTrusted => T(language, "公开可信源", "Public trusted"),
|
||
ApiSourceVisibility.SensitivePrivate => T(language, "私有/敏感源", "Private / sensitive"),
|
||
_ => endpoint.IsOfficial ? T(language, "官方/权威源", "Official / authoritative") : T(language, "公开可信源", "Public trusted")
|
||
};
|
||
}
|
||
|
||
private static IReadOnlyDictionary<string, string> SourceMetadata(ApiEndpoint endpoint)
|
||
{
|
||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["contentType"] = "text/plain",
|
||
["displayMode"] = "source",
|
||
["priority"] = "low",
|
||
["sourceVisibility"] = endpoint.SourceVisibility.ToString(),
|
||
["sourceSensitivity"] = endpoint.SourceVisibility == ApiSourceVisibility.SensitivePrivate ? "sensitive" : "public",
|
||
["sourceName"] = endpoint.SourceName
|
||
};
|
||
|
||
return metadata;
|
||
}
|
||
|
||
private static string CodeLanguage(string id)
|
||
{
|
||
return id switch
|
||
{
|
||
var value when value.Contains("json", StringComparison.OrdinalIgnoreCase) => "json",
|
||
var value when value.Contains("xml", StringComparison.OrdinalIgnoreCase) => "xml",
|
||
var value when value.Contains("sql", StringComparison.OrdinalIgnoreCase) => "sql",
|
||
var value when value.Contains("html", StringComparison.OrdinalIgnoreCase) => "html",
|
||
_ => string.Empty
|
||
};
|
||
}
|
||
|
||
private static string DiffLeading(string line) => line.StartsWith('+') ? "+" : line.StartsWith('-') ? "-" : " ";
|
||
|
||
private static string DiffStatus(string line) => line.StartsWith('+') ? "added" : line.StartsWith('-') ? "removed" : "context";
|
||
|
||
private static string StatusLeading(string line) => StatusOf(line) switch { "ok" => "OK", "error" => "!", _ => "i" };
|
||
|
||
private static string StatusOf(string line)
|
||
{
|
||
return line.Contains("OK", StringComparison.OrdinalIgnoreCase) || line.Contains("SUCCESS", StringComparison.OrdinalIgnoreCase)
|
||
? "ok"
|
||
: line.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || line.Contains("MISMATCH", StringComparison.OrdinalIgnoreCase) || line.Contains("ERROR", StringComparison.OrdinalIgnoreCase)
|
||
? "error"
|
||
: "info";
|
||
}
|
||
|
||
private static string T(string language, string zh, string en) => string.Equals(language, "en-US", StringComparison.OrdinalIgnoreCase) ? en : zh;
|
||
}
|