Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions src/plugins/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,19 +326,25 @@ TypedPluginLoader.Register<MyPlugin>();
| 路径 | `src/plugins/ZeroBot.Painter/` |
| 命名空间 | `ZeroBot.Painter` |

**功能:** 小画家绘图,群聊内通过指令调用后台绘图训练服务生成图片,按群与群员做每日用量限制。
**功能:** 小画家绘图,群聊内通过指令调用后台绘图训练服务生成图片,按群与群员做每日用量限制;支持群公共素材库(图片素材随消息或引用消息添加,绘图时按名称引用)。

**组件:**
- `PainterManageCommandHandler` - 启用/禁用指令 (`/小画家:启用`、`/小画家:禁用`)
- `PainterMaterialCommandHandler` - 公共素材指令 (`/小画家:公共素材`,增删需高权限,列出对群员开放)
- `PaintCommandHandler` - 绘图指令 (`/小画家:画`,`CommandQueuedHandler` + 表情反馈)
- `PainterApi` - 绘图接口 HTTP 客户端(multipart 上传,请求级超时热生效)
- `PainterApi` - 绘图接口 HTTP 客户端(multipart 上传,请求级超时热生效;失败时回发上游状态码与响应体原文,截断至 300 字符)
- `PainterMaterialStore` - 公共素材落盘(工作目录 `painter/{群uid}/{名称SHA-256前16位}/`,含 `material.json` 与图片文件)
- `PainterImages` - 引用消息 / 本消息图片收集(引用消息的图片优先,不复用只取首图的 `GetMilkyImageMessagesAsync`)
- `PainterQuota` - UTC+8 每日用量/日期/上限校验工具

**命令:**
- `/小画家:启用:{number}` / `/小画家:禁用` - 开启/关闭本群绘图功能(仅群聊,高权限用户,权限 `painter.manage`;number 为每人每日上限,1~5)
- `/小画家:画:{prompt}` - 调用绘图接口并把结果发回群聊(仅群聊,附 0~10 张参考图,受群内每人每日上限限制;未开启的群完全静默)
- `/小画家:启用:{number}` / `/小画家:禁用` - 开启/关闭本群绘图功能(仅群聊,高权限用户,权限 `painter.manage`;number 为每人每日上限,1~5,-1 不限制)
- `/小画家:画:{prompt}` - 调用绘图接口并把结果发回群聊(仅群聊,附 0~10 张参考图,受群内每人每日上限限制;未开启的群完全静默)。参考图优先级:引用消息中的图片 > 本条消息中的图片 > 提示词开头 `{name}` 引用的公共素材图片;`{name}` 未命中素材时原样保留在提示词中
- `/小画家:公共素材` - 列出本群公共素材(仅群聊、本群已开启,所有群员可用)
- `/小画家:公共素材:添加:{name}` - 把本条消息(或引用消息)中的图片添加为公共素材,同名覆盖(高权限用户,权限 `painter.manage`)
- `/小画家:公共素材:删除:{name}` - 删除本群公共素材(高权限用户,权限 `painter.manage`)

**配置文件:** `painter-config.json`(`Endpoint` 支持热加载,默认可用环境变量 `Z_VTUBER_TRAINING_ENDPOINT`;`HttpTimeoutSeconds` 默认 600;`Groups` 为群 PeerId → 每人每日上限;`DailyQuotas` 为当日用量表)
**配置文件:** `painter-config.json`(`Endpoint` 支持热加载,默认可用环境变量 `Z_VTUBER_TRAINING_ENDPOINT`;`HttpTimeoutSeconds` 默认 600;`MaxImages` 单次最多参考图数,默认 10;`Groups` 为群 PeerId → 每人每日上限;`DailyQuotas` 为当日用量表)

---

Expand All @@ -349,16 +355,18 @@ TypedPluginLoader.Register<MyPlugin>();
| 路径 | `src/plugins/ZeroBot.Workflow/` |
| 命名空间 | `ZeroBot.Workflow` |

**功能:** 工作流执行插件,通过后台工作流服务运行脚本并按结果类型(音频/文件)发回群聊。
**功能:** 工作流执行插件,通过后台工作流服务运行脚本并按结果类型(音频/文件)发回群聊;群功能默认关闭,需由 bot 管理员启用。

