From 8ce3a62826806239104b17a7a8c7493a6e5697d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 25 Aug 2026 17:32:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B2=97=E4=BD=8D=E4=BC=9A=E5=95=86?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=8C=89=E7=94=A8=E6=88=B7=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=B8=BA=20write-file=20=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=90=AF=E7=94=A8=20AI=20=E5=86=99=E4=BD=9C=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../artifacts/artifact-file-detail.tsx | 35 ++++++++++---- .../gateway/routers/position_roundtable.py | 5 +- .../persistence/position_roundtable/model.py | 12 +++-- .../persistence/position_roundtable/sql.py | 7 +-- .../test_position_roundtable_sessions.py | 46 +++++++++++-------- 5 files changed, 67 insertions(+), 38 deletions(-) diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 0d952f12..47a287d0 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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 && ( { setDraftContent(value); setHasLocalEdits(true); diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index 5c1837fa..005fade1 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -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} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py index fd900dd0..cf82436c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py index 30891365..f3289cb0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py @@ -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( diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index 72d192e8..84082b4e 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -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",