Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ a deployment-owned remote daemon or managed agent platform.

## Documentation

- [Provider Base64 file messages (中文)](docs/provider-base64-files.md)
- Plugin development tutorial — https://langbot.app/docs/zh/plugin/dev/tutor
- Debugging the Runtime / CLI / SDK — https://langbot.app/docs/zh/develop/plugin-runtime
- Dev environment setup — https://langbot.app/docs/zh/develop/dev-config
Expand Down
1 change: 1 addition & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ lbp --help

## 文档

- [Provider Base64 文件消息](docs/provider-base64-files.md) —— 完整消息、流式消息与平台文件组件的转换规则。
- 插件开发教程 —— https://langbot.app/docs/zh/plugin/dev/tutor
- 调试运行时 / CLI / SDK —— https://langbot.app/docs/zh/develop/plugin-runtime
- 开发环境配置 —— https://langbot.app/docs/zh/develop/dev-config
Expand Down
51 changes: 51 additions & 0 deletions docs/provider-base64-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Provider 消息中的 Base64 文件

`ContentElement.from_file_base64()` 表示由 Runner 或插件生成的内联文件。`Message` 和 `MessageChunk` 的 `get_content_platform_message_chain()` 会将该元素转换为 `File(base64=..., name=...)`,供平台适配器消费。

## 使用示例

```python
import base64

from langbot_plugin.api.entities.builtin.platform.message import File
from langbot_plugin.api.entities.builtin.provider.message import ContentElement, Message

data = "报告内容\n".encode("utf-8")
message = Message(
role="assistant",
content=[
ContentElement.from_text("文件已生成。"),
ContentElement.from_file_base64(
base64.b64encode(data).decode("ascii"), "报告.txt"
),
],
)
chain = message.get_content_platform_message_chain()
assert isinstance(chain[1], File)
assert chain[1].name == "报告.txt"
assert base64.b64decode(chain[1].base64) == data
```

流式输出使用 `MessageChunk` 构造相同的 `content`。这些文件元素经 `RunnerResult.message_delta()`、`message_completed()` 或携带最终消息的 `run_completed()` 序列化往返后仍然保留。

## 转换规则

- Base64 字符串和文件名原样传递,不下载、解码或重写载荷。
- 缺少 `file_base64`(值为 `None`)时跳过该元素;空字符串则保留为零字节文件组件。
- 缺省文件名或 `file_name=None` 转换为平台 `File` 的默认空名。
- 文件与文字、图片、URL 文件按原内容顺序转换。`prefix_text` 只加在第一个文字组件上;没有文字时,在文件前插入文字前缀。
- Host 已解析的 `attachments` 继续追加在转换内容之后,不进入模型或插件协议;重复转换不修改原始消息及附件。
- SDK 转换成功只说明文件组件已保留。实际发送能力、附件大小和格式限制仍由目标平台及适配器决定。

## 本地回归

在 SDK 仓库根目录执行:

```bash
uv sync --dev --python 3.12
uv run --no-sync pytest tests/api/entities/builtin/test_provider_message.py \
tests/api/entities/builtin/test_provider_message_files.py \
tests/api/entities/builtin/runner/test_context_result.py tests/test_message.py -q
```