**组件:**
- `WorkflowCommandHandler` - 工作流指令 (`/workflow:run`,`CommandQueuedHandler` + 表情反馈)
- `WorkflowManageCommandHandler` - 群开关指令 (`/workflow:enable`、`/workflow:disable`)
- `WorkflowApi` - 工作流服务 HTTP 客户端(先 `/v1/compile` 校验展开,再 `/v1/run` 执行;请求级超时)

**命令:**
- `/workflow:run:{file|voice}:{script}` - 仅群聊、仅高权限用户(sudoers / 群管理员)可用;`script` 吃掉第二个分隔符之后的全部内容(可换行),`file` 发送文件、`voice` 发送语音
- `/workflow:enable` / `/workflow:disable` - 开启/关闭本群工作流功能(仅群聊,仅 bot 管理员即 sudoers,群管理员无权操作)
- `/workflow:run:{file|voice}:{script}` - 仅群聊、需本群已启用、仅高权限用户(sudoers / 群管理员)可用;`script` 吃掉第二个分隔符之后的全部内容(可换行),`file` 发送文件、`voice` 发送语音;未启用的群静默忽略

**配置:** 配置节 `WorkflowService`,例如环境变量 `WorkflowService__BaseUrl`(服务基地址);`WorkflowService__HttpTimeoutSeconds`(单次请求超时秒数,默认 600)
**配置:** 配置节 `WorkflowService`,例如环境变量 `WorkflowService__BaseUrl`(服务基地址);`WorkflowService__HttpTimeoutSeconds`(单次请求超时秒数,默认 1800 即 30 分钟)。群开关持久化于热加载的 `workflow-config.json`(`EnabledGroups` 为已启用群 PeerId 集合)

**依赖:** `ZeroAsh.Milky.Net.Client`(通过群文件上传发送 `file` 结果)

Expand Down
84 changes: 73 additions & 11 deletions src/plugins/ZeroBot.Painter/PaintCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text;
using Microsoft.Extensions.Logging;
using Milky.Net.Model;
using ZeroBot.Abstraction.Bot;
Expand All @@ -12,6 +13,10 @@ namespace ZeroBot.Painter;
/// 同群同人每天(UTC+8)受该群上限限制,通过限额检查后、调用接口之前即消耗一次用量,
/// 失败调用同样计入且不回滚。
///
/// 参赛图片的优先级:引用消息中的图片 &gt; 本条消息中的图片 &gt; 提示词开头 <c>{name}</c> 引用的公共素材图片。
/// 提示词开头的 <c>{name}</c> 引用命中本群公共素材时从提示词中移除并追加其图片,
/// 未命中则原样保留(不替换)。
///
/// 群上限在请求发起时(派发阶段)捕获,排队期间管理员禁用/调整上限不影响在途请求,
/// 保证「以发起时状态为准,进行中的调用完成并回发结果」(PRD §5)。
/// </summary>
Expand All @@ -20,6 +25,7 @@ public class PaintCommandHandler(
IBotContext bot,
IJsonConfig<PainterOptions> config,
PainterApi api,
PainterMaterialStore store,
ILogger<PaintCommandHandler> logger) : CommandQueuedHandler(dispatcher)
{
private const string CommandPrefix = "/小画家:画";
Expand Down Expand Up @@ -70,6 +76,32 @@ public static bool TryParsePrompt(string raw, out string prompt)
return true;
}

/// <summary>
/// 切出提示词开头连续的 <c>{name}</c> 引用。
/// 返回每个引用的原文(含花括号)与其中的名称,以及引用之后的剩余提示词;
/// 首字符不是 <c>{</c>、<c>{}</c> 或缺少右花括号时停止切分(后续内容整体作为剩余提示词)。
/// 名称非法(空)的引用视为普通文本,停止切分。
/// </summary>
public static (List<(string Raw, string Name)> References, string Remaining) SplitMaterialReferences(string prompt)
{
var references = new List<(string Raw, string Name)>();
var remaining = prompt;

while (remaining.Length > 1 && remaining[0] == '{')
{
var close = remaining.IndexOf('}');
if (close <= 1) break;

var name = remaining[1..close].Trim();
if (name.Length == 0) break;

references.Add((remaining[..(close + 1)], name));
remaining = remaining[(close + 1)..];
}

return (references, remaining);
}

