fix: 岗位会商历史按用户隔离,并为 write-file 预览启用 AI 写作视图

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-25 17:32:56 +08:00
1 parent 8c3d6a02fc
commit 8ce3a62826
5 files changed
+67 -38

No files matched your search

@@ -366,10 +366,15 @@ export function ArtifactFileDetail({
!useWriteFileStreaming &&
!isSkillFile &&
!isMock;
// 「AI 写作」md 展示开关:只要是可编辑的 markdown 就提供「AI 写作」视图(在 源码/预览 旁多一个
// 切换项),用户可随时切过去体验划词「AI 改写」。默认仍是「预览」,避免编辑器在内容加载期间
// 挂载导致白屏 + 避免复杂 md(表格/公式/HTML)误入有损编辑。
const canUseAiEditor = canEditArtifact;
// 「AI 写作」md 展示开关:在 源码/预览 旁多一个切换项,支持划词「AI 改写」。
// 深度研究等 write-file 虚拟预览没有 artifacts PATCH,但仍应提供该视图(本地 draft);
// 流式写入中不挂载,避免编辑器在内容加载期间白屏。
const canUseAiEditor =
language === "markdown" &&
!isSkillFile &&
!isMock &&
!thread.isLoading &&
(canEditArtifact || useWriteFileStreaming);
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false);
@@ -393,7 +398,9 @@ export function ArtifactFileDetail({
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact ? draftContent : (content ?? ""));
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
@@ -443,17 +450,26 @@ export function ArtifactFileDetail({
setViewMode("code");
}, [documentRewriteActive]);
useEffect(() => {
if (viewMode !== "edit" || canUseAiEditor) return;
setViewMode(isSupportPreview ? "preview" : "code");
}, [canUseAiEditor, isSupportPreview, viewMode]);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits || documentRewriteActive) {
if (
(!canEditArtifact && !canUseAiEditor) ||
hasLocalEdits ||
documentRewriteActive
) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, documentRewriteActive, hasLocalEdits]);
}, [canEditArtifact, canUseAiEditor, content, documentRewriteActive, hasLocalEdits]);
useEffect(() => {
if (
@@ -1709,8 +1725,9 @@ export function ArtifactFileDetail({
)}
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
<MarkdownAiEditor
// 编辑用原始内容(与源码模式一致,不脱敏);保存仍走头部「保存 Markdown」按钮。
markdown={displayContent ?? ""}
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
markdown={(hasLocalEdits || canEditArtifact ? draftContent : content) ?? ""}
onChange={(value) => {
setDraftContent(value);
setHasLocalEdits(true);
@@ -99,7 +99,8 @@ class NodeResponse(BaseModel):
class SessionResponse(BaseModel):
id: str
# Older personal rows can already contain a mock task id; task_scoped is
# the authoritative opt-in marker for cross-user task sharing.
# the authoritative marker for task-bound rows. History is partitioned by
# user, so it only controls snapshot-id locking and list bucketing now.
external_task_id: str | None = None
task_scoped: bool = False
task_snapshot: dict[str, Any] = Field(default_factory=dict)
@@ -248,7 +249,7 @@ def _task_snapshot_for_scope(
*,
task_scoped: bool = True,
) -> dict[str, Any]:
"""Lock the snapshot id only for an explicit shared task scope."""
"""Lock the snapshot id only for an explicit task-bound scope."""
if not task_scoped or not external_task_id:
return dict(task_snapshot)
return {**task_snapshot, "id": external_task_id, "externalTaskId": external_task_id}
@@ -22,9 +22,9 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
class PositionRoundtableSessionRow(Base):
__tablename__ = "position_roundtable_sessions"
__table_args__ = (
# History is read as "all conversations for one task, newest first".
# `user_id` remains audit data only; it must not partition a shared
# task workspace.
# History is read as "one user's conversations for one task, newest
# first". `user_id` partitions visibility; a shared task workspace no
# longer exposes other users' collaboration records.
Index(
"ix_position_roundtable_sessions_task_updated",
"external_task_id",
@@ -33,8 +33,10 @@ class PositionRoundtableSessionRow(Base):
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# Creator audit field. `task_scoped`, not merely external_task_id, opts a
# session into sharing. This keeps historical sidebar/mock rows private.
# Owner field: every read/write path filters on it. `task_scoped` still
# marks rows created from an explicit route taskId (locking the snapshot
# task id and keeping them out of the unscoped personal list), but it no
# longer grants cross-user access.
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
task_scoped: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
@@ -135,8 +135,8 @@ class PositionRoundtableRepository:
@staticmethod
def _can_access(row: PositionRoundtableSessionRow, user_id: str) -> bool:
"""Task-bound sessions are shared; unbound legacy sessions stay private."""
return bool(row.task_scoped) or row.user_id == user_id
"""History is partitioned by creator; even task-bound rows stay private."""
return row.user_id == user_id
@staticmethod
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
@@ -344,11 +344,12 @@ class PositionRoundtableRepository:
async def list_sessions(
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
) -> list[dict[str, Any]]:
"""List shared task history or the caller's legacy personal history."""
"""List the caller's own task history or legacy personal history."""
if external_task_id:
stmt = select(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.external_task_id == external_task_id,
PositionRoundtableSessionRow.task_scoped.is_(True),
PositionRoundtableSessionRow.user_id == user_id,
)
else:
stmt = select(PositionRoundtableSessionRow).where(
@@ -526,40 +526,48 @@ async def test_conversation_snapshots_round_trip_and_history_delete_removes_node
assert saved_node is not None
assert saved_node["conversation_snapshot"] == node_conversation
# Position roundtable history is shared by taskId, so a collaborator can
# read and remove the same task conversation without being its creator.
assert await store.get_session(created["id"], "user-b") is not None
assert await store.delete_session(created["id"], "user-b")
# History is partitioned by creator: another user working the same taskId
# can neither read nor remove this conversation.
assert await store.get_session(created["id"], "user-b") is None
assert not await store.delete_session(created["id"], "user-b")
assert await store.get_session(created["id"], "user-a") is not None
assert await store.delete_session(created["id"], "user-a")
assert await store.get_session(created["id"], "user-a") is None
assert await store.list_nodes(created["id"], "user-a") is None
@pytest.mark.asyncio
async def test_task_scoped_history_is_shared_but_never_mixes_tasks(store):
shared = await store.create_session(
async def test_task_scoped_history_is_partitioned_by_user_but_never_mixes_tasks(store):
creator_session = await store.create_session(
"user-a",
external_task_id="external-task-100",
task_snapshot={"id": "external-task-100", "title": "共享任务"},
)
await store.create_session(
"user-b",
external_task_id="external-task-200",
task_snapshot={"id": "external-task-200", "title": "另一任务"},
)
visible_to_creator = await store.list_sessions(
"user-a", external_task_id="external-task-100"
)
assert [row["id"] for row in visible_to_creator] == [creator_session["id"]]
# The same taskId no longer exposes another user's rows: a collaborator
# starts from an empty list and only ever sees rows they created.
assert await store.list_sessions("user-b", external_task_id="external-task-100") == []
collaborator_session = await store.create_session(
"user-b",
external_task_id="external-task-100",
task_snapshot={"id": "external-task-100", "title": "协作者自己的会话"},
)
visible_to_collaborator = await store.list_sessions(
"user-b", external_task_id="external-task-100"
)
assert [row["id"] for row in visible_to_collaborator] == [shared["id"]]
assert [row["id"] for row in visible_to_collaborator] == [collaborator_session["id"]]
updated = await store.update_session(
shared["id"],
assert await store.update_session(
creator_session["id"],
"user-b",
task_snapshot={"id": "external-task-100", "title": "协作者已更新"},
)
assert updated is not None
assert updated["task_snapshot"]["title"] == "协作者已更新"
assert (await store.get_session(shared["id"], "user-a"))["task_snapshot"]["title"] == "协作者已更新"
) is None
assert (await store.get_session(creator_session["id"], "user-a"))["task_snapshot"]["title"] == "共享任务"
@pytest.mark.asyncio
@@ -577,7 +585,7 @@ async def test_unscoped_history_keeps_original_user_isolation(store):
# Old client records could already carry a mock task id. The new database
# marker keeps them personal after the migration; only a new explicit
# route taskId creates a shared row.
# route taskId creates a task-bound row.
legacy_with_mock_task_id = await store.create_session(
"user-a",
external_task_id="legacy-mock-task-id",