本修复不新增字段、配置、环境变量或依赖。LangBot 使用固定版本的 SDK;运行中的实例需在包含修复的 SDK 发布并被其采用后,才能取得该行为。
14 changes: 14 additions & 0 deletions src/langbot_plugin/api/entities/builtin/provider/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ def _get_content_platform_message_chain(
mc.append(
platform_message.File(url=ce.file_url, name=ce.file_name)
)
elif ce.type == "file_base64":
if ce.file_base64 is not None:
mc.append(
platform_message.File(
base64=ce.file_base64, name=ce.file_name or ""
)
)
elif ce.type == "image_url":
if ce.image_url is None:
raise ValueError("image_url content requires image_url payload")
Expand Down Expand Up @@ -283,6 +290,13 @@ def _get_content_platform_message_chain(
mc.append(
platform_message.File(url=ce.file_url, name=ce.file_name)
)
elif ce.type == "file_base64":
if ce.file_base64 is not None:
mc.append(
platform_message.File(
base64=ce.file_base64, name=ce.file_name or ""
)
)
elif ce.type == "image_url":
if ce.image_url is None:
raise ValueError("image_url content requires image_url payload")
Expand Down
178 changes: 178 additions & 0 deletions tests/api/entities/builtin/test_provider_message_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
from __future__ import annotations

import base64

import pytest

from langbot_plugin.api.entities.builtin.platform.message import (
File,
Image,
MessageChain,
Plain,
)
from langbot_plugin.api.entities.builtin.provider.message import (
ContentElement,
Message,
MessageChunk,
)
from langbot_plugin.api.entities.builtin.runner.result import (
MessageCompletedPayload,
MessageDeltaPayload,
RunCompletedPayload,
RunnerResult,
)


@pytest.mark.parametrize("message_type", [Message, MessageChunk])
@pytest.mark.parametrize("data", [b"hello\n", b"\x00\xff\x80\x01", b""])
def test_base64_file_preserves_bytes_and_unicode_name(message_type, data):
encoded = base64.b64encode(data).decode("ascii")
message = message_type(
role="assistant",
content=[ContentElement.from_file_base64(encoded, "报告.bin")],
)

chain = message.get_content_platform_message_chain()

assert len(chain) == 1
assert isinstance(chain[0], File)
assert chain[0].base64 == encoded
assert base64.b64decode(chain[0].base64) == data
assert chain[0].name == "报告.bin"
assert chain[0].url == ""


@pytest.mark.parametrize("message_type", [Message, MessageChunk])
@pytest.mark.parametrize("name", [None, "", "report.txt"])
def test_base64_file_supports_optional_file_name(message_type, name):
message = message_type(
role="assistant",
content=[
ContentElement(type="file_base64", file_base64="aGVsbG8=", file_name=name)
],
)

chain = message.get_content_platform_message_chain()

assert len(chain) == 1
assert isinstance(chain[0], File)
assert chain[0].name == (name or "")
assert chain[0].base64 == "aGVsbG8="


@pytest.mark.parametrize("message_type", [Message, MessageChunk])
def test_base64_files_preserve_mixed_content_order_and_first_text_prefix(message_type):
message = message_type(
role="assistant",
content=[
ContentElement.from_file_base64("Zmlyc3Q=", "first.txt"),
ContentElement.from_image_base64("aW1hZ2U="),
ContentElement.from_text("first text"),
ContentElement.from_file_url(
"https://example.com/report.txt", "remote.txt"
),
ContentElement.from_file_base64("c2Vjb25k", "second.txt"),
ContentElement.from_text("second text"),
],
)
original = message.model_dump(mode="json")

chain = message.get_content_platform_message_chain(prefix_text="[bot] ")

assert [type(component) for component in chain] == [
File,
Image,
Plain,
File,
File,
Plain,
]
assert [chain[index].name for index in (0, 3, 4)] == [
"first.txt",
"remote.txt",
"second.txt",
]
assert chain[0].base64 == "Zmlyc3Q="
assert chain[3].url == "https://example.com/report.txt"
assert chain[4].base64 == "c2Vjb25k"
assert chain[2].text == "[bot] first text"
assert chain[5].text == "second text"
assert message.model_dump(mode="json") == original


@pytest.mark.parametrize("message_type", [Message, MessageChunk])
def test_file_only_content_inserts_prefix_before_file(message_type):
message = message_type(
role="assistant",
content=[ContentElement.from_file_base64("ZmlsZQ==", "report.txt")],
)

chain = message.get_content_platform_message_chain(prefix_text="[bot] ")

assert [type(component) for component in chain] == [Plain, File]
assert chain[0].text == "[bot] "
assert chain[1].base64 == "ZmlsZQ=="


@pytest.mark.parametrize("message_type", [Message, MessageChunk])
def test_base64_file_without_payload_is_skipped(message_type):
message = message_type(
role="assistant",
content=[ContentElement(type="file_base64", file_name="missing.txt")],
)

chain = message.get_content_platform_message_chain()

assert len(chain) == 0


@pytest.mark.parametrize("message_type", [Message, MessageChunk])
def test_host_attachments_follow_content_files_without_mutating_input(message_type):
attachments = MessageChain([File(name="host.txt", base64="aG9zdA==")])
message = message_type(
role="assistant",
content=[ContentElement.from_file_base64("Y29udGVudA==", "content.txt")],
attachments=attachments,
)
original_content = message.model_dump(mode="json")
original_attachments = attachments.model_dump()

for _ in range(2):
chain = message.get_content_platform_message_chain(prefix_text="[bot] ")
assert [type(component) for component in chain] == [Plain, File, File]
assert [chain[1].name, chain[2].name] == ["content.txt", "host.txt"]
assert [chain[1].base64, chain[2].base64] == ["Y29udGVudA==", "aG9zdA=="]

assert message.model_dump(mode="json") == original_content
assert message.attachments.model_dump() == original_attachments


@pytest.mark.parametrize(
"message_type,result_factory,payload_type,payload_attribute",
[
(MessageChunk, RunnerResult.message_delta, MessageDeltaPayload, "chunk"),
(Message, RunnerResult.message_completed, MessageCompletedPayload, "message"),
(Message, RunnerResult.run_completed, RunCompletedPayload, "message"),
],
)
def test_base64_file_survives_runner_result_json_roundtrip(
message_type, result_factory, payload_type, payload_attribute
):
data = b"\x00\xff\x80runner-file\n"
encoded = base64.b64encode(data).decode("ascii")
message = message_type(
role="assistant",
content=[ContentElement.from_file_base64(encoded, "report.bin")],
)
result = result_factory("run-1", message, sequence=3)

restored_result = RunnerResult.model_validate_json(result.model_dump_json())
restored_payload = payload_type.model_validate(restored_result.data)
restored_message = getattr(restored_payload, payload_attribute)
chain = restored_message.get_content_platform_message_chain()

assert restored_result.sequence == 3
assert len(chain) == 1
assert isinstance(chain[0], File)
assert chain[0].name == "report.bin"
assert base64.b64decode(chain[0].base64) == data
Loading