diff --git a/backend/app/api/endpoints/cloud_projects.py b/backend/app/api/endpoints/cloud_projects.py index eb540d1118..3d71c8d1b3 100644 --- a/backend/app/api/endpoints/cloud_projects.py +++ b/backend/app/api/endpoints/cloud_projects.py @@ -94,6 +94,16 @@ def update_cloud_project( return _project_response(db, project, current_user) +@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +def archive_cloud_project( + project_id: int, + version: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + cloud_project_service.archive(db, project_id, current_user.id, version) + + @router.post( "/{project_id}/local-bindings", response_model=LocalBindingResponse, diff --git a/backend/app/api/endpoints/deliveries.py b/backend/app/api/endpoints/deliveries.py index 326ab534d2..a2965aaa65 100644 --- a/backend/app/api/endpoints/deliveries.py +++ b/backend/app/api/endpoints/deliveries.py @@ -307,6 +307,20 @@ def update_loop_item( return _loop_item_response(db, item, current_user) +@router.delete("/loop-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +def archive_loop_item( + item_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + if external_loop_item_provider.is_external_item(db, item_id): + raise HTTPException( + status.HTTP_409_CONFLICT, + "External provider tasks cannot be archived from Wegent", + ) + loop_item_service.delete(db, item_id, current_user.id) + + @router.post( "/loop-items/{item_id}/comments", response_model=LoopItemCommentResponse, diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index d853fefd7f..56711fbb7d 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -94,12 +94,53 @@ def validate_provider(self) -> "CloudProjectCreate": return self +class CloudProjectCardDisplay(BaseModel): + show_assignee: bool = True + show_priority: bool = True + show_tags: bool = True + show_date: bool = True + + +class CloudProjectBoardStatus(BaseModel): + id: str = Field(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$") + name: str = Field(min_length=1, max_length=40) + color: Literal["gray", "blue", "orange", "purple", "green", "red"] = "gray" + + +def default_board_statuses() -> list[CloudProjectBoardStatus]: + return [ + CloudProjectBoardStatus(id="inbox", name="收集箱", color="gray"), + CloudProjectBoardStatus(id="pending", name="待开始", color="blue"), + CloudProjectBoardStatus(id="in_progress", name="进行中", color="orange"), + CloudProjectBoardStatus(id="in_review", name="待确认", color="purple"), + CloudProjectBoardStatus(id="completed", name="已完成", color="green"), + ] + + +class CloudProjectBoardConfig(BaseModel): + group_by: Literal["status", "priority", "assignee", "tag"] = "status" + statuses: list[CloudProjectBoardStatus] = Field( + default_factory=default_board_statuses + ) + + @model_validator(mode="after") + def validate_statuses(self) -> "CloudProjectBoardConfig": + ids = [item.id for item in self.statuses] + if len(ids) != len(set(ids)): + raise ValueError("board status ids must be unique") + if len(self.statuses) > 50: + raise ValueError("board supports at most 50 statuses") + return self + + class CloudProjectUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=100) description: str | None = None tags: list[str] | None = Field(default=None, max_length=MAX_TAGS_PER_ITEM) provider_config: dict[str, object] | None = None visibility: ProjectVisibility | None = None + card_display: CloudProjectCardDisplay | None = None + board_config: CloudProjectBoardConfig | None = None version: int = Field(ge=1) @field_validator("tags", mode="before") @@ -136,6 +177,12 @@ class CloudProjectResponse(BaseModel): # provider kinds it can operate. task_provider: str = "local" provider_config: dict[str, object] = Field(default_factory=dict) + card_display: CloudProjectCardDisplay = Field( + default_factory=CloudProjectCardDisplay + ) + board_config: CloudProjectBoardConfig = Field( + default_factory=CloudProjectBoardConfig + ) visibility: ProjectVisibility = "private" created_by_user_id: int current_user_id: int = 0 @@ -161,6 +208,8 @@ def populate_tags(cls, value: object) -> object: "provider_config": mask_provider_config( metadata.get("provider_config", {}) ), + "card_display": metadata.get("card_display", {}), + "board_config": metadata.get("board_config", {}), "visibility": ( "public" if metadata.get("visibility") == "public" else "private" ), diff --git a/backend/app/schemas/delivery.py b/backend/app/schemas/delivery.py index cece310d3a..dd166eeb1b 100644 --- a/backend/app/schemas/delivery.py +++ b/backend/app/schemas/delivery.py @@ -24,9 +24,7 @@ class LoopItemCreate(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" - status: Literal["inbox", "pending", "in_progress", "in_review", "completed"] = ( - "inbox" - ) + status: str | None = Field(default=None, max_length=32) assignee_user_id: int | None = None priority: Literal["none", "low", "medium", "high", "urgent"] = "none" due_at: datetime | None = None @@ -40,9 +38,7 @@ class LoopItemUpdate(BaseModel): version: int = Field(ge=1) title: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = None - status: ( - Literal["inbox", "pending", "in_progress", "in_review", "completed"] | None - ) = None + status: str | None = Field(default=None, max_length=32) assignee_user_id: int | None = None priority: Literal["none", "low", "medium", "high", "urgent"] | None = None due_at: datetime | None = None @@ -58,7 +54,7 @@ class LoopItemReorder(BaseModel): """Manual order of the TODOs inside one board lane (parent + status).""" parent_id: str | None = Field(default=None, max_length=64) - status: Literal["inbox", "pending", "in_progress", "in_review", "completed"] + status: str = Field(max_length=32) item_ids: list[str] = Field(min_length=1, max_length=1000) @@ -73,6 +69,7 @@ class LoopItemResponse(BaseModel): description: str status: str assignee_user_id: int | None + assignee_name: str | None = None priority: str due_at: datetime | None sort_order: int diff --git a/backend/app/services/cloud_projects/service.py b/backend/app/services/cloud_projects/service.py index 6db36339b1..de82068703 100644 --- a/backend/app/services/cloud_projects/service.py +++ b/backend/app/services/cloud_projects/service.py @@ -15,6 +15,7 @@ from app.core.provider_credentials import store_provider_config from app.models.cloud_project import CloudProject, CloudProjectLocalBinding +from app.models.delivery import LoopItem, loop_datetime_is_unset from app.models.project import Project from app.models.resource_member import MemberStatus, ResourceMember from app.models.share_link import ResourceType @@ -26,6 +27,7 @@ CloudProjectMemberUpdate, CloudProjectUpdate, LocalBindingCreate, + default_board_statuses, normalize_provider_config, ) from app.services.cloud_projects.access import require_cloud_project_role @@ -74,6 +76,12 @@ def create( "provider_config": provider_config, "visibility": values.visibility, "tags": [], + "board_config": { + "group_by": "status", + "statuses": [ + item.model_dump() for item in default_board_statuses() + ], + }, }, ) db.add(project) @@ -143,11 +151,51 @@ def update( if ( "tags" in values.model_fields_set or "provider_config" in values.model_fields_set + or "card_display" in values.model_fields_set + or "board_config" in values.model_fields_set or "visibility" in values.model_fields_set ): metadata = dict(project.metadata_json or {}) if "tags" in values.model_fields_set and values.tags is not None: metadata["tags"] = updates.pop("tags") + if ( + "card_display" in values.model_fields_set + and values.card_display is not None + ): + metadata["card_display"] = values.card_display.model_dump() + updates.pop("card_display", None) + if ( + "board_config" in values.model_fields_set + and values.board_config is not None + ): + previous = metadata.get("board_config") + previous = previous if isinstance(previous, dict) else {} + previous_statuses = previous.get("statuses") + previous_statuses = ( + previous_statuses if isinstance(previous_statuses, list) else [] + ) + previous_ids = { + str(item.get("id")) + for item in previous_statuses + if isinstance(item, dict) and item.get("id") + } + next_ids = {item.id for item in values.board_config.statuses} + removed_ids = previous_ids - next_ids + if removed_ids: + db.query(LoopItem).filter( + LoopItem.cloud_project_id == project.id, + LoopItem.status.in_(removed_ids), + loop_datetime_is_unset(LoopItem.deleted_at), + ).update( + { + "status": "", + "completed_at": None, + "version": LoopItem.version + 1, + }, + synchronize_session=False, + ) + metadata["board_config"] = values.board_config.model_dump() + updates.pop("board_config", None) if ( "provider_config" in values.model_fields_set and values.provider_config is not None @@ -197,6 +245,31 @@ def update( db.refresh(project) return project + def archive(self, db: Session, project_id: int, user_id: int, version: int) -> None: + """Archive a project so it no longer appears in active project lists.""" + + project = require_cloud_project_role( + db, project_id, user_id, BaseRole.Maintainer + ).project + updated = ( + db.query(CloudProject) + .filter( + CloudProject.id == project.id, + CloudProject.version == version, + CloudProject.status == "active", + ) + .update( + { + "status": "archived", + "version": CloudProject.version + 1, + } + ) + ) + if updated != 1: + db.rollback() + raise HTTPException(status.HTTP_409_CONFLICT, "Cloud project changed") + db.commit() + def add_local_binding( self, db: Session, diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index 978ba00e87..31eff9a567 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -49,6 +49,22 @@ class LoopItemService: + @staticmethod + def _project_status_ids(project: CloudProject) -> list[str]: + metadata = ( + project.metadata_json if isinstance(project.metadata_json, dict) else {} + ) + board = metadata.get("board_config") + board = board if isinstance(board, dict) else {} + statuses = board.get("statuses") + if not isinstance(statuses, list): + return ["inbox", "pending", "in_progress", "in_review", "completed"] + return [ + str(item["id"]) + for item in statuses + if isinstance(item, dict) and item.get("id") + ] + def _require_internal_task_project( self, db: Session, @@ -104,6 +120,9 @@ def response_values( "can_view_detail": can_view_detail, "can_edit": can_edit, } + if item.assignee_user_id: + assignee = db.get(User, item.assignee_user_id) + values["assignee_name"] = assignee.user_name if assignee else None if not can_view_detail: values["description"] = "" return values @@ -252,6 +271,16 @@ def create( project.next_item_number += 1 payload = values.model_dump() tags = payload.pop("tags") + if payload.get("assignee_user_id") is None: + payload["assignee_user_id"] = user_id + configured_statuses = self._project_status_ids(project) + requested_status = payload.get("status") + if requested_status is None: + payload["status"] = configured_statuses[0] if configured_statuses else "" + elif requested_status not in configured_statuses: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, "Unknown board status" + ) item = LoopItem( id=f"{project.project_key}-{sequence}", cloud_project_id=project.id, @@ -520,6 +549,13 @@ def update( metadata["tags"] = updates.pop("tags") or [] updates["metadata_json"] = metadata next_status = updates.get("status") + if "status" in values.model_fields_set and next_status is not None: + project = db.get(CloudProject, item.cloud_project_id) + if project is None or next_status not in self._project_status_ids(project): + if next_status != "": + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, "Unknown board status" + ) if next_status and next_status != item.status: updates["completed_at"] = ( self._now() if next_status == "completed" else None @@ -543,12 +579,28 @@ def update( return item def delete(self, db: Session, item_id: str, user_id: int) -> LoopItem: - """Soft delete a TODO; the row is kept for the recycle bin.""" + """Soft delete a TODO subtree; rows are kept for the recycle bin.""" item = self.get(db, item_id, user_id) self._require_item_access(db, item, user_id, edit=True) - item.deleted_at = self._now() - item.version += 1 + archived_at = self._now() + pending_parent_ids = [item.id] + archived_items = [item] + while pending_parent_ids: + children = ( + db.query(LoopItem) + .filter( + LoopItem.cloud_project_id == item.cloud_project_id, + LoopItem.parent_id.in_(pending_parent_ids), + loop_datetime_is_unset(LoopItem.deleted_at), + ) + .all() + ) + pending_parent_ids = [child.id for child in children] + archived_items.extend(children) + for archived_item in archived_items: + archived_item.deleted_at = archived_at + archived_item.version += 1 db.commit() db.refresh(item) return item @@ -894,7 +946,8 @@ def list_my_work(self, db: Session, user_id: int) -> list[dict[str, object]]: .filter( LoopItem.cloud_project_id.in_(project_by_id), loop_datetime_is_unset(LoopItem.deleted_at), - (LoopItem.assignee_user_id == user_id) + (LoopItem.created_by_user_id == user_id) + | (LoopItem.assignee_user_id == user_id) | LoopItem.id.in_(active_task_items) | LoopItem.id.in_(collaborator_items), ) diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py index 5c1838e351..7fadd6006d 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -14,7 +14,7 @@ from sqlalchemy.orm import Session from app.core.security import create_access_token -from app.models.delivery import CloudProject, Delivery, DeliveryAsset +from app.models.delivery import CloudProject, Delivery, DeliveryAsset, LoopItem from app.models.project import Project from app.models.user import User from app.services.cloud_files import cloud_file_service @@ -127,6 +127,105 @@ def test_cloud_project_tag_registry( assert cleared.json()["tags"] == [] +def test_cloud_project_card_display_is_shared_through_project_metadata( + test_client: TestClient, test_token: str +) -> None: + created = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={"project_key": "display", "name": "Shared card display"}, + ).json() + assert created["card_display"] == { + "show_assignee": True, + "show_priority": True, + "show_tags": True, + "show_date": True, + } + + updated = test_client.patch( + f"/api/v1/cloud-projects/{created['id']}", + headers=_auth(test_token), + json={ + "version": created["version"], + "card_display": { + **created["card_display"], + "show_assignee": False, + }, + }, + ) + assert updated.status_code == 200 + assert updated.json()["card_display"]["show_assignee"] is False + + listed = test_client.get("/api/v1/cloud-projects", headers=_auth(test_token)) + match = next(item for item in listed.json()["items"] if item["id"] == created["id"]) + assert match["card_display"]["show_assignee"] is False + + +def test_cloud_project_board_config_supports_custom_statuses( + test_client: TestClient, test_db: Session, test_token: str +) -> None: + project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={"project_key": "board", "name": "Custom board"}, + ).json() + assert [item["id"] for item in project["board_config"]["statuses"]] == [ + "inbox", + "pending", + "in_progress", + "in_review", + "completed", + ] + + configured = test_client.patch( + f"/api/v1/cloud-projects/{project['id']}", + headers=_auth(test_token), + json={ + "version": project["version"], + "board_config": { + "group_by": "priority", + "statuses": [ + {"id": "idea", "name": "想法", "color": "gray"}, + {"id": "shipping", "name": "发布中", "color": "blue"}, + ], + }, + }, + ) + assert configured.status_code == 200 + project = configured.json() + assert project["board_config"]["group_by"] == "priority" + + task = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(test_token), + json={"title": "Ship it", "status": "shipping"}, + ) + assert task.status_code == 201 + + stored_task = test_db.query(LoopItem).filter(LoopItem.id == task.json()["id"]).one() + stored_task.assignee_user_id = None + test_db.commit() + my_work = test_client.get( + "/api/v1/cloud-work-items/my-work", headers=_auth(test_token) + ) + assert any(item["id"] == task.json()["id"] for item in my_work.json()["items"]) + + cleared = test_client.patch( + f"/api/v1/cloud-projects/{project['id']}", + headers=_auth(test_token), + json={ + "version": project["version"], + "board_config": {"group_by": "assignee", "statuses": []}, + }, + ) + assert cleared.status_code == 200 + test_db.expire_all() + refreshed = test_client.get( + f"/api/v1/loop-items/{task.json()['id']}", headers=_auth(test_token) + ) + assert refreshed.json()["status"] == "" + + def test_cloud_project_generates_key_when_omitted( test_client: TestClient, test_token: str ) -> None: @@ -753,6 +852,7 @@ def test_todo_lifecycle_and_multiple_local_tasks( ) assert created.status_code == 201 item = created.json() + assert item["assignee_name"] == test_user.user_name assert item["id"] == "CHAIN-1" assert item["cloud_project_id"] == project["id"] assert item["status"] == "inbox" @@ -856,6 +956,7 @@ def test_loop_item_tags_roundtrip( ) assert listed.status_code == 200 assert listed.json()["items"][0]["tags"] == ["产品需求", "研发"] + assert listed.json()["items"][0]["assignee_name"] == test_user.user_name updated = test_client.patch( f"/api/v1/loop-items/{item['id']}", diff --git a/executor/src/agents/claude_code.rs b/executor/src/agents/claude_code.rs index 0d6faca667..fc98a64e18 100644 --- a/executor/src/agents/claude_code.rs +++ b/executor/src/agents/claude_code.rs @@ -14,7 +14,8 @@ use serde_json::{json, Map, Value}; use crate::{ agents::{ backend_url::request_backend_url, interactive_mcp::build_interactive_form_answer_query, - skill_download::skill_download_concurrency, task_identity::task_identity_env, + runtime_capabilities::resolve_skill, skill_download::skill_download_concurrency, + task_identity::task_identity_env, }, attachments::{ append_text_to_vision_prompt, convert_openai_to_anthropic_content, create_multimodal_query, @@ -30,7 +31,7 @@ use crate::{ logging::{log_executor_event, push_error_fields, task_fields}, process::CommandSpec, protocol::ExecutionRequest, - services::skill_deployer::{build_skill_deployment_plan, SkillDeploymentOptions}, + services::skill_deployer::{build_skill_deployment_plan, SkillDeploymentOptions, SkillRef}, }; const FILE_EDIT_HOOK_COMMAND_ENV: &str = "WEGENT_FILE_EDIT_HOOK_COMMAND"; @@ -651,7 +652,7 @@ pub(super) async fn deploy_claude_task_skills(request: &ExecutionRequest, spec: let Some(bot_config) = primary_bot(request) else { return; }; - let Some(plan) = build_skill_deployment_plan( + let Some(mut plan) = build_skill_deployment_plan( bot_config, request, SkillDeploymentOptions { @@ -666,6 +667,37 @@ pub(super) async fn deploy_claude_task_skills(request: &ExecutionRequest, spec: return; }; + let resolver_client = reqwest::Client::new(); + for skill_name in plan.skills.clone() { + if plan.resolved_skill_map.contains_key(&skill_name) { + continue; + } + match resolve_skill(&resolver_client, &plan, &skill_name, None, &backend_url).await { + Ok(Some((skill_id, namespace))) => { + plan.resolved_skill_map.insert( + skill_name.clone(), + SkillRef { + skill_id, + namespace, + is_public: false, + content_hash: None, + }, + ); + } + Ok(None) => { + log_executor_event( + "claude task skill not found", + &[("skill", skill_name.clone())], + ); + } + Err(error) => { + let mut fields = vec![("skill", skill_name.clone())]; + push_error_fields(&mut fields, error); + log_executor_event("claude task skill resolution failed", &fields); + } + } + } + let provider = HttpPackageProvider::new(backend_url, plan.auth_token.clone()); stream::iter(plan.skills.iter().cloned()) .map(|skill_name| { diff --git a/executor/src/agents/runtime_capabilities.rs b/executor/src/agents/runtime_capabilities.rs index 8ffb2355f8..b4e774b373 100644 --- a/executor/src/agents/runtime_capabilities.rs +++ b/executor/src/agents/runtime_capabilities.rs @@ -983,7 +983,7 @@ fn normalize_etag_hash(value: &str) -> String { value.trim().trim_matches('"').to_owned() } -async fn resolve_skill( +pub(super) async fn resolve_skill( client: &reqwest::Client, plan: &SkillDeploymentPlan, skill_name: &str, diff --git a/executor/src/local/app_ipc.rs b/executor/src/local/app_ipc.rs index ea92f40afb..3efc9a2de9 100644 --- a/executor/src/local/app_ipc.rs +++ b/executor/src/local/app_ipc.rs @@ -712,6 +712,17 @@ async fn handle_task_runtime_request(method: &str, params: Value) -> Result { + let project_id = required_task_string(¶ms, "project_id")?; + let version = params + .get("version") + .and_then(Value::as_i64) + .ok_or_else(|| AppIpcError::new("bad_request", "version is required"))?; + runtime + .archive_project(project_id, version) + .map_err(task_runtime_error)?; + Ok(json!({})) + } "external_projects.configure" => { let project = task_input::(¶ms, "project")?; serialize_task_value( @@ -757,10 +768,11 @@ async fn handle_task_runtime_request(method: &str, params: Value) -> Result Result { + let project_id = required_task_string(¶ms, "project_id")?; + let name = required_task_string(¶ms, "name")?; + let view_type = required_task_string(¶ms, "view_type")?; + serialize_task_value( + runtime + .aitable_create_view(project_id, name, view_type) + .await + .map_err(task_runtime_error)?, + ) + } "external_todos.list" => { let project = task_input::(¶ms, "project")?; serialize_task_value( @@ -987,6 +1010,15 @@ async fn handle_task_runtime_request(method: &str, params: Value) -> Result { + let project_id = required_task_string(¶ms, "project_id")?; + let task_id = required_task_string(¶ms, "task_id")?; + runtime + .archive_task(project_id, task_id) + .await + .map_err(task_runtime_error)?; + Ok(json!({})) + } "todos.comment" => { let project_id = required_task_string(¶ms, "project_id")?; let task_id = required_task_string(¶ms, "task_id")?; diff --git a/executor/src/task_runtime/aitable_provider.rs b/executor/src/task_runtime/aitable_provider.rs index 1f089d05f2..8ca5f7474e 100644 --- a/executor/src/task_runtime/aitable_provider.rs +++ b/executor/src/task_runtime/aitable_provider.rs @@ -11,6 +11,7 @@ use serde_json::{json, Map, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::process::Stdio; use tokio::process::Command; use super::{LoopItem, TaskProviderKind, TaskRuntimeError}; @@ -31,6 +32,13 @@ struct AITableConfig { status_mapping: Map, } +#[derive(Debug, PartialEq, Eq)] +struct ViewQueryConfig { + filters: Option, + sort: Option, + field_ids: Option, +} + impl AITableProvider { pub(crate) fn new(database_path: PathBuf) -> Result { let executor_home = database_path.parent().unwrap_or_else(|| Path::new(".")); @@ -46,8 +54,22 @@ impl AITableProvider { } pub(crate) async fn auth_login(&self) -> Result { - self.run(&["auth", "login"]).await?; - self.auth_status().await + let mut child = self + .command(&["auth", "login", "--force"])? + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| { + TaskRuntimeError::ProviderRequest(format!( + "DWS login is unavailable at {}: {error}", + self.dws_binary.display() + )) + })?; + tokio::spawn(async move { + let _ = child.wait().await; + }); + Ok(json!({"started": true})) } pub(crate) async fn auth_logout(&self) -> Result<(), TaskRuntimeError> { @@ -82,6 +104,17 @@ impl AITableProvider { &config.table_id, ]) .await?; + let views = self + .run(&[ + "aitable", + "view", + "get", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + ]) + .await?; let tables = list_from(&table, &["tables", "sheets", "items", "data"]); let active_table = tables .iter() @@ -102,15 +135,47 @@ impl AITableProvider { .iter() .map(normalize_field) .collect::>(), + "views": list_from(&views, &["views", "items", "data", "results"]), })) } + pub(crate) async fn create_view( + &self, + project: &LoopItem, + name: &str, + view_type: &str, + ) -> Result { + if name.trim().is_empty() { + return Err(invalid("view name must not be empty")); + } + if !matches!(view_type, "grid" | "kanban") { + return Err(invalid("view type must be grid or kanban")); + } + let config = self.config(project)?; + self.run(&[ + "aitable", + "view", + "create", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--name", + name, + "--view-type", + view_type, + ]) + .await + .map(unwrap) + } + pub(crate) async fn list_records( &self, project: &LoopItem, query: Option<&str>, limit: i64, cursor: Option<&str>, + view_id: Option<&str>, ) -> Result { let config = self.config(project)?; let page_limit = limit.clamp(1, 100) as usize; @@ -132,6 +197,38 @@ impl AITableProvider { if let Some(cursor) = cursor.filter(|value| !value.trim().is_empty()) { args.extend(["--cursor", cursor]); } + let view = match view_id.filter(|value| !value.trim().is_empty()) { + Some(view_id) => Some( + self.run(&[ + "aitable", + "view", + "get", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--view-ids", + view_id, + ]) + .await?, + ), + None => None, + }; + let selected_view = view.as_ref().and_then(|response| { + list_from(response, &["views", "items", "data", "results"]) + .into_iter() + .next() + }); + let view_query = view_query_config(selected_view.as_ref())?; + if let Some(filters) = view_query.filters.as_deref() { + args.extend(["--filters", filters]); + } + if let Some(sort) = view_query.sort.as_deref() { + args.extend(["--sort", sort]); + } + if let Some(field_ids) = view_query.field_ids.as_deref() { + args.extend(["--field-ids", field_ids]); + } let response = self.run(&args).await?; let items = list_from(&response, &["records", "items", "data", "results"]) .iter() @@ -330,7 +427,7 @@ impl AITableProvider { &config.base_id, "--table-id", &config.table_id, - "--field-ids", + "--field-id", field_id, "--yes", ]) @@ -377,29 +474,117 @@ impl AITableProvider { }) } + async fn board_config( + &self, + project: &LoopItem, + ) -> Result<(AITableConfig, Vec), TaskRuntimeError> { + let mut config = self.config(project)?; + let response = self + .run(&[ + "aitable", + "field", + "get", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + ]) + .await?; + let fields = list_from(&response, &["fields", "items", "data", "results"]) + .iter() + .map(normalize_field) + .collect::>(); + infer_board_mapping(&mut config.mapping, &fields); + Ok((config, fields)) + } + + async fn enrich_user_cells(&self, fields: &[Value], records: &mut [Value]) { + let user_fields = fields + .iter() + .filter(|field| field.get("type").and_then(Value::as_str) == Some("user")) + .filter_map(|field| field.get("id").and_then(Value::as_str)) + .collect::>(); + let mut names = HashMap::new(); + let mut user_ids = Vec::new(); + for record in records.iter() { + for field_id in &user_fields { + let Some(users) = record + .get("cells") + .and_then(|cells| cells.get(*field_id)) + .and_then(Value::as_array) + else { + continue; + }; + for user_id in users.iter().filter_map(|user| { + user.get("userId") + .or_else(|| user.get("user_id")) + .and_then(Value::as_str) + }) { + if !user_ids.iter().any(|candidate| candidate == user_id) { + user_ids.push(user_id.to_owned()); + } + } + } + } + for user_id in user_ids.into_iter().take(30) { + let Ok(response) = self + .run(&["contact", "user", "search", "--query", &user_id]) + .await + else { + continue; + }; + let user = list_from(&response, &["result", "users", "items", "data"]) + .into_iter() + .find(|user| { + user.get("userId") + .or_else(|| user.get("user_id")) + .and_then(Value::as_str) + == Some(user_id.as_str()) + }); + if let Some(name) = user.as_ref().and_then(|user| { + user.get("name") + .or_else(|| user.get("nick")) + .and_then(Value::as_str) + }) { + names.insert(user_id, name.to_owned()); + } + } + for record in records { + for field_id in &user_fields { + let Some(users) = record + .get_mut("cells") + .and_then(|cells| cells.get_mut(*field_id)) + .and_then(Value::as_array_mut) + else { + continue; + }; + for user in users { + let Some(object) = user.as_object_mut() else { + continue; + }; + let user_id = object + .get("userId") + .or_else(|| object.get("user_id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + if let Some(user_id) = user_id { + object.insert( + "name".to_owned(), + json!(names.get(&user_id).cloned().unwrap_or(user_id)), + ); + } + } + } + } + } + async fn run(&self, args: &[&str]) -> Result { - std::fs::create_dir_all(&self.dws_config_dir) - .map_err(|error| TaskRuntimeError::ProviderRequest(error.to_string()))?; - let output = Command::new(&self.dws_binary) - .args(args) - .args(["--format", "json"]) - // DWS 1.0.32 keeps OAuth credentials below the user home even when - // DWS_CONFIG_DIR is set. Override both so Wework never consumes a - // developer's global DWS session. - .env("HOME", &self.dws_home) - .env("USERPROFILE", &self.dws_home) - .env("DWS_CONFIG_DIR", &self.dws_config_dir) - // Wework owns this isolated DWS home. File-backed DEKs avoid - // repeated macOS Keychain prompts when a stale `dek` item exists. - .env("DWS_DISABLE_KEYCHAIN", "1") - .output() - .await - .map_err(|error| { - TaskRuntimeError::ProviderRequest(format!( - "DWS is unavailable at {}: {error}", - self.dws_binary.display() - )) - })?; + let output = self.command(args)?.output().await.map_err(|error| { + TaskRuntimeError::ProviderRequest(format!( + "DWS is unavailable at {}: {error}", + self.dws_binary.display() + )) + })?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let value = serde_json::from_str::(stdout.trim()) @@ -418,46 +603,36 @@ impl AITableProvider { Ok(value) } + fn command(&self, args: &[&str]) -> Result { + std::fs::create_dir_all(&self.dws_config_dir) + .map_err(|error| TaskRuntimeError::ProviderRequest(error.to_string()))?; + let mut command = Command::new(&self.dws_binary); + command + .args(args) + .args(["--format", "json"]) + // DWS 1.0.32 keeps OAuth credentials below the user home even when + // DWS_CONFIG_DIR is set. Override both so Wework never consumes a + // developer's global DWS session. + .env("HOME", &self.dws_home) + .env("USERPROFILE", &self.dws_home) + .env("DWS_CONFIG_DIR", &self.dws_config_dir) + // Wework owns this isolated DWS home. File-backed DEKs avoid + // repeated macOS Keychain prompts when a stale `dek` item exists. + .env("DWS_DISABLE_KEYCHAIN", "1"); + Ok(command) + } + /// Project records onto LoopItems using the optional board mapping. pub(crate) async fn list_board( &self, project: &LoopItem, ) -> Result, TaskRuntimeError> { - let mut config = self.config(project)?; - if mapping_get(&config.mapping, "parent_field_id").is_none() { - let fields = self - .run(&[ - "aitable", - "field", - "get", - "--base-id", - &config.base_id, - "--table-id", - &config.table_id, - ]) - .await?; - let parent_field = list_from(&fields, &["fields", "items", "data", "results"]) - .iter() - .map(normalize_field) - .filter(|field| field.get("name").and_then(Value::as_str) == Some("父记录")) - .min_by_key(|field| { - (field.get("type").and_then(Value::as_str) != Some("text")) as u8 - }); - if let Some(field_id) = parent_field - .as_ref() - .and_then(|field| field.get("id")) - .and_then(Value::as_str) - { - config - .mapping - .insert("parent_field_id".to_owned(), json!(field_id)); - } - } + let (config, fields) = self.board_config(project).await?; let mut records = Vec::new(); let mut cursor: Option = None; for _ in 0..50 { let page = self - .list_records(project, None, 100, cursor.as_deref()) + .list_records(project, None, 100, cursor.as_deref(), None) .await?; if let Some(items) = page.get("items").and_then(Value::as_array) { records.extend(items.iter().cloned()); @@ -470,6 +645,7 @@ impl AITableProvider { break; } } + self.enrich_user_cells(&fields, &mut records).await; let title_records = records .iter() .filter_map(|candidate| { @@ -503,7 +679,7 @@ impl AITableProvider { project: &LoopItem, input: TaskCreate, ) -> Result { - let config = self.config(project)?; + let (config, _) = self.board_config(project).await?; let mut cells = Map::new(); insert_mapped( &mut cells, @@ -545,7 +721,7 @@ impl AITableProvider { task_id: &str, input: TaskUpdate, ) -> Result { - let config = self.config(project)?; + let (config, _) = self.board_config(project).await?; let record_id = task_id .rsplit(':') .next() @@ -589,6 +765,47 @@ impl AITableProvider { } } +fn infer_board_mapping(mapping: &mut Map, fields: &[Value]) { + let candidates = [ + ("title_field_id", &["标题", "任务名称", "任务", "名称"][..]), + ("description_field_id", &["描述", "备注", "详情"][..]), + ("status_field_id", &["状态", "进度"][..]), + ("parent_field_id", &["父记录", "父任务"][..]), + ("priority_field_id", &["优先级"][..]), + ("assignee_field_id", &["负责人", "执行人"][..]), + ( + "due_field_id", + &["截止时间", "计划结束日期", "截止日期"][..], + ), + ]; + for (key, names) in candidates { + if mapping_get(mapping, key).is_some() { + continue; + } + let field = fields.iter().find(|field| { + field + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| names.iter().any(|candidate| name.contains(candidate))) + }); + if let Some(field_id) = field + .and_then(|field| field.get("id")) + .and_then(Value::as_str) + { + mapping.insert(key.to_owned(), json!(field_id)); + } + } + if mapping_get(mapping, "title_field_id").is_none() { + if let Some(field_id) = fields + .first() + .and_then(|field| field.get("id")) + .and_then(Value::as_str) + { + mapping.insert("title_field_id".to_owned(), json!(field_id)); + } + } +} + fn resolve_dws_binary() -> PathBuf { if let Some(path) = std::env::var_os("DWS_BINARY_PATH") { return PathBuf::from(path); @@ -623,6 +840,42 @@ fn required(value: &Map, key: &str) -> Result) -> Result { + let filters = view + .and_then(|view| view.get("filter").or_else(|| view.get("filters"))) + .filter(|filters| { + filters + .get("operands") + .and_then(Value::as_array) + .is_some_and(|items| !items.is_empty()) + }) + .map(serde_json::to_string) + .transpose() + .map_err(|error| invalid(error.to_string()))?; + let sort = view + .and_then(|view| view.get("sort").or_else(|| view.get("sorts"))) + .filter(|sort| sort.as_array().is_some_and(|items| !items.is_empty())) + .map(serde_json::to_string) + .transpose() + .map_err(|error| invalid(error.to_string()))?; + let field_ids = view + .and_then(|view| view.get("columns").or_else(|| view.get("fieldIds"))) + .and_then(Value::as_array) + .map(|columns| { + columns + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(",") + }) + .filter(|columns| !columns.is_empty()); + Ok(ViewQueryConfig { + filters, + sort, + field_ids, + }) +} + fn unwrap(response: Value) -> Value { if let Value::Object(map) = &response { for key in ["data", "result"] { @@ -883,6 +1136,13 @@ fn board_loop_item( } }; let source_status = cell_text(record, mapping_get(mapping, "status_field_id")); + let assignee_label = cell_text(record, mapping_get(mapping, "assignee_field_id")); + let due_at = normalized_due_at(&cell_text(record, mapping_get(mapping, "due_field_id"))); + let source_cells = record + .get("cells") + .or_else(|| record.get("fields")) + .cloned() + .unwrap_or_else(|| json!({})); let status = mapped_status(config, &source_status); let priority = map_option( &cell_text(record, mapping_get(mapping, "priority_field_id")), @@ -913,6 +1173,9 @@ fn board_loop_item( "task_provider": TaskProviderKind::DingtalkAitable, "record_id": record_id, "source_status": source_status, + "assignee_label": assignee_label, + "due_at": due_at, + "source_cells": source_cells, }), version: 1, created_at: now.clone(), @@ -921,6 +1184,24 @@ fn board_loop_item( } } +fn normalized_due_at(value: &str) -> String { + let value = value.trim(); + if value.is_empty() { + return String::new(); + } + if let Ok(timestamp) = value.parse::() { + let datetime = if timestamp.abs() >= 10_000_000_000 { + chrono::DateTime::from_timestamp_millis(timestamp) + } else { + chrono::DateTime::from_timestamp(timestamp, 0) + }; + if let Some(datetime) = datetime { + return datetime.to_rfc3339(); + } + } + value.to_owned() +} + fn parent_record_id( record: &Value, mapping: &Map, diff --git a/executor/src/task_runtime/aitable_provider_tests.rs b/executor/src/task_runtime/aitable_provider_tests.rs index 626580da1c..80b99df916 100644 --- a/executor/src/task_runtime/aitable_provider_tests.rs +++ b/executor/src/task_runtime/aitable_provider_tests.rs @@ -15,6 +15,32 @@ fn reads_dws_result_arrays_and_normalizes_records() { ); } +#[test] +fn translates_dingtalk_view_configuration_into_record_query_arguments() { + let view = json!({ + "columns": ["fld_title", "fld_owner"], + "filter": { + "operator": "and", + "operands": [{"operator": "eq", "operands": ["fld_owner", "user-1"]}] + }, + "sort": [{"fieldId": "fld_title", "direction": "asc"}] + }); + + let config = view_query_config(Some(&view)).unwrap(); + + assert_eq!(config.field_ids.as_deref(), Some("fld_title,fld_owner")); + assert_eq!( + config.filters.as_deref(), + Some( + r#"{"operands":[{"operands":["fld_owner","user-1"],"operator":"eq"}],"operator":"and"}"# + ) + ); + assert_eq!( + config.sort.as_deref(), + Some(r#"[{"direction":"asc","fieldId":"fld_title"}]"#) + ); +} + #[test] fn accepts_dws_success_envelopes_with_empty_error_objects() { assert!(!dws_response_failed(&json!({ @@ -42,6 +68,24 @@ fn maps_localized_board_options() { assert_eq!(map_option("紧急", PRIORITY_OPTIONS, "none"), "urgent"); } +#[test] +fn infers_board_fields_from_dingtalk_schema() { + let fields = vec![ + json!({"id": "fld-title", "name": "任务标题", "type": "text"}), + json!({"id": "fld-status", "name": "天河状态", "type": "text"}), + json!({"id": "fld-owner", "name": "负责人", "type": "user"}), + json!({"id": "fld-due", "name": "计划结束日期", "type": "date"}), + ]; + let mut mapping = Map::new(); + + infer_board_mapping(&mut mapping, &fields); + + assert_eq!(mapping["title_field_id"], json!("fld-title")); + assert_eq!(mapping["status_field_id"], json!("fld-status")); + assert_eq!(mapping["assignee_field_id"], json!("fld-owner")); + assert_eq!(mapping["due_field_id"], json!("fld-due")); +} + #[test] fn uses_explicit_status_mapping_and_reverses_it_for_writes() { let config = AITableConfig { @@ -93,3 +137,13 @@ fn resolves_parent_tasks_from_link_ids_or_parent_titles() { Some("linked-parent".to_owned()) ); } + +#[test] +fn normalizes_dingtalk_due_dates_for_board_cards() { + assert_eq!( + normalized_due_at("1785456000000"), + "2026-07-31T00:00:00+00:00" + ); + assert_eq!(normalized_due_at("2026-08-01"), "2026-08-01"); + assert_eq!(normalized_due_at(""), ""); +} diff --git a/executor/src/task_runtime/mcp.rs b/executor/src/task_runtime/mcp.rs index f0be6d8202..1b5749b365 100644 --- a/executor/src/task_runtime/mcp.rs +++ b/executor/src/task_runtime/mcp.rs @@ -118,7 +118,7 @@ async fn handle_request(runtime: &TaskRuntime, request: &Value) -> Option ) }), "ping" => id.map(|id| result_response(id, json!({}))), - "tools/list" => id.map(|id| result_response(id, json!({"tools": tools()}))), + "tools/list" => id.map(|id| result_response(id, json!({"tools": visible_tools(runtime)}))), "tools/call" => { let id = id?; let name = request.pointer("/params/name").and_then(Value::as_str)?; @@ -142,6 +142,12 @@ async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value .and_then(Value::as_str) .map(ToOwned::to_owned) .or_else(|| default_project_id.clone()); + if requested_project_id.as_deref().is_some_and(|project_id| { + is_dingtalk_aitable_project(runtime, project_id) && is_task_provider_tool(name) + }) { + let project_id = requested_project_id.as_deref().unwrap_or_default(); + return text_result(dingtalk_route_redirect(runtime, project_id), false); + } let is_locally_routed = requested_project_id .as_deref() .is_some_and(|project_id| is_locally_routed_project(runtime, project_id, name)); @@ -387,7 +393,13 @@ async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value .unwrap_or(100); match project_id { Ok(project_id) => runtime - .aitable_list_records(project_id, query.as_deref(), limit, cursor.as_deref()) + .aitable_list_records( + project_id, + query.as_deref(), + limit, + cursor.as_deref(), + None, + ) .await .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), Err(error) => Err(error), @@ -490,19 +502,55 @@ async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value } } -fn is_locally_routed_project(runtime: &TaskRuntime, project_id: &str, tool_name: &str) -> bool { +fn is_locally_routed_project(runtime: &TaskRuntime, project_id: &str, _tool_name: &str) -> bool { + runtime + .list_projects() + .unwrap_or_default() + .into_iter() + .find(|project| project.id == project_id) + .is_some_and(|project| project.metadata["project_store"].as_str() == Some("local")) +} + +fn is_dingtalk_aitable_project(runtime: &TaskRuntime, project_id: &str) -> bool { runtime .list_projects() .unwrap_or_default() .into_iter() .find(|project| project.id == project_id) .is_some_and(|project| { - project.metadata["project_store"].as_str() == Some("local") - || (project.metadata["task_provider"].as_str() == Some("dingtalk_aitable") - && is_task_provider_tool(tool_name)) + project.metadata["task_provider"].as_str() == Some("dingtalk_aitable") }) } +fn dingtalk_route_redirect(runtime: &TaskRuntime, project_id: &str) -> String { + let binding = runtime + .list_projects() + .unwrap_or_default() + .into_iter() + .find(|project| project.id == project_id) + .map(|project| { + json!({ + "route": "dws", + "product": "aitable", + "space_id": project.id, + "space_name": project.name, + "base_id": project.metadata["provider_config"]["base_id"], + "table_id": project.metadata["provider_config"]["table_id"], + "view_id": project.metadata["provider_config"].get("view_id").cloned(), + "instruction": "Use these bound IDs directly. Do not search or list DingTalk bases, and do not switch resources if access fails. Use list_spaces only when the user explicitly names another Wework project." + }) + }) + .unwrap_or_else(|| { + json!({ + "route": "dws", + "product": "aitable", + "space_id": project_id, + "instruction": "Resolve the Wework project binding before using dws. Do not guess or search for a replacement table." + }) + }); + binding.to_string() +} + fn is_task_provider_tool(name: &str) -> bool { matches!( name, @@ -1243,6 +1291,27 @@ fn tools() -> Vec { ] } +fn visible_tools(runtime: &TaskRuntime) -> Vec { + let bound_project_id = env::var("WEWORK_SPACE_ID").ok(); + tools_for_bound_project(runtime, bound_project_id.as_deref()) +} + +fn tools_for_bound_project(runtime: &TaskRuntime, project_id: Option<&str>) -> Vec { + let dingtalk_bound = + project_id.is_some_and(|project_id| is_dingtalk_aitable_project(runtime, project_id)); + if !dingtalk_bound { + return tools(); + } + tools() + .into_iter() + .filter(|tool| { + tool["name"] + .as_str() + .map_or(true, |name| !is_task_provider_tool(name)) + }) + .collect() +} + fn tool(name: &str, description: &str, input_schema: Value) -> Value { json!({"name": name, "description": description, "inputSchema": input_schema}) } @@ -1471,8 +1540,8 @@ mod tests { )); } - #[test] - fn routes_backend_dingtalk_table_operations_to_the_local_provider() { + #[tokio::test] + async fn hides_wework_task_tools_for_a_bound_dingtalk_table() { let directory = tempfile::tempdir().unwrap(); let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); store @@ -1493,23 +1562,46 @@ mod tests { .unwrap(); let runtime = TaskRuntime::new(store).unwrap(); + let names = tools_for_bound_project(&runtime, Some("cloud-aitable")) + .into_iter() + .filter_map(|tool| tool["name"].as_str().map(ToOwned::to_owned)) + .collect::>(); + for tool_name in [ "list_board_items", "search_board_items", "describe_space_table", "list_table_records", + "create_table_record", + "update_table_record", ] { - assert!(is_locally_routed_project( - &runtime, - "cloud-aitable", - tool_name - )); + assert!(!names.iter().any(|name| name == tool_name)); } - assert!(!is_locally_routed_project( + assert!(names.iter().any(|name| name == "list_space_files")); + assert!(names.iter().any(|name| name == "list_deliveries")); + + let redirect: Value = + serde_json::from_str(&dingtalk_route_redirect(&runtime, "cloud-aitable")).unwrap(); + assert_eq!(redirect["route"], "dws"); + assert_eq!(redirect["product"], "aitable"); + assert_eq!(redirect["base_id"], "base-1"); + assert_eq!(redirect["table_id"], "table-1"); + assert!(redirect["instruction"] + .as_str() + .unwrap() + .contains("Do not search or list DingTalk bases")); + + let stale_call = call_tool( &runtime, - "cloud-aitable", - "list_space_files" - )); + "list_board_items", + json!({"space_id": "cloud-aitable"}), + ) + .await; + assert_eq!(stale_call["isError"], false); + assert!(stale_call["content"][0]["text"] + .as_str() + .unwrap() + .contains("\"base_id\":\"base-1\"")); } #[test] diff --git a/executor/src/task_runtime/model.rs b/executor/src/task_runtime/model.rs index 020ea0d3ac..d8442fd6c9 100644 --- a/executor/src/task_runtime/model.rs +++ b/executor/src/task_runtime/model.rs @@ -61,6 +61,8 @@ pub struct ProjectUpdate { pub description: Option, pub tags: Option>, pub provider_config: Option, + pub board_config: Option, + pub card_display: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/executor/src/task_runtime/router.rs b/executor/src/task_runtime/router.rs index 0bf71e6753..4c356a6702 100644 --- a/executor/src/task_runtime/router.rs +++ b/executor/src/task_runtime/router.rs @@ -57,6 +57,10 @@ impl TaskRuntime { .map(mask_project) } + pub fn archive_project(&self, project_id: &str, version: i64) -> Result<(), TaskRuntimeError> { + self.local_store.archive_project(project_id, version) + } + pub fn configure_external_project( &self, project: ProjectDescriptor, @@ -215,10 +219,11 @@ impl TaskRuntime { query: Option<&str>, limit: i64, cursor: Option<&str>, + view_id: Option<&str>, ) -> Result { let project = self.aitable_project(project_id)?; self.aitable_provider - .list_records(&project, query, limit, cursor) + .list_records(&project, query, limit, cursor, view_id) .await } @@ -297,6 +302,18 @@ impl TaskRuntime { self.aitable_provider.delete_field(&project, field_id).await } + pub async fn aitable_create_view( + &self, + project_id: &str, + name: &str, + view_type: &str, + ) -> Result { + let project = self.aitable_project(project_id)?; + self.aitable_provider + .create_view(&project, name, view_type) + .await + } + fn aitable_project(&self, project_id: &str) -> Result { let project = self.local_store.get_project(project_id)?; if task_provider(&project)? != TaskProviderKind::DingtalkAitable { @@ -447,6 +464,20 @@ impl TaskRuntime { } } + pub async fn archive_task( + &self, + project_id: &str, + task_id: &str, + ) -> Result<(), TaskRuntimeError> { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Local => self.local_store.archive_task(project_id, task_id), + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + pub async fn add_comment( &self, project_id: &str, diff --git a/executor/src/task_runtime/store.rs b/executor/src/task_runtime/store.rs index aa44ebc7bb..375584e9bc 100644 --- a/executor/src/task_runtime/store.rs +++ b/executor/src/task_runtime/store.rs @@ -105,6 +105,16 @@ impl LocalTaskStore { "project_store": ProjectStoreKind::Local, "task_provider": input.task_provider, "provider_config": provider_config, + "board_config": { + "group_by": "status", + "statuses": [ + {"id": "inbox", "name": "收集箱", "color": "gray"}, + {"id": "pending", "name": "待开始", "color": "blue"}, + {"id": "in_progress", "name": "进行中", "color": "orange"}, + {"id": "in_review", "name": "待确认", "color": "purple"}, + {"id": "completed", "name": "已完成", "color": "green"} + ] + }, "tags": [], }); let connection = self.connection()?; @@ -284,6 +294,12 @@ impl LocalTaskStore { provider_config, )?; } + if let Some(board_config) = input.board_config { + metadata["board_config"] = board_config; + } + if let Some(card_display) = input.card_display { + metadata["card_display"] = card_display; + } let connection = self.connection()?; let updated = connection.execute( "UPDATE loop_items @@ -310,6 +326,22 @@ impl LocalTaskStore { self.get_project(project_id) } + pub fn archive_project(&self, project_id: &str, version: i64) -> Result<(), TaskRuntimeError> { + let connection = self.connection()?; + let archived_at = now(); + let updated = connection.execute( + "UPDATE loop_items + SET deleted_at = ?1, updated_at = ?1, version = version + 1 + WHERE id = ?2 AND resource_type = 'project' AND version = ?3 + AND deleted_at IS NULL", + params![archived_at, project_id, version], + )?; + if updated != 1 { + return Err(TaskRuntimeError::VersionConflict); + } + Ok(()) + } + pub fn list_tasks(&self, project_id: &str) -> Result, TaskRuntimeError> { let project = self.get_project(project_id)?; let provider = task_provider(&project)?; @@ -469,6 +501,32 @@ impl LocalTaskStore { self.get_item(task_id, "task") } + pub fn archive_task(&self, project_id: &str, task_id: &str) -> Result<(), TaskRuntimeError> { + self.get_task(project_id, task_id)?; + let connection = self.connection()?; + let archived_at = now(); + let updated = connection.execute( + "WITH RECURSIVE task_tree(id) AS ( + SELECT id FROM loop_items + WHERE id = ?1 AND resource_type = 'task' + AND cloud_project_id = ?2 AND deleted_at IS NULL + UNION ALL + SELECT child.id FROM loop_items child + JOIN task_tree parent ON child.parent_id = parent.id + WHERE child.resource_type = 'task' + AND child.cloud_project_id = ?2 AND child.deleted_at IS NULL + ) + UPDATE loop_items + SET deleted_at = ?3, updated_at = ?3, version = version + 1 + WHERE id IN (SELECT id FROM task_tree)", + params![task_id, project_id, archived_at], + )?; + if updated == 0 { + return Err(TaskRuntimeError::TaskNotFound); + } + Ok(()) + } + pub fn reorder_tasks( &self, project_id: &str, diff --git a/executor/tests/local_app_ipc_contract.rs b/executor/tests/local_app_ipc_contract.rs index 0c7bf058d8..f6d103cf7b 100644 --- a/executor/tests/local_app_ipc_contract.rs +++ b/executor/tests/local_app_ipc_contract.rs @@ -238,6 +238,31 @@ async fn app_ipc_manages_local_projects_and_nested_todos() { .await .unwrap_err(); assert_eq!(conflict.code, "version_conflict"); + + server + .dispatch( + "todos.archive", + json!({"project_id": project_id, "task_id": parent_id}), + ) + .await + .unwrap(); + let todos = server + .dispatch("todos.list", json!({"project_id": project_id})) + .await + .unwrap(); + assert!(todos.as_array().unwrap().is_empty()); + + let projects = server.dispatch("projects.list", json!({})).await.unwrap(); + let current_project = &projects.as_array().unwrap()[0]; + server + .dispatch( + "projects.archive", + json!({"project_id": project_id, "version": current_project["version"]}), + ) + .await + .unwrap(); + let projects = server.dispatch("projects.list", json!({})).await.unwrap(); + assert!(projects.as_array().unwrap().is_empty()); assert!(executor_home.path().join("data/tasks.sqlite").is_file()); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a7236ca2b..7b5322d979 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -639,6 +639,12 @@ importers: '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 + ag-grid-community: + specifier: ^36.0.2 + version: 36.0.2 + ag-grid-react: + specifier: ^36.0.2 + version: 36.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -749,8 +755,8 @@ importers: specifier: ^10.5.0 version: 10.5.0(postcss@8.5.15) dingtalk-workspace-cli: - specifier: 1.0.32 - version: 1.0.32 + specifier: 1.0.54 + version: 1.0.54 eslint: specifier: ^10.3.0 version: 10.4.1(jiti@1.21.7) @@ -4336,9 +4342,24 @@ packages: resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} engines: {node: '>=0.8'} + ag-charts-types@14.0.2: + resolution: {integrity: sha512-F7ZG0g8Y+iKhJi50AfZRwEyUM/TBsNyh2IoXB0JaDN97lnbemIK8GE5kF1eBtXtN4mcC+lPXK9oZUeVXwO9EWA==} + + ag-grid-community@36.0.2: + resolution: {integrity: sha512-TINZfuFvMY2nc3JfQHiUWT7dNIxI89ZxS5XkXIPi/rYICoNupRqpaM41KVzGPPfSkM0AwhuzTFxAiF08zEkV1Q==} + + ag-grid-react@36.0.2: + resolution: {integrity: sha512-yVPmqdhx1zp06FLyZmwmIxIO57w4ko+qN64MXgPlBMJVL0MNA5hULqAY/+SoB4cd0bBHqz8okuuAbHHpu5QoHQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + ag-psd@30.2.0: resolution: {integrity: sha512-tSAWfNzLl5brFqKEer7egxASZ1a0LwHnReM/D5VVpvrgdwdsa8OVfCQpysK7tGaOuE2tPEqp3mAuH7aIvPhcig==} + ag-stack@36.0.2: + resolution: {integrity: sha512-YuhQExQw5YsWK0wxrksRyYBAqOU0v08lJH5uxRsKx+49ko5vkDgnJuhX4yF995BBVdLY1LKlXukLEub+olKyuA==} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -5182,8 +5203,8 @@ packages: dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} - dingtalk-workspace-cli@1.0.32: - resolution: {integrity: sha512-GaddHutdGn269vUmRt7TsHtycO526piUrq5IUqV6BLkimUoAjp2bqt1yI1/dZYr95ebwGCNwQJp30BkS9E39ug==} + dingtalk-workspace-cli@1.0.54: + resolution: {integrity: sha512-R1gNPwc7yVgU5VKbyI7FaYNjh2L2+/yBo7cdqEdrBP35Xcnm0yOjJcRbk/EEq31Sk60feSBoQ3RtKVhxwduW+Q==} engines: {node: '>=16'} hasBin: true @@ -13044,11 +13065,27 @@ snapshots: adler-32@1.3.1: {} + ag-charts-types@14.0.2: {} + + ag-grid-community@36.0.2: + dependencies: + ag-charts-types: 14.0.2 + ag-stack: 36.0.2 + + ag-grid-react@36.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + ag-grid-community: 36.0.2 + prop-types: 15.8.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + ag-psd@30.2.0: dependencies: base64-js: 1.5.1 pako: 2.1.0 + ag-stack@36.0.2: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -13952,7 +13989,7 @@ snapshots: dingbat-to-unicode@1.0.1: {} - dingtalk-workspace-cli@1.0.32: {} + dingtalk-workspace-cli@1.0.54: {} direction@2.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6c31642116..6e5346a60c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,7 @@ packages: confirmModulesPurge: false allowBuilds: core-js: true - dingtalk-workspace-cli: true + dingtalk-workspace-cli: false esbuild: true msw: true protobufjs: true diff --git a/wework/package.json b/wework/package.json index 5111cc7072..22264d291a 100644 --- a/wework/package.json +++ b/wework/package.json @@ -92,6 +92,8 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", + "ag-grid-community": "^36.0.2", + "ag-grid-react": "^36.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "i18next": "^26.2.0", @@ -130,7 +132,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-istanbul": "^4.1.8", "autoprefixer": "^10.5.0", - "dingtalk-workspace-cli": "1.0.32", + "dingtalk-workspace-cli": "1.0.54", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/wework/scripts/prepare-dws-binary.mjs b/wework/scripts/prepare-dws-binary.mjs index 3348ea8fc4..3457c2ca62 100644 --- a/wework/scripts/prepare-dws-binary.mjs +++ b/wework/scripts/prepare-dws-binary.mjs @@ -1,12 +1,13 @@ // SPDX-FileCopyrightText: 2026 Weibo, Inc. // SPDX-License-Identifier: Apache-2.0 -import { chmod, copyFile, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises' +import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { arch, platform } from 'node:process' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { spawnSync } from 'node:child_process' +import JSZip from 'jszip' const require = createRequire(import.meta.url) const packageJson = require.resolve('dingtalk-workspace-cli/package.json') @@ -49,28 +50,31 @@ async function findBinary(directory) { return null } -function extractArchive(archive, destination) { - const isZip = archive.endsWith('.zip') - if (process.platform === 'win32') { - if (isZip) { - const result = spawnSync('tar', ['-xf', archive, '-C', destination], { stdio: 'inherit' }) - if (result.status !== 0) throw new Error(`Failed to extract ${archiveName}`) - return - } - const result = spawnSync('tar', ['-xzf', archive, '-C', destination], { stdio: 'inherit' }) - if (result.status !== 0) throw new Error(`Failed to extract ${archiveName}`) - return - } - - const command = isZip ? 'unzip' : 'tar' - const args = isZip ? ['-q', archive, '-d', destination] : ['-xzf', archive, '-C', destination] - const result = spawnSync(command, args, { stdio: 'inherit' }) - if (result.status !== 0) throw new Error(`Failed to extract ${archiveName}`) +async function extractZip(archive, destination) { + const zip = await JSZip.loadAsync(await readFile(archive)) + await Promise.all( + Object.values(zip.files).map(async entry => { + const output = join(destination, entry.name) + if (entry.dir) { + await mkdir(output, { recursive: true }) + return + } + await mkdir(dirname(output), { recursive: true }) + await writeFile(output, await entry.async('nodebuffer')) + }) + ) } try { const archive = join(packageRoot, 'assets', archiveName) - extractArchive(archive, temporaryDirectory) + if (archiveName.endsWith('.zip')) { + await extractZip(archive, temporaryDirectory) + } else { + const result = spawnSync('tar', ['-xzf', archive, '-C', temporaryDirectory], { + stdio: 'inherit', + }) + if (result.status !== 0) throw new Error(`Failed to extract ${archiveName}`) + } const source = await findBinary(temporaryDirectory) if (!source) throw new Error(`DWS binary is missing from ${archiveName}`) const destination = resolve( diff --git a/wework/src-tauri/src/system_sleep.rs b/wework/src-tauri/src/system_sleep.rs index 274e1e0960..51a627d59d 100644 --- a/wework/src-tauri/src/system_sleep.rs +++ b/wework/src-tauri/src/system_sleep.rs @@ -3,7 +3,12 @@ use std::collections::{HashSet, VecDeque}; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + const MAX_SETTLED_TASK_IDS: usize = 256; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; #[derive(Default)] pub(crate) struct SystemSleepState { @@ -240,7 +245,8 @@ fn spawn_inhibitor_command(mut command: Command) -> Result { #[cfg(target_os = "windows")] impl Drop for SleepInhibitor { fn drop(&mut self) { - let current_thread_id = unsafe { windows_sys::Win32::System::Threading::GetCurrentThreadId() }; + let current_thread_id = + unsafe { windows_sys::Win32::System::Threading::GetCurrentThreadId() }; if current_thread_id != self.thread_id { log::warn!( "Sleep inhibitor dropped on a different OS thread than it was acquired on; \ diff --git a/wework/src-tauri/tauri.conf.json b/wework/src-tauri/tauri.conf.json index 99607216c2..dc4adc9c37 100644 --- a/wework/src-tauri/tauri.conf.json +++ b/wework/src-tauri/tauri.conf.json @@ -37,9 +37,7 @@ "enable": true, "scope": { "requireLiteralLeadingDot": false, - "allow": [ - "**/*" - ] + "allow": ["**/*"] } } } @@ -55,10 +53,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "externalBin": [ - "binaries/wegent-executor", - "binaries/dws" - ], + "externalBin": ["binaries/wegent-executor", "binaries/dws"], "resources": [ "binaries/codex/**/*", "bundled-hooks/**/*", diff --git a/wework/src/api/aitable.ts b/wework/src/api/aitable.ts index f277950fc8..6d75a911dc 100644 --- a/wework/src/api/aitable.ts +++ b/wework/src/api/aitable.ts @@ -14,7 +14,7 @@ export interface AITableField { id: string name: string type: string - config: Record + config: Record | null ai_config?: Record | null raw: Record } @@ -30,6 +30,7 @@ export interface AITableDescription { tables: Array> active_table: Record fields: AITableField[] + views?: Array> } export interface AITableRecordPage { @@ -43,7 +44,7 @@ export interface AITableApi { describe(projectId: string): Promise listRecords( projectId: string, - options?: { query?: string; limit?: number; cursor?: string } + options?: { query?: string; limit?: number; cursor?: string; viewId?: string } ): Promise getRecord?(projectId: string, recordId: string): Promise createRecord(projectId: string, cells: Record): Promise @@ -63,6 +64,10 @@ export interface AITableApi { data: { name?: string; config?: Record } ): Promise deleteField(projectId: string, fieldId: string): Promise + createView?( + projectId: string, + data: { name: string; type: 'grid' | 'kanban' } + ): Promise> } type LocalRequest = ( @@ -97,6 +102,7 @@ export function createLocalAITableApi(request: LocalRequest): AITableApi { query: options.query, limit: options.limit, cursor: options.cursor, + view_id: options.viewId, }) }, getRecord(projectId, recordId) { @@ -133,5 +139,12 @@ export function createLocalAITableApi(request: LocalRequest): AITableApi { async deleteField(projectId, fieldId) { await request('aitable.delete_field', { project_id: projectId, field_id: fieldId }) }, + createView(projectId, data) { + return request('aitable.create_view', { + project_id: projectId, + name: data.name, + view_type: data.type, + }) + }, } } diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts index 837bcdc63b..1d6eec5475 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -50,9 +50,10 @@ export interface CloudLoopItem { can_view_detail?: boolean can_edit?: boolean assignee_user_id: number | null + assignee_name?: string | null title: string description: string - status: 'inbox' | 'pending' | 'in_progress' | 'in_review' | 'completed' + status: string priority: 'none' | 'low' | 'medium' | 'high' | 'urgent' due_at: string | null tags: string[] @@ -63,6 +64,8 @@ export interface CloudLoopItem { updated_at: string completed_at: string | null source_status?: string | null + source_record_id?: string | null + source_cells?: Record } export interface CloudLoopItemAttachment { @@ -102,6 +105,20 @@ export interface CloudProject { status_mapping?: Record custom_statuses?: string[] } + card_display?: { + show_assignee: boolean + show_priority: boolean + show_tags: boolean + show_date: boolean + } + board_config?: { + group_by: 'status' | 'priority' | 'assignee' | 'tag' + statuses: Array<{ + id: string + name: string + color: 'gray' | 'blue' | 'orange' | 'purple' | 'green' | 'red' + }> + } created_by_user_id: number current_user_id?: number current_user_name?: string @@ -235,6 +252,8 @@ export function createDeliveryApi(client: HttpClient) { description?: string tags?: string[] visibility?: 'private' | 'public' + card_display?: CloudProject['card_display'] + board_config?: CloudProject['board_config'] provider_config?: { repository?: string domain?: string @@ -255,6 +274,9 @@ export function createDeliveryApi(client: HttpClient) { ): Promise { return client.patch(`/v1/cloud-projects/${projectId}`, data) }, + archiveCloudProject(projectId: CloudProjectIdInput, version: number): Promise { + return client.delete(`/v1/cloud-projects/${projectId}?version=${version}`) + }, listMyWork(): Promise<{ items: CloudMyWorkItem[] }> { return client.get('/v1/cloud-work-items/my-work') }, @@ -306,11 +328,14 @@ export function createDeliveryApi(client: HttpClient) { ): Promise { return client.patch(`/v1/loop-items/${encodeURIComponent(itemId)}`, data) }, + archiveLoopItem(itemId: string): Promise { + return client.delete(`/v1/loop-items/${encodeURIComponent(itemId)}`) + }, reorderLoopItems( projectId: CloudProjectIdInput, data: { parent_id: string | null - status: CloudLoopItem['status'] + status: string item_ids: string[] } ): Promise<{ items: CloudLoopItem[] }> { diff --git a/wework/src/api/dws.ts b/wework/src/api/dws.ts index c2149e8790..97b2720ebd 100644 --- a/wework/src/api/dws.ts +++ b/wework/src/api/dws.ts @@ -13,7 +13,7 @@ type LocalRequest = (method: string, params?: Record) => Pro export interface DwsApi { authStatus(): Promise - login(): Promise + login(): Promise logout(): Promise } diff --git a/wework/src/api/local/localDelivery.test.ts b/wework/src/api/local/localDelivery.test.ts index 91896db4d3..a259a3f396 100644 --- a/wework/src/api/local/localDelivery.test.ts +++ b/wework/src/api/local/localDelivery.test.ts @@ -180,6 +180,7 @@ describe('local delivery API', () => { if (method === 'projects.update') { return { ...projectRecord, name: 'Renamed board', version: 2 } } + if (method === 'projects.archive') return {} if (method === 'todos.list') return [taskRecord] if (method === 'todos.create') return taskRecord throw new Error(`Unexpected method: ${method}`) @@ -212,6 +213,12 @@ describe('local delivery API', () => { project_id: 'project-1', project: { name: 'Renamed board', version: 1 }, }) + + await api.archiveCloudProject('project-1', 2) + expect(request).toHaveBeenCalledWith('projects.archive', { + project_id: 'project-1', + version: 2, + }) }) test('does not expose cached backend projects as local project spaces', async () => { @@ -242,6 +249,7 @@ describe('local delivery API', () => { if (method === 'todos.list') return [taskRecord] if (method === 'todos.update') return updatedRecord if (method === 'todos.reorder') return [updatedRecord] + if (method === 'todos.archive') return {} throw new Error(`Unexpected method: ${method}`) }) const api = createLocalDeliveryApi(request) @@ -257,6 +265,7 @@ describe('local delivery API', () => { item_ids: ['LOCAL-1'], }) ).resolves.toMatchObject({ items: [{ id: 'LOCAL-1' }] }) + await api.archiveLoopItem('LOCAL-1') expect(request).toHaveBeenCalledWith('todos.update', { project_id: 'project-1', @@ -271,6 +280,10 @@ describe('local delivery API', () => { item_ids: ['LOCAL-1'], }, }) + expect(request).toHaveBeenCalledWith('todos.archive', { + project_id: 'project-1', + task_id: 'LOCAL-1', + }) }) test('binds a runtime task through executor storage', async () => { diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts index 4d21289d7a..18a56e46c8 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -103,6 +103,18 @@ function localProject(record: LocalLoopItemRecord): CloudProject { !Array.isArray(record.metadata.provider_config) ? (record.metadata.provider_config as CloudProject['provider_config']) : {}, + board_config: + record.metadata.board_config && + typeof record.metadata.board_config === 'object' && + !Array.isArray(record.metadata.board_config) + ? (record.metadata.board_config as CloudProject['board_config']) + : undefined, + card_display: + record.metadata.card_display && + typeof record.metadata.card_display === 'object' && + !Array.isArray(record.metadata.card_display) + ? (record.metadata.card_display as CloudProject['card_display']) + : undefined, created_by_user_id: 0, current_user_id: 0, current_user_name: '', @@ -226,11 +238,15 @@ function localTask(record: LocalLoopItemRecord, project?: CloudProject): CloudLo can_view_detail: !isPublicVisitor || ownsTask, can_edit: ['Owner', 'Maintainer', 'Developer'].includes(role) || ownsTask, assignee_user_id: null, + assignee_name: + typeof record.metadata.assignee_label === 'string' + ? record.metadata.assignee_label || null + : null, title: record.title ?? '', description: record.description, status: (record.status ?? 'inbox') as CloudLoopItem['status'], priority: (record.priority ?? 'none') as CloudLoopItem['priority'], - due_at: null, + due_at: typeof record.metadata.due_at === 'string' ? record.metadata.due_at || null : null, tags: stringList(record.metadata.tags), sort_order: record.sort_order, current_delivery_id: record.current_delivery_id, @@ -240,6 +256,12 @@ function localTask(record: LocalLoopItemRecord, project?: CloudProject): CloudLo completed_at: record.completed_at, source_status: typeof record.metadata.source_status === 'string' ? record.metadata.source_status : null, + source_record_id: + typeof record.metadata.record_id === 'string' ? record.metadata.record_id : null, + source_cells: + typeof record.metadata.source_cells === 'object' && record.metadata.source_cells !== null + ? (record.metadata.source_cells as Record) + : {}, } } @@ -352,6 +374,8 @@ export function createLocalDeliveryApi( name?: string description?: string tags?: string[] + board_config?: CloudProject['board_config'] + card_display?: CloudProject['card_display'] version: number } ) { @@ -361,6 +385,12 @@ export function createLocalDeliveryApi( }) return localProject(record) }, + async archiveCloudProject(projectId: CloudProjectId, version: number) { + await request('projects.archive', { + project_id: projectId, + version, + }) + }, async listMyWork() { return { items: [] } }, @@ -416,6 +446,14 @@ export function createLocalDeliveryApi( taskProjects.set(record.id, projectId) return localTask(record) }, + async archiveLoopItem(itemId: string) { + const projectId = await resolveProjectId(itemId) + await request('todos.archive', { + project_id: projectId, + task_id: itemId, + }) + taskProjects.delete(itemId) + }, async reorderLoopItems( projectId: CloudProjectId, data: { diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts index f82e194e8a..559a40209a 100644 --- a/wework/src/api/local/localServices.test.ts +++ b/wework/src/api/local/localServices.test.ts @@ -1474,6 +1474,35 @@ describe('createLocalAppServices', () => { expect(sendPayload.executionRequest.prompt).toContain('Current TODO: WEG-1') }) + test('automatically deploys and emphasizes dws for a DingTalk AI Table project', async () => { + const request = vi.fn().mockResolvedValue({ accepted: true }) + const services = createLocalAppServices({ + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'device-uuid' }), + request, + subscribe: vi.fn(), + }) + + await services.runtimeWorkApi?.createRuntimeTask({ + teamId: 0, + deviceId: 'local-device', + workspacePath: '/Users/me/project', + taskId: 'task-dingtalk', + runtime: 'codex', + message: '把任务状态改成进行中', + additionalContext: { + dingtalkAITableProject: { + kind: 'application', + value: 'Base ID: base-1\nTable ID: table-1', + }, + }, + }) + + const payload = request.mock.calls.find(([method]) => method === 'runtime.tasks.create')?.[1] + expect(payload.executionRequest.skill_names).toEqual(['dws']) + expect(payload.executionRequest.preload_skills).toEqual(['dws']) + expect(payload.executionRequest.user_selected_skills).toEqual(['dws']) + }) + test('activates project-space capabilities for a generic cloud reference', async () => { const request = vi.fn().mockResolvedValue({ accepted: true }) const services = createLocalAppServices({ diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index db4719744e..90fcc7f566 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -1265,6 +1265,9 @@ function buildLocalRuntimeExecutionRequest( const reasoning = runtimeReasoning(input.modelOptions) const collaborationMode = runtimeCollaborationMode(input.modelOptions) const skillNames = (input.additionalSkills ?? []).map(skillName).filter(isNonEmptyString) + const requiredSkillNames = input.additionalContext?.dingtalkAITableProject ? ['dws'] : [] + const deployedSkillNames = Array.from(new Set([...skillNames, ...requiredSkillNames])) + const preloadSkills = [...(input.additionalSkills ?? []), ...requiredSkillNames] const workspaceProject = input.workspacePath ? { source: input.workspaceSource, @@ -1301,9 +1304,9 @@ function buildLocalRuntimeExecutionRequest( prompt: messageWithApplicationContext(input.message, input.additionalContext), enable_tools: true, enable_deep_thinking: true, - skill_names: skillNames, - preload_skills: input.additionalSkills ?? [], - user_selected_skills: input.additionalSkills ?? [], + skill_names: deployedSkillNames, + preload_skills: preloadSkills, + user_selected_skills: preloadSkills, ...(workspaceProject ? { workspace: { diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx index cd3d344874..604063b74b 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx @@ -4792,6 +4792,11 @@ describe('DesktopWorkbenchLayout', () => { const tabbar = screen.getByTestId('right-workspace-tabbar') const sideChat = screen.getByTestId('right-workspace-chat-panel') expect(sideChat).toBeInTheDocument() + expect(screen.getByTestId('side-chat-composer-layout')).toHaveClass( + 'mx-auto', + 'w-[min(46rem,calc(100%_-_2rem))]', + 'max-w-[calc(100%_-_2rem)]' + ) expect(within(tabbar).getAllByText('临时聊天')).toHaveLength(1) await waitFor(() => { expect(screen.getByTestId('desktop-workbench-content')).toHaveStyle({ width: '580px' }) @@ -4841,9 +4846,21 @@ describe('DesktopWorkbenchLayout', () => { await userEvent.click(screen.getByTestId('right-workspace-chat-option')) const sideChat = screen.getByTestId('right-workspace-chat-panel') - await userEvent.type(within(sideChat).getByTestId('chat-message-input'), 'side chat') + const sideChatInput = within(sideChat).getByTestId('chat-message-input') + await userEvent.type(sideChatInput, 'side chat') await userEvent.click(within(sideChat).getByTestId('send-message-button')) + expect(sideChatInput).toHaveValue('') + + expect( + screen.getByTestId('right-workspace-chat-scroll-area-content').lastElementChild + ).toHaveClass( + 'mx-auto', + 'w-[min(46rem,calc(100%_-_6rem))]', + 'max-w-[calc(100%_-_6rem)]', + 'px-0' + ) + await waitFor(() => expect(createTemporaryRuntimeTaskMock).toHaveBeenCalledTimes(1)) await waitFor(() => expect(subscribeRuntimeTaskStreamMock).toHaveBeenCalledWith( @@ -4877,8 +4894,10 @@ describe('DesktopWorkbenchLayout', () => { await userEvent.click(screen.getByTestId('right-workspace-chat-option')) const sideChat = screen.getByTestId('right-workspace-chat-panel') - await userEvent.type(within(sideChat).getByTestId('chat-message-input'), 'side chat') + const sideChatInput = within(sideChat).getByTestId('chat-message-input') + await userEvent.type(sideChatInput, 'side chat') await userEvent.click(within(sideChat).getByTestId('send-message-button')) + expect(sideChatInput).toHaveValue('') await waitFor(() => expect(subscribeRuntimeTaskStreamMock).toHaveBeenCalledWith( @@ -4893,6 +4912,7 @@ describe('DesktopWorkbenchLayout', () => { }) await waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + expect(sideChatInput).toHaveValue('side chat') expect(within(sideChat).getByTestId('send-message-button')).toBeEnabled() }) diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index 8423645b02..9e512d35d4 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -77,7 +77,6 @@ export function DesktopWorkbenchLayout() { startNewChat: onNewChat, startStandaloneChat: onStartStandaloneChat, startNewProjectChat: onStartNewProjectChat, - createProjectRuntimeTask: onCreateProjectRuntimeTask, openRuntimeTask: onOpenRuntimeTask, searchRuntimeWork: onSearchRuntimeWork = async () => ({ items: [] }), renameRuntimeTask: onRenameRuntimeTask, @@ -758,28 +757,6 @@ export function DesktopWorkbenchLayout() { user={state.user} localProjects={localTodoProjects} services={services} - onRunTodo={({ - project, - message, - goal, - attachments, - collaborationMode, - deliveryId, - cloudProjectId, - }) => - onCreateProjectRuntimeTask(message, { - project, - attachments, - initialGoal: goal ? { objective: goal } : null, - collaborationMode, - deliveryId, - cloudProjectId, - }) - } - onOpenRuntimeTask={async address => { - navigateTo('/') - await onOpenRuntimeTask?.(address) - }} /> ) : (
void + onRuntimeTaskOptimisticOpen: (address: RuntimeTaskAddress) => void + } + ) => Promise + onAddressChange?: (address: RuntimeTaskAddress | null) => void + sendEphemeral?: boolean + emptyStateText?: string + placeholder?: string expanded?: boolean onRestoreConversation?: () => void } @@ -51,6 +72,12 @@ export function TemporaryChatPanel({ instanceId, testId = 'right-workspace-chat-panel', initialInput = '', + initialAddress = null, + createTask, + onAddressChange, + sendEphemeral = true, + emptyStateText = '临时聊天不会出现在左侧任务列表。', + placeholder = '要求后续变更', expanded = false, onRestoreConversation, }: TemporaryChatPanelProps) { @@ -84,21 +111,43 @@ export function TemporaryChatPanel({ }), [attachmentSelection, projectChat] ) - const [address, setAddress] = useState(null) + const [address, setAddress] = useState(initialAddress) const [messages, setMessages] = useState([]) const [input, setInput] = useState(initialInput) const [error, setError] = useState(null) const [sending, setSending] = useState(false) const [loadingFullTranscript, setLoadingFullTranscript] = useState(false) + const lifecycleStore = useRuntimeTaskLifecycleStore() + const taskLifecycle = useRuntimeTaskLifecycle(address) + const paneStatus = useMemo( + () => + deriveRuntimePaneStatus({ + messages, + currentRuntimeTask: address, + lifecycle: taskLifecycle, + }), + [address, messages, taskLifecycle] + ) + const busy = sending || paneStatus.isBusy const pendingMessageActionsRef = useRef([]) const messageActionFrameRef = useRef(null) + const updateAddress = useCallback( + (nextAddress: RuntimeTaskAddress | null) => { + setAddress(nextAddress) + onAddressChange?.(nextAddress) + }, + [onAddressChange] + ) + useEffect(() => { if (!initialInput) return const frame = requestAnimationFrame(() => { - document - .querySelector(`[data-testid="${testId}"] [data-testid="chat-message-input"]`) - ?.focus() + focusComposerAtEnd( + document.querySelector( + `[data-testid="${testId}"] [data-testid="chat-message-input"]` + ) + ) }) return () => cancelAnimationFrame(frame) }, [initialInput, testId]) @@ -164,9 +213,11 @@ export function TemporaryChatPanel({ let cancelled = false void loadRuntimeTranscriptForPane(address) .then(transcript => { - if (!cancelled && transcript.messages.length > 0) { - setMessages(transcript.messages) - } + if (cancelled) return + lifecycleStore.syncTranscript(address, transcript, { + preserveActiveTurn: lifecycleStore.getTask(address)?.derived.isRunning ?? false, + }) + if (transcript.messages.length > 0) setMessages(transcript.messages) }) .catch(caughtError => { if (!cancelled) { @@ -176,7 +227,7 @@ export function TemporaryChatPanel({ return () => { cancelled = true } - }, [address, loadRuntimeTranscriptForPane]) + }, [address, lifecycleStore, loadRuntimeTranscriptForPane]) const loadFullTranscript = useCallback(async () => { if (!address || loadingFullTranscript) return @@ -186,6 +237,9 @@ export function TemporaryChatPanel({ includeFullContent: true, refresh: true, }) + lifecycleStore.syncTranscript(address, transcript, { + preserveActiveTurn: lifecycleStore.getTask(address)?.derived.isRunning ?? false, + }) if (transcript.messages.length > 0) { setMessages(transcript.messages) } @@ -194,13 +248,13 @@ export function TemporaryChatPanel({ } finally { setLoadingFullTranscript(false) } - }, [address, loadRuntimeTranscriptForPane, loadingFullTranscript]) + }, [address, lifecycleStore, loadRuntimeTranscriptForPane, loadingFullTranscript]) useEffect(() => { if (!address) return return subscribeRuntimeTaskStream(address, { onMessageAction: dispatchMessages, - onAssistantStart: () => setSending(true), + onAssistantStart: () => setSending(false), onAssistantSettled: () => setSending(false), }) }, [address, dispatchMessages, subscribeRuntimeTaskStream]) @@ -218,33 +272,42 @@ export function TemporaryChatPanel({ if (!message) return setError(null) setMessages(current => [...current, createUserMessage(message)]) + setInput('') setSending(true) const currentAttachments = sideChatProjectChat.attachments const attachmentIds = remoteAttachmentIds(currentAttachments) const attachments = localRuntimeAttachments(currentAttachments) - const handleError = (message: string) => { - setError(message) + const handleError = (errorMessage: string) => { + setError(errorMessage) + setInput(current => current || message) setSending(false) } const targetAddress = address ?? - (await createTemporaryRuntimeTask(message, { - project: currentProject, - source, - attachments: currentAttachments, - onError: handleError, - onRuntimeTaskOptimisticOpen: setAddress, - })) + (createTask + ? await createTask(message, { + attachments: currentAttachments, + onError: handleError, + onRuntimeTaskOptimisticOpen: updateAddress, + }) + : await createTemporaryRuntimeTask(message, { + project: currentProject, + source, + attachments: currentAttachments, + onError: handleError, + onRuntimeTaskOptimisticOpen: updateAddress, + })) if (!targetAddress) { - setAddress(null) + setInput(current => current || message) + updateAddress(null) setSending(false) return } if (!address) { - setAddress(targetAddress) + updateAddress(targetAddress) sideChatProjectChat.resetAttachments() return } @@ -253,7 +316,7 @@ export function TemporaryChatPanel({ { address: targetAddress, message, - ephemeral: true, + ...(sendEphemeral ? { ephemeral: true } : {}), ...selectedModelFields, ...(attachmentIds.length > 0 ? { attachmentIds } : {}), ...(attachments.length > 0 ? { attachments } : {}), @@ -268,6 +331,7 @@ export function TemporaryChatPanel({ }, [ address, + createTask, createTemporaryRuntimeTask, currentProject, input, @@ -275,6 +339,8 @@ export function TemporaryChatPanel({ selectedModelFields, sendRuntimePaneMessage, source, + sendEphemeral, + updateAddress, ] ) @@ -290,16 +356,16 @@ export function TemporaryChatPanel({ {messages.length === 0 ? (
-

临时聊天不会出现在左侧任务列表。

+

{emptyStateText}

) : ( {expanded && ( @@ -325,18 +391,21 @@ export function TemporaryChatPanel({