protected override ValueTask<bool> PredicateAsync(Event<IncomingMessage> message,
CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -131,7 +163,25 @@ protected override async ValueTask DequeueAsync(Event<IncomingMessage> @event,
return;
}

if (string.IsNullOrWhiteSpace(prompt))
// 提示词开头的 {name} 引用本群公共素材:命中则移除占位符并追加其图片,未命中则保留原文。
var (references, remainingPrompt) = SplitMaterialReferences(prompt.TrimStart());
var materialNames = new List<string>();
var builder = new StringBuilder();
foreach (var (rawReference, materialName) in references)
{
if (store.Find(peerId, materialName) is null)
{
builder.Append(rawReference);
continue;
}

if (!materialNames.Contains(materialName)) materialNames.Add(materialName);
}

builder.Append(remainingPrompt);
var finalPrompt = builder.ToString();

if (string.IsNullOrWhiteSpace(finalPrompt))
{
await @event.ReplyAsGroup(bot, cancellationToken,
["提示词不能为空,请使用:/小画家:画:{prompt}".ToMilkyTextSegment()]);
Expand All @@ -145,34 +195,46 @@ await @event.ReplyAsGroup(bot, cancellationToken,
return;
}

byte[]? bytes;
PaintResult result;
try
{
var options = config.Current;
var images = new List<byte[]>();
foreach (var seg in @event.Data.Segments.OfType<ImageIncomingSegment>().Take(options.MaxImages))

// 优先级:引用消息的图片 > 本条消息的图片 > 提示词引用素材的图片,总计不超过 MaxImages 张。
var images = await PainterImages.CollectContextImagesAsync(bot, @event, options.MaxImages,
cancellationToken);
foreach (var materialName in materialNames)
{
images.Add(await seg.GetMilkyImageBytesAsync(bot, @event, cancellationToken));
if (images.Count >= options.MaxImages) break;

foreach (var materialImage in await store.LoadImagesAsync(peerId, materialName, cancellationToken))
{
if (images.Count >= options.MaxImages) break;
images.Add(materialImage);
}
}

bytes = await api.GenerateAsync(options.Endpoint, prompt, images,
result = await api.GenerateAsync(options.Endpoint, finalPrompt, images,
TimeSpan.FromSeconds(options.HttpTimeoutSeconds), cancellationToken);
}
catch (Exception e)
{
// 取图或调用异常:仅记日志并按失败处理;用量已消耗,不回滚。
logger.LogError(e, "绘图调用异常");
bytes = null;
result = PaintResult.Fail(null);
}

if (bytes is null)
if (!result.Success || result.Data is not { Length: > 0 } image)
{
await @event.ReplyAsGroup(bot, cancellationToken,
["绘图失败,请稍后重试。".ToMilkyTextSegment()]);
// 带上上游返回的错误详情(PainterApi 已截断),便于用户与运维定位问题。
var message = string.IsNullOrWhiteSpace(result.Error)
? "绘图失败,请稍后重试。"
: $"绘图失败:{result.Error}";
await @event.ReplyAsGroup(bot, cancellationToken, [message.ToMilkyTextSegment()]);
return;
}

await @event.ReplyAsGroup(bot, cancellationToken, [bytes.ToMilkyImageSegment()]);
await @event.ReplyAsGroup(bot, cancellationToken, [image.ToMilkyImageSegment()]);
}
catch (Exception e)
{
Expand Down
69 changes: 58 additions & 11 deletions src/plugins/ZeroBot.Painter/PainterApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@

namespace ZeroBot.Painter;

/// <summary>
/// 绘图接口调用结果:成功时 <see cref="Data"/> 为图片二进制;
/// 失败时 <see cref="Error"/> 为可直接回复到聊天中的错误信息(尽力包含上游返回的错误详情),
/// 无上游详情时为 null,由调用方回落到通用失败提示。
/// </summary>
public sealed record PaintResult(bool Success, byte[]? Data, string? Error)
{
public static PaintResult Ok(byte[] data) => new(true, data, null);

public static PaintResult Fail(string? error) => new(false, null, error);
}

/// <summary>
/// 绘图训练服务接口客户端。
/// 自持专用 <see cref="HttpClient"/>(实例级超时置为无限),唯一超时来源是请求级
Expand All @@ -14,10 +26,14 @@ public class PainterApi(ILogger<PainterApi> logger)
// 也不与 SynthesizePlugin 注册的 AddSingleton<HttpClient>() 抢解析。
private static readonly HttpClient Http = new() { Timeout = Timeout.InfiniteTimeSpan };

/// <summary>回复到聊天中的错误信息最大长度,超出截断,避免上游长响应刷屏。</summary>
private const int MaxErrorLength = 300;

/// <summary>
/// 以 multipart/form-data 调用绘图接口,成功返回图片二进制;失败(非 2xx / 超时 / 异常 / 空体 / JSON 响应)返回 null。
/// 以 multipart/form-data 调用绘图接口,成功返回图片二进制;
/// 失败(非 2xx / 超时 / 异常 / 空体 / JSON 响应)返回 <see cref="PaintResult"/>,尽量带上上游错误详情。
/// </summary>
public async Task<byte[]?> GenerateAsync(string endpoint, string prompt, IReadOnlyList<byte[]> images,
public async Task<PaintResult> GenerateAsync(string endpoint, string prompt, IReadOnlyList<byte[]> images,
TimeSpan timeout, CancellationToken cancellationToken = default)
{
try
Expand All @@ -40,34 +56,65 @@ public class PainterApi(ILogger<PainterApi> logger)
using var response = await Http.PostAsync(url, form, cts.Token);
if (!response.IsSuccessStatusCode)
{
logger.LogError("绘图请求失败,状态码 {Status}", response.StatusCode);
return null;
var body = await response.Content.ReadAsStringAsync(cts.Token);
logger.LogError("绘图请求失败,状态码 {Status},响应 {Body}",
response.StatusCode, Truncate(body, MaxErrorLength));
return PaintResult.Fail(DescribeFailure(response, body));
}

// 防御分支:接口若改为 JSON 结构响应,本期不解析,避免把 JSON 当图片发出。
// 防御分支:接口若改为 JSON 结构响应,本期不解析,避免把 JSON 当图片发出;
// 但尽量把响应内容(通常是错误详情)回传给用户,便于定位问题。
var mediaType = response.Content.Headers.ContentType?.MediaType;
if (string.Equals(mediaType, "application/json", StringComparison.OrdinalIgnoreCase))
{
logger.LogError("绘图接口返回 JSON({MediaType}),本期不支持解析,按失败处理", mediaType);
return null;
var body = await response.Content.ReadAsStringAsync(cts.Token);
logger.LogError("绘图接口返回 JSON({MediaType}),本期不支持解析,按失败处理:{Body}",
mediaType, Truncate(body, MaxErrorLength));

var detail = NormalizeBody(body);
return PaintResult.Fail(string.IsNullOrWhiteSpace(detail)
? "绘图接口返回了非图片响应"
: $"绘图接口返回了非图片响应:{detail}");
}

var bytes = await response.Content.ReadAsByteArrayAsync(cts.Token);
return bytes.Length == 0 ? null : bytes;
return bytes.Length == 0
? PaintResult.Fail("绘图接口返回了空响应")
: PaintResult.Ok(bytes);
}
catch (TaskCanceledException e) when (!cancellationToken.IsCancellationRequested)
{
// 外层令牌未取消 → 请求级 CTS 触发,即超时。
logger.LogError(e, "绘图请求超时({TimeoutSeconds} 秒)", timeout.TotalSeconds);
return null;
var seconds = timeout.TotalSeconds;
logger.LogError(e, "绘图请求超时({TimeoutSeconds} 秒)", seconds);
return PaintResult.Fail($"绘图请求超时({seconds:0.##} 秒)");
}
catch (Exception e)
{
logger.LogError(e, "绘图请求异常");
return null;
return PaintResult.Fail(null);
}
}

/// <summary>
/// 拼装非 2xx 的失败信息,形如 <c>上游返回 500 Internal Server Error:{响应体}</c>。
/// </summary>
private static string DescribeFailure(HttpResponseMessage response, string body)
{
var status = $"上游返回 {(int)response.StatusCode} {response.StatusCode}";
var detail = NormalizeBody(body);
return string.IsNullOrWhiteSpace(detail) ? status : $"{status}:{detail}";
}

/// <summary>
/// 把上游响应体按原文整理为可直接回复的错误信息:去首尾空白并截断;空白响应返回 null。
/// </summary>
public static string? NormalizeBody(string? body)
=> string.IsNullOrWhiteSpace(body) ? null : Truncate(body.Trim(), MaxErrorLength);

private static string Truncate(string value, int maxLength)
=> value.Length <= maxLength ? value : value[..maxLength] + "…";

/// <summary>
/// 按文件魔数嗅探图片类型,返回 (Content-Type, 扩展名);未知类型回落到通用二进制。
/// </summary>
Expand Down
Loading
Loading