fix: 岗位会商历史按用户隔离,并为 write-file 预览启用 AI 写作视图
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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}
|
||||
|
||||
+7
-5
@@ -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")
|
||||
|
||||
+4
-3
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user