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