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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。

## [Unreleased]
- 网关同时收到同名附件上传时不再丢文件:`write_inbound` 先分配路径再写入,同一秒内的两个请求会算出同一个 `{unix_ts}_{display}` 名字,各自都看到候选路径不存在而写入同一路径,只有后写的存活;现改为在按工作区缓存的 `asyncio.Lock` 下分配并写入,后来者会看到前一个刚写下的文件并走 `{stem}-{n}{suffix}` 兜底(Fixes #1889)
- Telegram 通道可以限制允许的用户 ID(私聊和群聊都生效)。v1.0.2b7 的更新说明里已经写过这项,但合并时前端字段和 octop-gateway 1.0.1 依赖被丢掉了,这次补回
- 知识库文档上限设为 0(不限制)时,空库不再被当成已满,可以继续上传和新建文档
- 对话输入栏在专家配置了默认模型时显示该模型;与专家默认相同的选择不再写成会话粘性覆盖。专家的默认模型、知识库或连接器变更后,静默刷新会更新输入栏
- 云端协同:同一账号可以同时连入多条链接,后连上的不再把先连上的挤掉。接受方默认用观察到的地址作为显示名,改名只影响本侧。接受方卡片展示对端地址和来源 IP。对端专家可以上传附件,文件落到专家所在侧;两边都需要这个版本
- 知识库本地向量化改为单分块推理、复用模型并限制并发,避免大文档索引耗尽内存;systemd 服务启动/重启时为已有安装补充可覆盖的内存限制和重启限速,保留自定义启动配置
- 从技能市场把技能集导入为技能包时,某个技能没有安装包不再让整包失败;该技能会被跳过,其余技能照常导入。若全部技能都没有安装包,导入仍然失败
- 老会话在历史还在加载时再发一条消息,这条消息不再从列表里消失;长会话向上补更早的消息时,新消息也不会被错当成插入而留下空白
- 模型提供商配置里点「撤销授权」会清掉已保存的 API Key,卡片回到未授权。只改备注或流式用量时不会误删密钥
- 暗色主题下专家 / 技能 / 子智能体 Markdown 编辑器白底:Monaco 未跟随应用主题渲染为亮色 `vs`;编辑器现按应用明暗模式切换主题(工作区文件编辑器同样改为读取应用主题,而非仅系统偏好)(Fixes #1355)
- 知识库与对话附件支持 WPS Writer(`.wps`)及旧版 Word(`.doc`):用 `firecrawl-anydoc` 抽成 Markdown 入库 / 注入模型上下文(OLE 二进制按内容识别,不依赖本机安装 WPS)(#1345)

### 修复
- 云端协同对端专家的模型、连接器、知识库和对话模式会显示在本端输入框上方
Expand Down
29 changes: 27 additions & 2 deletions src/octop/infra/gateway/media/inbound_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import mimetypes
import re
import time
Expand Down Expand Up @@ -405,6 +406,29 @@ async def _unique_inbound_path(workspace: BackendWorkspace, stored_name: str) ->
raise OctopError(ErrorCode.INTERNAL_ERROR, f"cannot allocate unique path for {stored_name!r}")


# ``write_inbound`` allocates a free path and only then uploads to it. Both steps
# await, so two concurrent uploads whose timestamped names collide can observe the
# same free candidate and write to it — the second one silently wins and the first
# upload's bytes are lost. Serialise allocate+write per workspace so that every
# accepted upload keeps its own path and its own bytes.
_inbound_write_locks: dict[str, tuple[asyncio.AbstractEventLoop, asyncio.Lock]] = {}


def _inbound_write_lock(workspace: BackendWorkspace) -> asyncio.Lock:
"""Per-workspace lock guarding inbound path allocation plus upload.

The lock is rebuilt when the running event loop changes so a module-level
cache never hands a lock bound to a closed loop to a new one.
"""
loop = asyncio.get_running_loop()
key = str(workspace.workspace_dir)
entry = _inbound_write_locks.get(key)
if entry is None or entry[0] is not loop:
entry = (loop, asyncio.Lock())
_inbound_write_locks[key] = entry
return entry[1]


async def write_inbound(
workspace: BackendWorkspace,
data: bytes,
Expand All @@ -421,8 +445,9 @@ async def write_inbound(
if not Path(display_name).suffix:
display_name = f"{display_name}{inbound_extension(display_name, normalized_type)}"
stored_name = build_timestamped_inbound_name(display_name)
path = await _unique_inbound_path(workspace, stored_name)
await workspace.aupload_bytes(path, data)
async with _inbound_write_lock(workspace):
path = await _unique_inbound_path(workspace, stored_name)
await workspace.aupload_bytes(path, data)
return InboundFile(
path=path,
filename=display_name,
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/gateway/test_inbound_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import re
import tempfile

Expand Down Expand Up @@ -103,3 +104,27 @@ async def test_write_inbound_flat_docx_path() -> None:
)
assert stored.path.endswith(".docx")
assert stored.filename == "report.docx"


@pytest.mark.asyncio
async def test_concurrent_same_name_uploads_keep_their_own_bytes() -> None:
"""Uploads racing on one name must each keep their own path and bytes."""
with tempfile.TemporaryDirectory() as ws_dir:
workspace = _workspace(ws_dir)
payloads = (b"first report", b"second report")

stored = await asyncio.gather(
*(
write_inbound(
workspace,
payload,
filename="report.txt",
media_type="text/plain",
)
for payload in payloads
)
)

assert len({item.path for item in stored}) == 2
written = [await read_inbound_bytes(workspace, item.path) for item in stored]
assert sorted(written) == sorted(payloads)