From aadd61e2c6d60de9c004f3de6f6636b14aea019e Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Wed, 29 Jul 2026 17:56:54 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8Dwindows=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E4=BD=BF=E7=94=A8dws=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/src/task_runtime/aitable_provider.rs | 66 ++++++++++++------- pnpm-lock.yaml | 10 +-- pnpm-workspace.yaml | 2 +- wework/package.json | 2 +- wework/scripts/prepare-dws-binary.mjs | 36 +++++++--- wework/src-tauri/src/system_sleep.rs | 6 ++ wework/src/api/dws.ts | 2 +- .../features/todo/CloudProjectManageView.tsx | 20 +++++- .../features/todo/CloudTodoWorkspace.test.tsx | 13 ++++ wework/src/i18n/locales/en/common.json | 3 +- wework/src/i18n/locales/zh-CN/common.json | 3 +- 11 files changed, 119 insertions(+), 44 deletions(-) diff --git a/executor/src/task_runtime/aitable_provider.rs b/executor/src/task_runtime/aitable_provider.rs index 1f089d05f2..69dc051952 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}; @@ -46,8 +47,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> { @@ -378,28 +393,12 @@ impl AITableProvider { } 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,6 +417,25 @@ 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8a108386d..6b26974b87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -743,8 +743,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) @@ -5151,8 +5151,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 @@ -13868,7 +13868,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 f72ef80878..6aa907ca21 100644 --- a/wework/package.json +++ b/wework/package.json @@ -128,7 +128,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 7a96e58762..d9d4b5bb96 100644 --- a/wework/scripts/prepare-dws-binary.mjs +++ b/wework/scripts/prepare-dws-binary.mjs @@ -1,25 +1,26 @@ // 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') const packageRoot = dirname(packageJson) const target = process.env.WEWORK_DWS_TARGET?.trim() || - ({ + { 'darwin-arm64': 'aarch64-apple-darwin', 'darwin-x64': 'x86_64-apple-darwin', 'linux-x64': 'x86_64-unknown-linux-gnu', 'linux-arm64': 'aarch64-unknown-linux-gnu', 'win32-x64': 'x86_64-pc-windows-msvc', - }[`${platform}-${arch}`]) + }[`${platform}-${arch}`] if (!target) throw new Error(`Unsupported DWS build platform: ${platform}-${arch}`) @@ -49,14 +50,31 @@ async function findBinary(directory) { return null } +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) - const command = archiveName.endsWith('.zip') ? 'unzip' : 'tar' - const args = archiveName.endsWith('.zip') - ? ['-q', archive, '-d', temporaryDirectory] - : ['-xzf', archive, '-C', temporaryDirectory] - const result = spawnSync(command, args, { stdio: 'inherit' }) - if (result.status !== 0) throw new Error(`Failed to extract ${archiveName}`) + 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 b18e145832..9c49fc69cb 100644 --- a/wework/src-tauri/src/system_sleep.rs +++ b/wework/src-tauri/src/system_sleep.rs @@ -2,7 +2,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 { @@ -213,6 +218,7 @@ fn sleep_inhibitor_command() -> Result { "-Command", "Add-Type -Namespace Wework -Name Sleep -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint flags);'; [Wework.Sleep]::SetThreadExecutionState(0x80000001); while ($true) { Start-Sleep -Seconds 3600 }", ]); + command.creation_flags(CREATE_NO_WINDOW); Ok(command) } 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/features/todo/CloudProjectManageView.tsx b/wework/src/features/todo/CloudProjectManageView.tsx index 66af950d67..a1fe801fed 100644 --- a/wework/src/features/todo/CloudProjectManageView.tsx +++ b/wework/src/features/todo/CloudProjectManageView.tsx @@ -51,6 +51,18 @@ const AITABLE_BOARD_FIELDS = [ ['due_field_id', '截止时间'], ] as const +const DWS_AUTH_POLL_INTERVAL_MS = 750 +const DWS_AUTH_POLL_ATTEMPTS = 160 + +async function waitForDwsAuthentication(dwsApi: DwsApi): Promise { + for (let attempt = 0; attempt < DWS_AUTH_POLL_ATTEMPTS; attempt += 1) { + await new Promise(resolve => window.setTimeout(resolve, DWS_AUTH_POLL_INTERVAL_MS)) + const status = await dwsApi.authStatus() + if (status.authenticated && status.token_valid !== false) return status + } + throw new Error('钉钉授权等待超时,请重试。') +} + export function CloudProjectManageView({ api, aitableApi, @@ -730,8 +742,10 @@ export function CloudProjectManageView({ onClick={() => { if (!dwsApi) return setAitableBusy(true) + setError(null) void dwsApi .login() + .then(() => waitForDwsAuthentication(dwsApi)) .then(setDwsStatus) .catch(cause => setError(cause instanceof Error ? cause.message : '连接钉钉失败') @@ -740,7 +754,11 @@ export function CloudProjectManageView({ }} className="h-9 rounded-lg border border-border px-3 text-sm font-medium hover:bg-muted disabled:opacity-40" > - {dwsStatus?.authenticated ? '重新连接' : '连接钉钉'} + {aitableBusy + ? t('todo.dws_waiting_for_authorization') + : dwsStatus?.authenticated + ? '重新连接' + : '连接钉钉'}
diff --git a/wework/src/features/todo/CloudTodoWorkspace.test.tsx b/wework/src/features/todo/CloudTodoWorkspace.test.tsx index c1b333a67a..a0f47414d1 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.test.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.test.tsx @@ -860,6 +860,15 @@ describe('CloudTodoWorkspace', () => { updateField: vi.fn(), deleteField: vi.fn(), } + workbenchServices.dwsApi = { + authStatus: vi.fn(async () => ({ + authenticated: true, + token_valid: true, + corp_name: '测试组织', + })), + login: vi.fn(() => new Promise(() => undefined)), + logout: vi.fn(async () => undefined), + } render( { }, }) ) + await userEvent.click(screen.getByTestId('aitable-dws-login')) + expect(workbenchServices.dwsApi.login).toHaveBeenCalledOnce() + expect(screen.getByTestId('aitable-dws-login')).toHaveTextContent('等待浏览器授权…') + expect(screen.getByTestId('aitable-dws-login')).toBeDisabled() }) it('keeps the project header above the macOS drag region and opens new TODO', async () => { diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index 47ed143115..8c6069c41b 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -1894,6 +1894,7 @@ "project_visibility_manage_description": "Private projects are visible to invited members only; public projects are visible to everyone connected to this Backend.", "project_visibility_private_manage_description": "Only invited members can view the project and its tasks", "project_visibility_public_manage_description": "Everyone can see the task list; visitors can only open and edit their own tasks", - "project_visibility_update_failed": "Failed to update project access" + "project_visibility_update_failed": "Failed to update project access", + "dws_waiting_for_authorization": "Waiting for browser authorization…" } } diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index 14d6ff9979..944ab6e04f 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -1893,6 +1893,7 @@ "project_visibility_manage_description": "私有项目仅邀请成员可见;公开项目对所有连接当前 Backend 的用户可见。", "project_visibility_private_manage_description": "仅邀请成员可查看项目和任务", "project_visibility_public_manage_description": "所有用户可见任务列表,访客仅能打开和编辑自己的任务", - "project_visibility_update_failed": "更新项目权限失败" + "project_visibility_update_failed": "更新项目权限失败", + "dws_waiting_for_authorization": "等待浏览器授权…" } } From 2ca71b2877fafb057510c580fdfb0e41c003ca50 Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Thu, 30 Jul 2026 14:42:51 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=B8=8B?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E7=BB=91=E5=AE=9A=E9=92=89=E9=92=89=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/src/agents/claude_code.rs | 38 +- executor/src/agents/runtime_capabilities.rs | 2 +- executor/src/local/app_ipc.rs | 14 +- executor/src/task_runtime/aitable_provider.rs | 331 +++++++++++++-- .../task_runtime/aitable_provider_tests.rs | 54 +++ executor/src/task_runtime/mcp.rs | 126 +++++- executor/src/task_runtime/router.rs | 15 +- pnpm-lock.yaml | 37 ++ wework/package.json | 2 + wework/src/api/aitable.ts | 17 +- wework/src/api/deliveries.ts | 3 + wework/src/api/local/localDelivery.ts | 12 +- wework/src/api/local/localServices.test.ts | 29 ++ wework/src/api/local/localServices.ts | 9 +- .../layout/DesktopWorkbenchLayout.tsx | 2 + wework/src/features/todo/AITableView.test.tsx | 107 ++++- wework/src/features/todo/AITableView.tsx | 340 +++++++++++---- .../features/todo/CloudProjectManageView.tsx | 217 +--------- .../features/todo/CloudTodoWorkspace.test.tsx | 145 ++++++- .../src/features/todo/CloudTodoWorkspace.tsx | 392 ++++++++++++++---- .../todo/DingTalkProjectAssistant.tsx | 159 +++++++ .../todo/projectProviderConfig.test.ts | 55 ++- .../features/todo/projectProviderConfig.ts | 59 +++ .../workbench/useWorkbenchRuntimeMessaging.ts | 1 + .../workbench/workbenchContextTypes.ts | 1 + wework/src/i18n/locales/en/common.json | 14 + wework/src/i18n/locales/zh-CN/common.json | 14 + 27 files changed, 1755 insertions(+), 440 deletions(-) create mode 100644 wework/src/features/todo/DingTalkProjectAssistant.tsx 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..84331b59aa 100644 --- a/executor/src/local/app_ipc.rs +++ b/executor/src/local/app_ipc.rs @@ -757,10 +757,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( diff --git a/executor/src/task_runtime/aitable_provider.rs b/executor/src/task_runtime/aitable_provider.rs index 69dc051952..8ca5f7474e 100644 --- a/executor/src/task_runtime/aitable_provider.rs +++ b/executor/src/task_runtime/aitable_provider.rs @@ -32,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(".")); @@ -97,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() @@ -117,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; @@ -147,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() @@ -345,7 +427,7 @@ impl AITableProvider { &config.base_id, "--table-id", &config.table_id, - "--field-ids", + "--field-id", field_id, "--yes", ]) @@ -392,6 +474,110 @@ 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 { let output = self.command(args)?.output().await.map_err(|error| { TaskRuntimeError::ProviderRequest(format!( @@ -441,41 +627,12 @@ impl AITableProvider { &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()); @@ -488,6 +645,7 @@ impl AITableProvider { break; } } + self.enrich_user_cells(&fields, &mut records).await; let title_records = records .iter() .filter_map(|candidate| { @@ -521,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, @@ -563,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() @@ -607,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); @@ -641,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"] { @@ -901,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")), @@ -931,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(), @@ -939,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/router.rs b/executor/src/task_runtime/router.rs index 0bf71e6753..35c390cacb 100644 --- a/executor/src/task_runtime/router.rs +++ b/executor/src/task_runtime/router.rs @@ -215,10 +215,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 +298,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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b26974b87..30dc4d901c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -633,6 +633,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 @@ -4308,9 +4314,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'} @@ -12962,11 +12983,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 diff --git a/wework/package.json b/wework/package.json index 6aa907ca21..6b4b1a7a64 100644 --- a/wework/package.json +++ b/wework/package.json @@ -90,6 +90,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", 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..0f2d9aa176 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -50,6 +50,7 @@ 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' @@ -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 { diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts index 4d21289d7a..01ce30944b 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -226,11 +226,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 +244,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) + : {}, } } 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 0280bcce23..dbf01cbf90 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -1258,6 +1258,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, @@ -1294,9 +1297,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.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index f23de3fc07..e86c1090a5 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -765,6 +765,7 @@ export function DesktopWorkbenchLayout() { collaborationMode, deliveryId, cloudProjectId, + additionalContext, }) => onCreateProjectRuntimeTask(message, { project, @@ -773,6 +774,7 @@ export function DesktopWorkbenchLayout() { collaborationMode, deliveryId, cloudProjectId, + additionalContext, }) } onOpenRuntimeTask={async address => { diff --git a/wework/src/features/todo/AITableView.test.tsx b/wework/src/features/todo/AITableView.test.tsx index 68e650e15d..8f6bb4ae6f 100644 --- a/wework/src/features/todo/AITableView.test.tsx +++ b/wework/src/features/todo/AITableView.test.tsx @@ -68,25 +68,128 @@ describe('AITableView', () => { expect(await screen.findByText('需求名称')).toBeInTheDocument() expect(screen.getByText('登录优化')).toBeInTheDocument() + expect(screen.getByTestId('aitable-grid')).toHaveClass('h-full') }) - it('keeps formula and unknown field types read-only', async () => { + it('hides DingTalk view tabs and renders a single table', async () => { + const api = apiWith([field({ id: 'fld_title', name: '需求名称' })], []) + vi.mocked(api.describe).mockResolvedValue({ + base: {}, + tables: [], + active_table: {}, + fields: [field({ id: 'fld_title', name: '需求名称' })], + views: [{ viewId: 'view-grid', viewName: '全部记录', viewType: 'grid' }], + }) + render() + + expect(await screen.findByText('需求名称')).toBeInTheDocument() + expect(screen.queryByText('全部记录')).not.toBeInTheDocument() + expect(screen.queryByTestId('aitable-create-kanban-view')).not.toBeInTheDocument() + }) + + it('applies the selected DingTalk View query while always rendering a table', async () => { + const api = apiWith( + [ + field({ id: 'fld_title', name: '需求名称' }), + field({ id: 'fld_owner', name: '负责人', type: 'user' }), + field({ id: 'fld_hidden', name: '内部备注' }), + ], + [] + ) + vi.mocked(api.describe).mockResolvedValue({ + base: {}, + tables: [], + active_table: {}, + fields: [ + field({ id: 'fld_title', name: '需求名称' }), + field({ id: 'fld_owner', name: '负责人', type: 'user' }), + field({ id: 'fld_hidden', name: '内部备注' }), + ], + views: [ + { viewId: 'view-grid', viewName: '全部记录', viewType: 'Grid' }, + { + viewId: 'view-kanban', + viewName: '负责人看板', + viewType: 'Kanban', + columns: ['fld_title', 'fld_owner', 'fld_hidden'], + custom: { + groupBase: { baseFieldId: 'fld_owner' }, + hiddenFields: { fld_hidden: true }, + }, + }, + ], + }) + vi.mocked(api.listRecords).mockResolvedValue({ + items: [ + record('rec1', { + fld_title: '修复登录', + fld_owner: [{ name: '陈波' }], + fld_hidden: '不应显示', + }), + ], + cursor: null, + has_more: false, + }) + + render( + + ) + + expect(await screen.findByText('修复登录')).toBeInTheDocument() + expect(screen.queryByTestId('aitable-kanban')).not.toBeInTheDocument() + expect(screen.getByText('陈波')).toBeInTheDocument() + expect(screen.getByText('修复登录')).toBeInTheDocument() + expect(screen.queryByText('内部备注')).not.toBeInTheDocument() + expect(screen.queryByText('不应显示')).not.toBeInTheDocument() + expect(api.listRecords).toHaveBeenCalledWith('7', { + limit: 100, + viewId: 'view-kanban', + }) + }) + + it('keeps formula, user, and unknown field types read-only', async () => { const api = apiWith( [ field({ id: 'fld_formula', name: '公式', type: 'formula' }), + field({ id: 'fld_owner', name: '负责人', type: 'user' }), field({ id: 'fld_custom', name: '未知类型', type: 'brandNewType' }), ], - [record('rec1', { fld_formula: '=A1+B1', fld_custom: '原始值' })] + [ + record('rec1', { + fld_formula: '=A1+B1', + fld_owner: { uid: 'user-1', name: '陈波' }, + fld_custom: '原始值', + }), + ] ) render() await screen.findByText('公式') // Read-only cells render as plain spans, never as editable buttons/inputs. expect(screen.queryByTestId('aitable-cell-edit-rec1-fld_formula')).not.toBeInTheDocument() + expect(screen.queryByTestId('aitable-cell-edit-rec1-fld_owner')).not.toBeInTheDocument() expect(screen.queryByTestId('aitable-cell-edit-rec1-fld_custom')).not.toBeInTheDocument() expect(screen.getByText('原始值')).toBeInTheDocument() }) + it('normalizes DingTalk millisecond dates for inline editing', async () => { + const api = apiWith( + [field({ id: 'fld_due', name: '截止日期', type: 'date' })], + [record('rec1', { fld_due: Date.UTC(2026, 6, 31) })] + ) + render() + + fireEvent.click(await screen.findByTestId('aitable-cell-edit-rec1-fld_due')) + + expect(screen.getByTestId('aitable-cell-input-rec1-fld_due')).toHaveValue('2026-07-31') + }) + it('commits only the edited cell back to the record', async () => { const api = apiWith( [field({ id: 'fld_title', name: '需求名称', type: 'text' })], diff --git a/wework/src/features/todo/AITableView.tsx b/wework/src/features/todo/AITableView.tsx index f57038e1b6..325a89ca3f 100644 --- a/wework/src/features/todo/AITableView.tsx +++ b/wework/src/features/todo/AITableView.tsx @@ -10,11 +10,45 @@ // Field management (add/rename/delete) requires a Developer-or-higher role. import { useCallback, useEffect, useMemo, useState } from 'react' +import { + AllCommunityModule, + ModuleRegistry, + colorSchemeVariable, + themeQuartz, + type ColDef, + type ICellRendererParams, + type IHeaderParams, +} from 'ag-grid-community' +import { AgGridReact } from 'ag-grid-react' import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' import type { AITableApi, AITableDescription, AITableField, AITableRecord } from '@/api/aitable' import type { CloudProject } from '@/api/deliveries' +ModuleRegistry.registerModules([AllCommunityModule]) + +const aitableGridTheme = themeQuartz.withPart(colorSchemeVariable).withParams({ + accentColor: 'rgb(var(--color-primary))', + backgroundColor: 'rgb(var(--color-bg-base))', + borderColor: 'rgb(var(--color-border))', + browserColorScheme: 'inherit', + cellHorizontalPadding: 12, + columnBorder: true, + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-sm)', + foregroundColor: 'rgb(var(--color-text-primary))', + headerBackgroundColor: 'rgb(var(--color-muted))', + headerFontSize: 'var(--text-sm)', + headerFontWeight: 500, + headerTextColor: 'rgb(var(--color-text-secondary))', + oddRowBackgroundColor: 'rgb(var(--color-bg-base))', + rowBorder: true, + rowHoverColor: 'rgb(var(--color-muted) / 0.4)', + selectedRowBackgroundColor: 'rgb(var(--color-primary) / 0.08)', + spacing: 4, + wrapperBorder: false, +}) + const EDITABLE_TYPES = new Set([ 'text', 'singleLineText', @@ -25,7 +59,6 @@ const EDITABLE_TYPES = new Set([ 'date', 'checkbox', 'url', - 'user', 'phone', 'email', ]) @@ -79,12 +112,62 @@ function cellText(value: unknown): string { return String(value) } +function gridValue(field: AITableField, value: unknown): string | number | Date | null { + if (value === null || value === undefined || value === '') return null + if (field.type === 'number') { + const number = typeof value === 'number' ? value : Number(cellText(value)) + return Number.isFinite(number) ? number : cellText(value) + } + if (field.type === 'date') { + const date = new Date(typeof value === 'number' ? value : cellText(value)) + return Number.isNaN(date.getTime()) ? cellText(value) : date + } + return cellText(value) +} + function selectValues(value: unknown): string[] { if (Array.isArray(value)) return value.map(item => cellText(item)).filter(Boolean) const single = cellText(value) return single ? [single] : [] } +function editorText(field: AITableField, value: unknown): string { + if (field.type !== 'date') return cellText(value) + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value).toISOString().slice(0, 10) + } + const text = cellText(value) + const match = text.match(/^\d{4}-\d{2}-\d{2}/) + return match?.[0] ?? text +} + +function viewValue(view: Record, keys: string[]): string { + for (const key of keys) { + const value = view[key] + if (typeof value === 'string' && value.trim()) return value + } + return '' +} + +function viewColumns(view: Record | undefined): string[] { + const columns = view?.columns ?? view?.fieldIds + return Array.isArray(columns) + ? columns.filter((column): column is string => typeof column === 'string') + : [] +} + +function viewHiddenFields(view: Record | undefined): Set { + const custom = view?.custom + if (typeof custom !== 'object' || custom === null) return new Set() + const hidden = (custom as Record).hiddenFields + if (typeof hidden !== 'object' || hidden === null) return new Set() + return new Set( + Object.entries(hidden as Record) + .filter(([, value]) => value === true) + .map(([fieldId]) => fieldId) + ) +} + interface CellEditorProps { field: AITableField record: AITableRecord @@ -97,6 +180,7 @@ function CellEditor({ field, record, onCommit }: CellEditorProps) { const [editing, setEditing] = useState(false) const [saving, setSaving] = useState(false) const type = field.type + const displayText = editorText(field, raw) async function commit(value: unknown) { setSaving(true) @@ -177,13 +261,13 @@ function CellEditor({ field, record, onCommit }: CellEditorProps) { type="button" data-testid={`aitable-cell-edit-${record.id}-${field.id}`} onClick={() => { - setDraft(cellText(raw)) + setDraft(displayText) setEditing(true) }} className="block w-full truncate rounded px-1 py-0.5 text-left text-sm hover:bg-muted" - title={cellText(raw)} + title={displayText} > - {cellText(raw) || } + {displayText || } ) } @@ -205,6 +289,81 @@ function CellEditor({ field, record, onCommit }: CellEditorProps) { ) } +interface GridCellContext { + canEditRecords: boolean + commitCell: (record: AITableRecord, fieldId: string, value: unknown) => Promise +} + +function GridCellRenderer({ data, colDef, context }: ICellRendererParams) { + const record = data + const field = colDef?.context as AITableField | undefined + const gridContext = context as GridCellContext + if (!record || !field) return null + + return ( + gridContext.commitCell(record, fieldId, value)} + /> + ) +} + +interface FieldHeaderContext { + canManageFields: boolean + removeField: (field: AITableField) => Promise +} + +function FieldHeader({ displayName, column, context }: IHeaderParams) { + const field = column.getColDef().context as AITableField | undefined + const headerContext = context as FieldHeaderContext + + return ( + + + {displayName} + + {field && headerContext.canManageFields ? ( + + ) : null} + + ) +} + +interface RecordActionsContext { + canEditRecords: boolean + removeRecord: (record: AITableRecord) => Promise +} + +function RecordActions({ data, context }: ICellRendererParams) { + const record = data + const actionContext = context as RecordActionsContext + if (!record || !actionContext.canEditRecords) return null + + return ( + + ) +} + export function AITableView({ api, project }: { api: AITableApi; project: CloudProject }) { const [description, setDescription] = useState(null) const [records, setRecords] = useState([]) @@ -218,20 +377,21 @@ export function AITableView({ api, project }: { api: AITableApi; project: CloudP const [hasMore, setHasMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false) const [mutationBusy, setMutationBusy] = useState(false) + const [selectedViewId, setSelectedViewId] = useState(project.provider_config.view_id ?? '') const canManageFields = ['Owner', 'Maintainer', 'Developer'].includes( project.access_role ?? 'Owner' ) const canEditRecords = canManageFields const load = useCallback( - async (keyword?: string) => { + async (keyword?: string, viewId = selectedViewId) => { setLoading(true) setError(null) try { await api.configureProject(project) const [schema, page] = await Promise.all([ api.describe(project.id), - api.listRecords(project.id, { query: keyword, limit: 100 }), + api.listRecords(project.id, { query: keyword, limit: 100, viewId: viewId || undefined }), ]) setDescription(schema) setRecords(page.items) @@ -243,7 +403,7 @@ export function AITableView({ api, project }: { api: AITableApi; project: CloudP setLoading(false) } }, - [api, project] + [api, project, selectedViewId] ) useEffect(() => { @@ -252,12 +412,18 @@ export function AITableView({ api, project }: { api: AITableApi; project: CloudP setError(null) try { await api.configureProject(project) - const [schema, page] = await Promise.all([ - api.describe(project.id), - api.listRecords(project.id, { limit: 100 }), - ]) + const initialViewId = project.provider_config.view_id ?? '' + const schema = await api.describe(project.id) + const resolvedViewId = + initialViewId || + (schema.views?.length ? viewValue(schema.views[0], ['viewId', 'view_id', 'id']) : '') + const page = await api.listRecords(project.id, { + limit: 100, + viewId: resolvedViewId || undefined, + }) if (cancelled) return setDescription(schema) + setSelectedViewId(resolvedViewId) setRecords(page.items) setCursor(page.cursor) setHasMore(page.has_more) @@ -273,6 +439,23 @@ export function AITableView({ api, project }: { api: AITableApi; project: CloudP }, [api, project]) const fields = useMemo(() => description?.fields ?? [], [description]) + const selectedView = useMemo( + () => + description?.views?.find( + view => viewValue(view, ['viewId', 'view_id', 'id']) === selectedViewId + ), + [description, selectedViewId] + ) + const visibleFields = useMemo(() => { + const columns = viewColumns(selectedView) + const hidden = viewHiddenFields(selectedView) + const available = fields.filter(field => !hidden.has(field.id)) + if (!columns.length) return available + const positions = new Map(columns.map((fieldId, index) => [fieldId, index])) + return available + .filter(field => positions.has(field.id)) + .sort((left, right) => positions.get(left.id)! - positions.get(right.id)!) + }, [fields, selectedView]) async function commitCell(record: AITableRecord, fieldId: string, value: unknown) { setError(null) @@ -326,6 +509,7 @@ export function AITableView({ api, project }: { api: AITableApi; project: CloudP query: query || undefined, limit: 100, cursor, + viewId: selectedViewId || undefined, }) setRecords(current => [...current, ...page.items]) setCursor(page.cursor) @@ -364,6 +548,53 @@ export function AITableView({ api, project }: { api: AITableApi; project: CloudP } } + const columnDefs = useMemo[]>( + () => [ + ...visibleFields.map((field): ColDef => { + const filter = + field.type === 'number' + ? 'agNumberColumnFilter' + : field.type === 'date' + ? 'agDateColumnFilter' + : 'agTextColumnFilter' + return { + colId: field.id, + context: field, + filter, + filterValueGetter: params => gridValue(field, params.data?.cells[field.id]), + headerComponentParams: { innerHeaderComponent: FieldHeader }, + headerName: field.name, + minWidth: 160, + sortable: true, + suppressHeaderMenuButton: false, + valueGetter: params => gridValue(field, params.data?.cells[field.id]), + cellRenderer: GridCellRenderer, + } + }), + { + colId: 'record-actions', + cellRenderer: RecordActions, + filter: false, + headerName: '', + maxWidth: 48, + minWidth: 48, + pinned: 'right', + resizable: false, + sortable: false, + suppressHeaderMenuButton: true, + }, + ], + [visibleFields] + ) + + const gridContext = { + canEditRecords, + canManageFields, + commitCell, + removeField, + removeRecord, + } + return (
@@ -408,80 +639,29 @@ export function AITableView({ api, project }: { api: AITableApi; project: CloudP
) : null} -
+
{loading ? (
) : ( - - - - {fields.map(field => ( - - ))} - - - - {records.map(record => ( - - {fields.map(field => ( - - ))} - - - ))} - {records.length === 0 ? ( - - - - ) : null} - -
- - - {field.name} - - {canManageFields ? ( - - ) : null} - - -
- commitCell(record, fieldId, value)} - /> - - {canEditRecords ? ( - - ) : null} -
- 暂无记录 -
+
+ + columnDefs={columnDefs} + context={gridContext} + defaultColDef={{ + flex: 1, + resizable: true, + suppressMovable: false, + }} + getRowId={params => params.data.id} + headerHeight={40} + overlayNoRowsTemplate="暂无记录" + rowData={records} + rowHeight={40} + theme={aitableGridTheme} + /> +
)}
diff --git a/wework/src/features/todo/CloudProjectManageView.tsx b/wework/src/features/todo/CloudProjectManageView.tsx index a1fe801fed..7b2afcfd42 100644 --- a/wework/src/features/todo/CloudProjectManageView.tsx +++ b/wework/src/features/todo/CloudProjectManageView.tsx @@ -1,17 +1,6 @@ import { useEffect, useState } from 'react' -import { - ArrowDown, - ArrowUp, - Check, - GitBranch, - LockKeyhole, - Pencil, - Search, - Tag, - Trash2, - X, -} from 'lucide-react' -import type { AITableApi, AITableField } from '@/api/aitable' +import { Check, GitBranch, LockKeyhole, Pencil, Search, Tag, Trash2, X } from 'lucide-react' +import type { AITableApi } from '@/api/aitable' import type { DwsApi, DwsAuthStatus } from '@/api/dws' import type { CloudLoopItem, @@ -41,16 +30,6 @@ function configText(project: CloudProject, key: string): string { return typeof value === 'string' ? value : '' } -const AITABLE_BOARD_FIELDS = [ - ['title_field_id', '标题'], - ['description_field_id', '描述'], - ['status_field_id', '状态'], - ['parent_field_id', '父任务'], - ['priority_field_id', '优先级'], - ['assignee_field_id', '负责人'], - ['due_field_id', '截止时间'], -] as const - const DWS_AUTH_POLL_INTERVAL_MS = 750 const DWS_AUTH_POLL_ATTEMPTS = 160 @@ -105,59 +84,9 @@ export function CloudProjectManageView({ const isAITableProvider = project.task_provider === 'dingtalk_aitable' const [aitableUrl, setAITableUrl] = useState(() => configText(project, 'source_url')) const [dwsStatus, setDwsStatus] = useState(null) - const [aitableFields, setAitableFields] = useState([]) - const [aitableMapping, setAitableMapping] = useState>(() => { - const mapping = project.provider_config.board_mapping - return typeof mapping === 'object' && mapping !== null - ? Object.fromEntries( - Object.entries(mapping).filter((entry): entry is [string, string] => - Boolean(entry[0] && typeof entry[1] === 'string') - ) - ) - : {} - }) - const [statusMode, setStatusMode] = useState<'mapped' | 'custom'>( - project.provider_config.status_mode === 'custom' ? 'custom' : 'mapped' - ) - const [statusMapping, setStatusMapping] = useState>( - project.provider_config.status_mapping ?? {} - ) - const [customStatusOrder, setCustomStatusOrder] = useState( - project.provider_config.custom_statuses ?? [] - ) const [aitableBusy, setAitableBusy] = useState(false) const [aitableSaved, setAITableSaved] = useState(false) const aitableLink = parseDingTalkAITableLink(aitableUrl) - const statusField = aitableFields.find(field => field.id === aitableMapping.status_field_id) - const statusOptions = Array.from( - new Set([ - ...(Array.isArray(statusField?.config?.options) - ? statusField.config.options - .map(option => - typeof option === 'object' && option !== null && 'name' in option - ? String(option.name) - : '' - ) - .filter(Boolean) - : []), - ...items.map(item => item.source_status ?? '').filter(Boolean), - ]) - ) - const orderedStatuses = [ - ...customStatusOrder.filter(status => statusOptions.includes(status)), - ...statusOptions.filter(status => !customStatusOrder.includes(status)), - ] - - function moveCustomStatus(status: string, offset: -1 | 1) { - const current = orderedStatuses - const index = current.indexOf(status) - const target = index + offset - if (index < 0 || target < 0 || target >= current.length) return - const next = [...current] - ;[next[index], next[target]] = [next[target], next[index]] - setCustomStatusOrder(next) - setAITableSaved(false) - } useEffect(() => { let active = true @@ -179,8 +108,7 @@ export function CloudProjectManageView({ let active = true void aitableApi .configureProject(project) - .then(() => aitableApi.describe(project.id)) - .then(value => active && setAitableFields(value.fields)) + .then(() => undefined) .catch(cause => active && setError(cause instanceof Error ? cause.message : '加载字段失败')) return () => { active = false @@ -270,17 +198,6 @@ export function CloudProjectManageView({ table_id: aitableLink!.tableId, source_url: aitableLink!.url, ...(aitableLink!.viewId ? { view_id: aitableLink!.viewId } : {}), - board_mapping: Object.fromEntries( - Object.entries(aitableMapping).filter(([, value]) => value) - ), - status_mode: statusMode, - status_mapping: - statusMode === 'mapped' - ? Object.fromEntries( - statusOptions.map(option => [option, statusMapping[option] ?? 'inbox']) - ) - : {}, - custom_statuses: statusMode === 'custom' ? orderedStatuses : [], }, }) setProjectVersion(updated.version) @@ -703,7 +620,7 @@ export function CloudProjectManageView({

钉钉多维表格

- 配置数据源与看板字段映射。未映射的字段只会显示在表格视图中。 + 配置钉钉数据源。看板与数据视图会直接读取表格字段,无需额外映射。

-
-

看板字段映射

-
- {AITABLE_BOARD_FIELDS.map(([key, label]) => ( - - ))} -
-
- {aitableMapping.status_field_id && ( -
-

状态泳道模式

-
- {( - [ - ['mapped', '映射', '归并到 Wework 的五种标准状态'], - ['custom', '自定义多泳道', '按钉钉状态选项原样生成泳道'], - ] as const - ).map(([value, label, detail]) => ( - - ))} -
- {statusMode === 'mapped' && statusOptions.length > 0 && ( -
- {statusOptions.map(option => ( - - ))} -
- )} - {statusMode === 'custom' && orderedStatuses.length > 0 && ( -
- {orderedStatuses.map((status, index) => ( -
- {status} - - -
- ))} -
- )} -
- )}
{aitableSaved && 已保存}
diff --git a/wework/src/features/todo/CloudTodoWorkspace.test.tsx b/wework/src/features/todo/CloudTodoWorkspace.test.tsx index a0f47414d1..efe9915889 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.test.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.test.tsx @@ -162,6 +162,135 @@ function services(): WorkbenchServices { describe('CloudTodoWorkspace', () => { afterEach(() => vi.restoreAllMocks()) + it('starts a project-level AI conversation with DingTalk dws context', async () => { + const workbenchServices = services() + const aitableProject = { + ...project, + task_provider: 'dingtalk_aitable' as const, + provider_config: { + base_id: 'base-1', + table_id: 'table-1', + view_id: 'view-1', + }, + } + workbenchServices.deliveryApi!.listCloudProjects = vi.fn(async () => ({ + items: [aitableProject], + })) + const onRunTodo = vi.fn(async () => ({ taskId: 'runtime-1', deviceId: 'device-1' })) + const onOpenRuntimeTask = vi.fn() + + render( + + ) + + await userEvent.click((await screen.findAllByText('Wegent V4'))[0]) + expect(await screen.findByTestId('dingtalk-project-assistant')).toBeInTheDocument() + await userEvent.type( + screen.getByTestId('dingtalk-project-assistant-input'), + '只看本周未完成的任务' + ) + await userEvent.click(screen.getByTestId('dingtalk-project-assistant-send')) + + await waitFor(() => expect(onRunTodo).toHaveBeenCalledTimes(1)) + expect(onRunTodo).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ id: 91 }), + message: '只看本周未完成的任务', + goal: 'Wegent V4', + cloudProjectId: 11, + additionalContext: expect.objectContaining({ + dingtalkAITableProject: expect.objectContaining({ + kind: 'application', + value: expect.stringContaining('"base_id": "base-1"'), + }), + }), + }) + ) + expect(onOpenRuntimeTask).toHaveBeenCalledWith({ + taskId: 'runtime-1', + deviceId: 'device-1', + }) + }) + + it('renders DingTalk records by live table fields without exposing provider record ids', async () => { + const workbenchServices = services() + workbenchServices.aitableApi = { + configureProject: vi.fn(async () => undefined), + describe: vi.fn(async () => ({ + base: {}, + tables: [], + active_table: {}, + fields: [ + { id: 'field-status', name: '天河状态', type: 'singleSelect', config: null, raw: {} }, + { id: 'field-owner', name: '负责人', type: 'member', config: {}, raw: {} }, + ], + })), + } as WorkbenchServices['aitableApi'] + workbenchServices.deliveryApi!.listCloudProjects = vi.fn(async () => ({ + items: [ + { + ...project, + task_provider: 'dingtalk_aitable' as const, + provider_config: { base_id: 'base-1', table_id: 'table-1' }, + }, + ], + })) + workbenchServices.deliveryApi!.listLoopItems = vi.fn(async () => ({ + items: [ + { + ...item, + id: 'aitable:base-1:record-1', + title: '修复发布流程', + assignee_name: '陈波', + source_cells: { 'field-status': '进行中', 'field-owner': [{ name: '陈波' }] }, + tags: [], + }, + { + ...item, + id: 'aitable:base-1:record-2', + parent_id: 'aitable:base-1:record-1', + title: '补齐测试', + assignee_name: '胡春林', + source_cells: { 'field-status': '待处理', 'field-owner': [{ name: '胡春林' }] }, + tags: [], + }, + ], + })) + + render( + + ) + + await userEvent.click((await screen.findAllByText('Wegent V4'))[0]) + + expect( + await screen.findByTestId('cloud-todo-column-field-field-status-进行中') + ).toBeInTheDocument() + expect(screen.getByTestId('cloud-todo-column-field-field-status-待处理')).toBeInTheDocument() + expect(screen.queryByText('aitable:base-1:record-1')).not.toBeInTheDocument() + expect(screen.getByText('修复发布流程')).toBeInTheDocument() + expect(screen.queryByText('补齐测试')).not.toBeInTheDocument() + expect(screen.getByText('1 个子任务')).toBeInTheDocument() + + await userEvent.click(screen.getByTestId('dingtalk-board-group-by')) + await userEvent.type(screen.getByTestId('dingtalk-board-group-search'), '负责人') + expect(screen.queryByTestId('dingtalk-board-group-option-field-status')).not.toBeInTheDocument() + await userEvent.click(screen.getByTestId('dingtalk-board-group-option-field-owner')) + expect( + await screen.findByTestId('cloud-todo-column-field-field-owner-陈波') + ).toBeInTheDocument() + }) + it('keeps projects visible when one project issue provider fails', async () => { const workbenchServices = services() workbenchServices.deliveryApi!.listLoopItems = vi.fn(async () => { @@ -820,7 +949,7 @@ describe('CloudTodoWorkspace', () => { expect(await screen.findByText('已保存')).toBeInTheDocument() }) - it('updates DingTalk table connection and board mappings from project management', async () => { + it('updates the DingTalk connection without exposing board mappings', async () => { const workbenchServices = services() const aitableProject = { ...project, @@ -880,12 +1009,11 @@ describe('CloudTodoWorkspace', () => { await userEvent.click((await screen.findAllByText('Wegent V4'))[0]) await userEvent.click(await screen.findByTestId('cloud-project-manage-view')) - await userEvent.selectOptions( - await screen.findByTestId('aitable-mapping-status_field_id'), - 'fld-status' - ) + expect(screen.queryByText('看板字段映射')).not.toBeInTheDocument() await userEvent.click(screen.getByTestId('aitable-manage-save')) + expect(screen.queryByTestId('aitable-status-mode-custom')).not.toBeInTheDocument() + await waitFor(() => expect(workbenchServices.deliveryApi!.updateCloudProject).toHaveBeenCalledWith(11, { version: 1, @@ -893,13 +1021,6 @@ describe('CloudTodoWorkspace', () => { base_id: 'base-1', table_id: 'table-1', source_url: 'https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1', - board_mapping: { - title_field_id: 'fld-title', - status_field_id: 'fld-status', - }, - status_mode: 'mapped', - status_mapping: {}, - custom_statuses: [], }, }) ) diff --git a/wework/src/features/todo/CloudTodoWorkspace.tsx b/wework/src/features/todo/CloudTodoWorkspace.tsx index 81c37a6914..956137e591 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DndContext, DragOverlay, @@ -35,6 +35,7 @@ import type { CloudProject, CloudProjectMember, } from '@/api/deliveries' +import type { AITableField } from '@/api/aitable' import { ApiError } from '@/api/http' import { DesktopAppSwitcher } from '@/components/layout/DesktopAppSwitcher' import { DesktopWindowControls } from '@/components/layout/DesktopWindowControls' @@ -56,6 +57,7 @@ import { AITableView } from '@/features/todo/AITableView' import type { Attachment, ProjectWithTasks, + RuntimeAdditionalContext, RuntimeTaskAddress, User as UserProfile, } from '@/types/api' @@ -64,8 +66,13 @@ import { CloudMyWorkView } from './CloudMyWorkView' import { CloudProjectManageView } from './CloudProjectManageView' import { CloudProjectsHome } from './CloudProjectsHome' import { CloudFilesView } from './CloudFilesView' +import { DingTalkProjectAssistant } from './DingTalkProjectAssistant' import { GlobalTodoSearch } from './GlobalTodoSearch' -import { parseDingTalkAITableLink, repositoryProviderConfig } from './projectProviderConfig' +import { + dingtalkAITableRuntimeContext, + parseDingTalkAITableLink, + repositoryProviderConfig, +} from './projectProviderConfig' import { TaskSearchPanel } from './TaskSearchPanel' import { TodoEditor } from './TodoEditor' import { emptyTaskSearchFilters, type TaskSearchFilters } from './taskSearch' @@ -75,6 +82,105 @@ type ProjectView = 'board' | 'table' | 'files' | 'manage' type RootView = 'projects' | 'my-work' type ProjectTaskProvider = 'local' | 'github' | 'gitlab' | 'dingtalk_aitable' +function aitableCellLabels(value: unknown): string[] { + if (value === null || value === undefined || value === '') return [] + return (Array.isArray(value) ? value : [value]) + .map(entry => { + if (typeof entry === 'object' && entry !== null) { + const object = entry as Record + return String(object.name ?? object.title ?? object.text ?? '') + } + return String(entry) + }) + .filter(Boolean) +} + +function AITableGroupFieldPicker({ + fields, + value, + onChange, +}: { + fields: AITableField[] + value: string + onChange: (fieldId: string) => void +}) { + const rootRef = useRef(null) + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const selected = fields.find(field => field.id === value) + const visibleFields = fields + .filter(field => `${field.name} ${field.type}`.toLowerCase().includes(query.toLowerCase())) + .sort((left, right) => { + const recommended = (field: AITableField) => + /状态|负责人|优先级|所属项目/.test(field.name) ? 0 : 1 + return recommended(left) - recommended(right) + }) + + useEffect(() => { + if (!open) return + const close = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [open]) + + return ( +
+ + {open ? ( +
+ +
+ {visibleFields.map(field => ( + + ))} + {visibleFields.length === 0 ? ( +

没有匹配字段

+ ) : null} +
+
+ ) : null} +
+ ) +} + interface LocatedCloudProject extends CloudProject { location: ProjectSpaceLocation } @@ -92,6 +198,7 @@ interface CloudTaskRunRequest { collaborationMode?: 'default' | 'plan' deliveryId?: string cloudProjectId?: string + additionalContext?: RuntimeAdditionalContext } interface CloudTodoWorkspaceProps { @@ -129,12 +236,19 @@ const boardCollisionDetection: CollisionDetection = args => { return cardCollision ? [cardCollision] : collisions.slice(0, 1) } -function TodoCardContent({ item }: { item: CloudLoopItem }) { +function TodoCardContent({ item, dingtalk = false }: { item: CloudLoopItem; dingtalk?: boolean }) { const tags = item.tags ?? [] return ( <> - {item.id} - {item.title} + {!dingtalk && {item.id}} + + {item.title} + + {dingtalk && item.description ? ( + + {item.description} + + ) : null} ))} {tags.length > 3 && +{tags.length - 3}} - {item.updated_at.slice(5, 10)} + + {(item.due_at ?? item.updated_at).slice(5, 10)} + + {dingtalk && item.assignee_name ? ( + + + {item.assignee_name.slice(0, 1)} + + {item.assignee_name} + + ) : null} ) } @@ -165,12 +289,16 @@ function DraggableTodoCard({ onClick, onAddChild, onOpenChildren, + dingtalk = false, + dragDisabled = false, }: { item: CloudLoopItem childCount: number onClick: () => void onAddChild: () => void onOpenChildren: () => void + dingtalk?: boolean + dragDisabled?: boolean }) { const { attributes, @@ -180,7 +308,7 @@ function DraggableTodoCard({ isDragging, } = useDraggable({ id: item.id, - disabled: item.can_edit === false, + disabled: item.can_edit === false || dragDisabled, }) const { isOver, setNodeRef: setDropRef } = useDroppable({ id: `todo-card:${item.id}` }) return ( @@ -206,7 +334,7 @@ function DraggableTodoCard({ {...listeners} {...attributes} > - +
{childCount > 0 ? ( @@ -655,27 +783,33 @@ function ProjectDialog({ function StartTaskDialog({ item, + projectName, projects, onClose, onStart, }: { - item: CloudLoopItem + item?: CloudLoopItem + projectName?: string projects: ProjectWithTasks[] onClose: () => void onStart: (project: ProjectWithTasks, message: string) => Promise }) { const [projectId, setProjectId] = useState(projects[0]?.id ?? 0) - const [message, setMessage] = useState(item.description || item.title) + const [message, setMessage] = useState( + item?.description || item?.title || `帮我查看并管理“${projectName ?? ''}”中的事项` + ) const [starting, setStarting] = useState(false) const selected = projects.find(project => project.id === projectId) return ( - +
- {item.id} + {item ? ( + {item.id} + ) : null} - {item.title} + {item?.title ?? projectName}

- 新任务会获得当前项目空间上下文,可读取共享目录、任务和历史交付,但不会自动上传本地会话。 + AI 会获得当前项目上下文;钉钉多维表格项目会自动使用 dws 读取和修改实时数据。

@@ -778,6 +912,13 @@ export function CloudTodoWorkspace({ const [createTodoStatus, setCreateTodoStatus] = useState('inbox') const [boardParentId, setBoardParentId] = useState(null) const [startItem, setStartItem] = useState(null) + const [projectAssistantOpen, setProjectAssistantOpen] = useState(true) + const [projectAssistantBusy, setProjectAssistantBusy] = useState(false) + const [projectAssistantError, setProjectAssistantError] = useState(null) + const [aitableFields, setAitableFields] = useState([]) + const [aitableGroupFieldId, setAitableGroupFieldId] = useState('') + const [aitableGroupFilter, setAitableGroupFilter] = useState('') + const [aitableBoardQuery, setAitableBoardQuery] = useState('') const [activeDragItemId, setActiveDragItemId] = useState(null) const boardSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }) @@ -844,21 +985,26 @@ export function CloudTodoWorkspace({ ) const selectedProjectApi = selectedProject ? apiForProjectId(selectedProject.id) : undefined const isAITableProject = selectedProject?.task_provider === 'dingtalk_aitable' - const usesCustomStatusLanes = - isAITableProject && selectedProject?.provider_config.status_mode === 'custom' - const customStatuses = Array.from( + const selectedGroupField = aitableFields.find(field => field.id === aitableGroupFieldId) + const configuredGroupValues = Array.isArray(selectedGroupField?.config?.options) + ? selectedGroupField.config.options.flatMap(option => aitableCellLabels(option)) + : [] + const aitableGroupValues = Array.from( new Set([ - ...(selectedProject?.provider_config.custom_statuses ?? []), - ...items.map(item => item.source_status ?? '').filter(Boolean), - ...(items.some(item => !(item.source_status ?? '').trim()) ? [''] : []), + ...configuredGroupValues, + ...items.flatMap(item => aitableCellLabels(item.source_cells?.[aitableGroupFieldId])), + ...(items.some(item => !aitableCellLabels(item.source_cells?.[aitableGroupFieldId]).length) + ? ['未设置'] + : []), ]) ) - const boardColumns = usesCustomStatusLanes - ? customStatuses.map((sourceStatus, index) => ({ - key: sourceStatus || '__unset__', - label: sourceStatus || '未设置', + const boardColumns = isAITableProject + ? aitableGroupValues.map((groupValue, index) => ({ + key: `field-${aitableGroupFieldId}-${groupValue}`, + label: groupValue, status: 'inbox' as CloudLoopItem['status'], - sourceStatus, + sourceStatus: null, + groupValue, dotClass: ['bg-zinc-400', 'bg-indigo-500', 'bg-amber-500', 'bg-violet-500'][index % 4], })) : columns.map(column => ({ @@ -866,10 +1012,42 @@ export function CloudTodoWorkspace({ label: column.label, status: column.status, sourceStatus: null, + groupValue: null, dotClass: columnDotClasses[column.status], })) const aitableApi = isAITableProject ? services.aitableApi : undefined + useEffect(() => { + if (!isAITableProject || !selectedProject || !aitableApi) return + let active = true + void aitableApi + .describe(selectedProject.id) + .then(description => { + if (!active) return + setAitableFields(description.fields) + const mapping = selectedProject.provider_config.board_mapping + const mappedStatus = + typeof mapping === 'object' && mapping !== null + ? (mapping as Record).status_field_id + : null + const defaultField = + description.fields.find(field => field.id === mappedStatus) ?? + description.fields.find(field => /select|member|checkbox/i.test(field.type)) ?? + description.fields[0] + setAitableGroupFieldId(current => + description.fields.some(field => field.id === current) + ? current + : (defaultField?.id ?? '') + ) + }) + .catch(cause => { + if (active) setBoardError(cause instanceof Error ? cause.message : '读取钉钉字段失败') + }) + return () => { + active = false + } + }, [aitableApi, isAITableProject, selectedProject]) + useEffect(() => { if (!services.aitableApi) return let active = true @@ -892,7 +1070,7 @@ export function CloudTodoWorkspace({ active = false } }, [projectSpaceApis.local, projects, services.aitableApi]) - const canCreateBoardTask = selectedProject !== null && !usesCustomStatusLanes + const canCreateBoardTask = selectedProject !== null // Only render board items that belong to the selected project. On a project // switch this flips to the skeleton in the same render, before the fetch. // `boardError` distinguishes a failed fetch (skeleton stays) from a @@ -927,6 +1105,7 @@ export function CloudTodoWorkspace({ function selectProject(projectId: string | null) { setSelectedProjectId(projectId) + setProjectView('board') setBoardParentId(null) setTagFilter(null) setProjectSearchOpen(false) @@ -1066,6 +1245,7 @@ export function CloudTodoWorkspace({ async function startTask(project: ProjectWithTasks, item: CloudLoopItem, message: string) { if (!onRunTodo) return + const cloudProject = projects.find(candidate => candidate.id === item.cloud_project_id) const address = await onRunTodo({ project, message, @@ -1073,6 +1253,7 @@ export function CloudTodoWorkspace({ attachments: [] as Attachment[], collaborationMode: 'default', cloudProjectId: item.cloud_project_id, + additionalContext: cloudProject ? dingtalkAITableRuntimeContext(cloudProject) : undefined, }) if (!address) return const itemApi = apiForProjectId(item.cloud_project_id) @@ -1090,37 +1271,35 @@ export function CloudTodoWorkspace({ await onOpenRuntimeTask?.(address) } + async function startProjectAssistant(project: ProjectWithTasks, message: string) { + if (!onRunTodo || !selectedProject) return + setProjectAssistantBusy(true) + setProjectAssistantError(null) + try { + const address = await onRunTodo({ + project, + message, + goal: selectedProject.name, + attachments: [] as Attachment[], + collaborationMode: 'default', + cloudProjectId: selectedProject.id, + additionalContext: dingtalkAITableRuntimeContext(selectedProject), + }) + if (!address) return + await onOpenRuntimeTask?.(address) + } catch (cause) { + setProjectAssistantError(cause instanceof Error ? cause.message : '启动 AI 会话失败') + } finally { + setProjectAssistantBusy(false) + } + } + async function moveItem( itemId: string, status: CloudLoopItem['status'], - beforeItemId: string | null = null, - sourceStatus: string | null = null + beforeItemId: string | null = null ) { const item = items.find(candidate => candidate.id === itemId) - if (usesCustomStatusLanes && item && sourceStatus) { - if (item.can_edit === false || item.source_status === sourceStatus) return - const previousItems = items - setItems(current => - current.map(candidate => - candidate.id === item.id ? { ...candidate, source_status: sourceStatus } : candidate - ) - ) - try { - const itemApi = apiForProjectId(item.cloud_project_id) - if (!itemApi) throw new Error('项目空间当前不可用') - const updated = await itemApi.updateLoopItem(item.id, { - version: item.version, - status: sourceStatus as CloudLoopItem['status'], - }) - setItems(current => - current.map(candidate => (candidate.id === updated.id ? updated : candidate)) - ) - } catch (cause) { - setItems(previousItems) - setBoardError(cause instanceof Error ? cause.message : '移动任务失败') - } - return - } const reordered = reorderLaneItems(items, itemId, status, beforeItemId) if (!item || item.can_edit === false || !reordered) return const previousItems = items @@ -1157,19 +1336,13 @@ export function CloudTodoWorkspace({ if (beforeCardId) { if (beforeCardId === activeId) return const target = items.find(candidate => candidate.id === beforeCardId) - if (target) - void moveItem( - activeId, - target.status, - beforeCardId, - usesCustomStatusLanes ? (target.source_status ?? null) : null - ) + if (target) void moveItem(activeId, target.status, beforeCardId) return } const status = boardStatusFromDropId(event.over?.id) if (status) { const column = boardColumns.find(candidate => candidate.key === status) - if (column) void moveItem(activeId, column.status, null, column.sourceStatus) + if (column) void moveItem(activeId, column.status) } } @@ -1462,7 +1635,7 @@ export function CloudTodoWorkspace({ : 'text-text-secondary hover:text-text-primary' )} > - 事项 + 看板 {isAITableProject && aitableApi ? ( ) : null} {selectedProject.access_role !== 'RestrictedAnalyst' && ( @@ -1510,6 +1683,16 @@ export function CloudTodoWorkspace({ )} + {isAITableProject && onRunTodo ? ( + + ) : null} {projectView === 'board' && ( <>
)} - {canCreateBoardTask && ( + {canCreateBoardTask && !isAITableProject && (
) : null} @@ -1755,6 +1998,15 @@ export function CloudTodoWorkspace({ )} + {selectedProject && isAITableProject && projectAssistantOpen && onRunTodo ? ( + setProjectAssistantOpen(false)} + onSubmit={startProjectAssistant} + /> + ) : null}
{globalSearchOpen && ( diff --git a/wework/src/features/todo/DingTalkProjectAssistant.tsx b/wework/src/features/todo/DingTalkProjectAssistant.tsx new file mode 100644 index 0000000000..da6b3f2deb --- /dev/null +++ b/wework/src/features/todo/DingTalkProjectAssistant.tsx @@ -0,0 +1,159 @@ +import { Bot, Loader2, Send, Sparkles, X } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { useTranslation } from '@/hooks/useTranslation' +import type { ProjectWithTasks } from '@/types/api' + +interface DingTalkProjectAssistantProps { + projects: ProjectWithTasks[] + busy: boolean + error: string | null + onClose: () => void + onSubmit: (project: ProjectWithTasks, message: string) => Promise +} + +export function DingTalkProjectAssistant({ + projects, + busy, + error, + onClose, + onSubmit, +}: DingTalkProjectAssistantProps) { + const { t } = useTranslation('common') + const [projectId, setProjectId] = useState(projects[0]?.id ?? 0) + const [message, setMessage] = useState('') + const selectedProject = useMemo( + () => projects.find(project => project.id === projectId) ?? projects[0], + [projectId, projects] + ) + + async function submit(value = message) { + const prompt = value.trim() + if (!prompt || !selectedProject || busy) return + await onSubmit(selectedProject, prompt) + setMessage('') + } + + return ( +