Files
AdministratorandClaude Opus 4.8 acc29c8121 fix(middlewares): 标题模型推理不再泄漏到 UI 流 + open/ephemeral 会话避免无谓 DB 访问
- title: 标题模型用隔离 config(不继承父级 streaming callbacks),force_disable_thinking,<think> 块清洗,避免推理 token 进入实时流/运行日志
- uploads: 无文件的普通对话轮次直接跳过,不再解析线程归属(避免命中 DB)
- thread_data: ephemeral 会话用当前登录用户解析数据目录
- thread_paths: open-/__skill-review__ 等合成线程视为 local-only,直接用当前用户
- frontend: 新增 build:test 构建脚本

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:20:12 +08:00

155 lines
6.4 KiB
Python

"""Canonical owner resolution for a thread's filesystem paths.
Task-deeplink conversations (those whose ``threads_meta.metadata.taskId`` is
set — rwfx / 3qfx / xdfx) are **shared across all users**: any logged-in
account may open the same conversation (enforced by the ``taskId`` escape
branches in :mod:`deerflow.persistence.thread_meta.sql`). But the agent's
generated files (md / artifacts) and uploads live under a *per-user* directory::
{base_dir}/users/{user_id}/threads/{thread_id}/user-data/...
If that ``{user_id}`` were the **current login**, account B opening a shared
task conversation would resolve to an empty ``users/B/...`` directory and 404
on every file account A generated — which is exactly the "切换账号后生成的 md
文件找不到" bug.
To keep the *file* layer consistent with the *conversation-sharing* layer, all
path resolution for a task-shared thread must use one stable user bucket: the
thread's **creator** (``threads_meta.user_id``). That value is fixed at thread
creation and never changes, so every account resolves to the same physical
directory.
Personal (non-task) threads are unaffected: their creator is always the only /
current user, so :func:`resolve_path_user_id` returns exactly what
``get_effective_user_id`` would have. On any failure (no DB, missing row,
memory-mode store) it falls back to the current login, so behaviour degrades
safely to the pre-fix semantics.
"""
from __future__ import annotations
import logging
import threading
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
# thread_id -> creator user_id, populated only for task-shared threads. The
# (taskId, creator) pair is immutable for a thread, so positive results are
# cached forever; the fallback (current login) is never cached because a row may
# appear after the first lookup.
_owner_cache: dict[str, str] = {}
_owner_cache_lock = threading.Lock()
_OWNER_CACHE_MAX = 4096
_LOCAL_ONLY_THREAD_PREFIXES = ("open-", "__skill-review__")
def _is_local_only_thread(thread_id: str | None) -> bool:
"""Return True for synthetic threads that are never persisted in threads_meta."""
return bool(thread_id) and str(thread_id).startswith(_LOCAL_ONLY_THREAD_PREFIXES)
def _cache_get(thread_id: str) -> str | None:
with _owner_cache_lock:
return _owner_cache.get(thread_id)
def _cache_put(thread_id: str, owner: str) -> None:
with _owner_cache_lock:
# Crude bound: clear wholesale rather than track LRU order. The mapping
# is tiny and immutable, so a rare full clear just re-warms lazily.
if len(_owner_cache) >= _OWNER_CACHE_MAX:
_owner_cache.clear()
_owner_cache[thread_id] = owner
async def _fetch_task_owner(thread_id: str) -> str | None:
"""Return the creator user_id iff *thread_id* is a task-shared thread.
Returns ``None`` for personal threads, untracked threads, the memory-mode
store, or any DB error — callers then fall back to the current login.
"""
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.thread_meta import ThreadMetaRepository
session_factory = get_session_factory()
if session_factory is None:
return None
store = ThreadMetaRepository(session_factory)
# user_id=None bypasses the owner filter so we can read any thread's row.
row = await store.get(thread_id, user_id=None)
if not row:
return None
metadata = row.get("metadata") or {}
owner = row.get("user_id")
if owner and _is_creator_shared_thread(metadata):
return str(owner)
return None
def _is_creator_shared_thread(metadata: dict) -> bool:
"""True iff a thread's files should resolve to its **creator** (shared bucket).
Two kinds of threads are shared to one stable directory rather than the
current login:
- **Task-deeplink conversations** (``metadata.taskId``) — rwfx / 3qfx / xdfx,
explicitly shared across all users;
- **Roundtable 会商 system threads** (``metadata.thread_type == "roundtable"``)
— the Step 2 seats / coordinator and Step 3 report / summary / dashboard /
structure threads. Their sandbox artifacts (md / 流程图 / 报告) live under the
creator's directory, but a历史草稿 reopened later — or a taskId会商 draft opened
by **another account** — must still be able to view them. Resolving to the
creator keeps the *file* layer consistent with the *draft-sharing* layer.
Roundtable thread ids are random UUIDs surfaced only inside the owner's draft
(personal drafts are owner-scoped; taskId drafts are already shared), so this
does not let an unrelated account *discover* another user's 会商 files — it only
lets a caller that already holds the thread id read the creator's bucket.
"""
return bool(metadata.get("taskId")) or metadata.get("thread_type") == "roundtable"
def resolve_path_user_id(thread_id: str | None) -> str:
"""Canonical user bucket for *thread_id*'s files (sync).
For a task-shared thread this is the thread creator, so all accounts that
share the conversation read/write the same physical directory. For every
other case it is the current login (``get_effective_user_id``).
"""
if not thread_id or _is_local_only_thread(thread_id):
return get_effective_user_id()
cached = _cache_get(thread_id)
if cached is not None:
return cached
try:
from deerflow.persistence.engine import run_db_blocking
owner = run_db_blocking(_fetch_task_owner(thread_id))
except Exception:
logger.debug("Failed to resolve task-shared owner for thread %s", thread_id, exc_info=True)
owner = None
if owner:
_cache_put(thread_id, owner)
return owner
return get_effective_user_id()
async def aresolve_path_user_id(thread_id: str | None) -> str:
"""Async variant of :func:`resolve_path_user_id` for FastAPI handlers."""
if not thread_id or _is_local_only_thread(thread_id):
return get_effective_user_id()
cached = _cache_get(thread_id)
if cached is not None:
return cached
try:
owner = await _fetch_task_owner(thread_id)
except Exception:
logger.debug("Failed to resolve task-shared owner for thread %s", thread_id, exc_info=True)
owner = None
if owner:
_cache_put(thread_id, owner)
return owner
return get_effective_user_id()