diff --git a/.github/GOVERNANCE.md b/.github/GOVERNANCE.md index 54145f71f7..c214acba84 100644 --- a/.github/GOVERNANCE.md +++ b/.github/GOVERNANCE.md @@ -146,7 +146,7 @@ but do not replace the approval required from an eligible GitHub reviewer. ## Main Branch Merge Gates -The live [main ruleset](https://github.com/huangruiteng/loopx/rules/18121976) +The live [main ruleset](https://github.com/loopx-project/loopx/rules/18121976) is the operational authority. It requires a pull request, one approving review, code-owner approval where applicable, dismissal of stale approvals, approval of the last push by another reviewer, resolved review threads, and @@ -220,7 +220,7 @@ The versioned [Current Technical Directions](../docs/project/technical-directions.md) page is the canonical map of active strategic programs, maturity, contribution routes, and promotion gates. The pinned -[GitHub Discussion](https://github.com/huangruiteng/loopx/discussions/2851) is +[GitHub Discussion](https://github.com/loopx-project/loopx/discussions/2851) is its community-facing projection; an issue, Discussion, RFC, or integration branch does not override merged runtime and stable reference contracts. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 991975bb67..b2e9338f40 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Thanks for helping improve LoopX. Do not report an unpatched security vulnerability here; use [Private Vulnerability Reporting](https://github.com/huangruiteng/loopx/security/advisories/new). Remove credentials, private data, raw agent sessions, internal links, and local runtime state before submitting. + Thanks for helping improve LoopX. Do not report an unpatched security vulnerability here; use [Private Vulnerability Reporting](https://github.com/loopx-project/loopx/security/advisories/new). Remove credentials, private data, raw agent sessions, internal links, and local runtime state before submitting. - type: checkboxes id: preflight diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c57e8669e5..9203623c5c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,17 +1,17 @@ blank_issues_enabled: false contact_links: - name: Ask a question - url: https://github.com/huangruiteng/loopx/discussions/categories/q-a + url: https://github.com/loopx-project/loopx/discussions/categories/q-a about: Get community help with usage, configuration, or design questions. - name: Discord community url: https://discord.gg/XmGgQyCFZd about: Join informal onboarding, workflow, and show-and-tell conversations. - name: Report a security vulnerability - url: https://github.com/huangruiteng/loopx/security/advisories/new + url: https://github.com/loopx-project/loopx/security/advisories/new about: Privately report suspected unpatched vulnerabilities. Do not open a public issue. - name: Contributor task board - url: https://github.com/huangruiteng/loopx/blob/main/docs/development/contributor-tasks.md + url: https://github.com/loopx-project/loopx/blob/main/docs/development/contributor-tasks.md about: Start here for public, claimable work. - name: Current technical directions - url: https://github.com/huangruiteng/loopx/blob/main/docs/project/technical-directions.md + url: https://github.com/loopx-project/loopx/blob/main/docs/project/technical-directions.md about: Understand active programs, maturity, ownership boundaries, and promotion gates. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6f109ca6da..f95a319092 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -58,7 +58,7 @@ baseline/head comparison and a failing-before or mutation check, not just test c Documentation-only changes may use a static/manual row and explain runtime N/A. A passing row does not waive required real-path/backend gates. --> -See [validation disclosure guidance](https://github.com/huangruiteng/loopx/blob/main/CONTRIBUTING.md#validation-disclosure). +See [validation disclosure guidance](https://github.com/loopx-project/loopx/blob/main/CONTRIBUTING.md#validation-disclosure). ## Frontend / Visual Evidence diff --git a/.github/SECURITY.md b/.github/SECURITY.md index d2d9782cb3..c81e1e168e 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -10,7 +10,7 @@ version-specific regression. ## Reporting A Vulnerability Please report suspected vulnerabilities through -[GitHub Private Vulnerability Reporting](https://github.com/huangruiteng/loopx/security/advisories/new). +[GitHub Private Vulnerability Reporting](https://github.com/loopx-project/loopx/security/advisories/new). Do not open a public issue, discussion, or pull request for an unpatched vulnerability. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index d64f84b2cc..cd1048aa23 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -9,10 +9,10 @@ channels. | Need | Channel | Use it for | | --- | --- | --- | -| Reproducible bug or installation failure | [GitHub Issues](https://github.com/huangruiteng/loopx/issues/new/choose) | Public, sanitized reproduction steps for behavior that can be investigated or fixed in the repository. | -| Feature request | [GitHub Issues](https://github.com/huangruiteng/loopx/issues/new/choose) | A concrete problem, desired outcome, alternatives, and the smallest useful product change. | -| Usage or design question | [GitHub Discussions: Q&A](https://github.com/huangruiteng/loopx/discussions/categories/q-a) | Questions, configuration help, and design discussion that do not yet identify a repository bug. | -| Security vulnerability | [Private Vulnerability Reporting](https://github.com/huangruiteng/loopx/security/advisories/new) | Suspected unpatched vulnerabilities. Do not post them in an issue, discussion, or chat. See [`SECURITY.md`](SECURITY.md). | +| Reproducible bug or installation failure | [GitHub Issues](https://github.com/loopx-project/loopx/issues/new/choose) | Public, sanitized reproduction steps for behavior that can be investigated or fixed in the repository. | +| Feature request | [GitHub Issues](https://github.com/loopx-project/loopx/issues/new/choose) | A concrete problem, desired outcome, alternatives, and the smallest useful product change. | +| Usage or design question | [GitHub Discussions: Q&A](https://github.com/loopx-project/loopx/discussions/categories/q-a) | Questions, configuration help, and design discussion that do not yet identify a repository bug. | +| Security vulnerability | [Private Vulnerability Reporting](https://github.com/loopx-project/loopx/security/advisories/new) | Suspected unpatched vulnerabilities. Do not post them in an issue, discussion, or chat. See [`SECURITY.md`](SECURITY.md). | | Informal peer help | [Discord](https://discord.gg/XmGgQyCFZd) | Onboarding, workflow comparison, show and tell, and community conversation. Chat is not an authoritative support or release record. | Public contributor work belongs on the @@ -25,14 +25,14 @@ requests should follow ## Official Publication Sources -- [GitHub Releases](https://github.com/huangruiteng/loopx/releases) is the +- [GitHub Releases](https://github.com/loopx-project/loopx/releases) is the authoritative source for published versions and release notes. -- [GitHub Discussions: Announcements](https://github.com/huangruiteng/loopx/discussions/categories/announcements) +- [GitHub Discussions: Announcements](https://github.com/loopx-project/loopx/discussions/categories/announcements) is the authoritative source for project announcements that are not tied to one release. The pinned - [Current technical directions and known limitations](https://github.com/huangruiteng/loopx/discussions/2851) + [Current technical directions and known limitations](https://github.com/loopx-project/loopx/discussions/2851) post is the community-facing projection of the versioned repository map. -- [GitHub Security Advisories](https://github.com/huangruiteng/loopx/security/advisories) +- [GitHub Security Advisories](https://github.com/loopx-project/loopx/security/advisories) is the authoritative source for coordinated vulnerability disclosures. Repository documentation describes the current product and contributor @@ -49,7 +49,7 @@ official publication source. Reposts, screenshots, personal accounts, and third-party communities may be useful, but they are not authoritative project communications. When sources conflict, prefer the official GitHub source for the relevant topic and ask for clarification in a public -[Discussion](https://github.com/huangruiteng/loopx/discussions). +[Discussion](https://github.com/loopx-project/loopx/discussions). ## Make A Useful Request diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 91d4230e74..3cb1cce4d6 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -555,6 +555,9 @@ jobs: - name: Install test dependencies run: python -m pip install --disable-pip-version-check -e ".[test]" + - name: Verify native Windows test Python discovery + run: node --no-warnings --experimental-strip-types --test tests/control_plane_ts/test_python_runtime.test.ts + - name: Run native Windows lifecycle tests run: >- python -m pytest -q @@ -567,6 +570,7 @@ jobs: tests/control_plane/test_effect_runtime_integration.py tests/control_plane/test_local_authority_shadow_outbox.py tests/test_self_update_runtime_activation.py + tests/test_windows_atomic_writes.py tests/test_windows_install.py - name: Set up the qualified SQLite runtime diff --git a/apps/presentation/dashboard/README.md b/apps/presentation/dashboard/README.md index 033cd73269..9806eb1e4b 100644 --- a/apps/presentation/dashboard/README.md +++ b/apps/presentation/dashboard/README.md @@ -9,6 +9,28 @@ Personal Workspace is the operator UI for Goals, Tasks, Chat, and outputs. The CLI and versioned control-plane projections remain the sources of truth; the UI does not create a second state authority. +## Workspace Language / 工作区语言 + +On first use, the workspace follows the browser's ordered language preferences: +English uses English, and Chinese uses the available Simplified Chinese catalog. +If neither language matches or language information is unavailable, it uses +English. This replaces the previous unconditional Chinese default. + +Choose **Settings → Language** to override detection. Explicit choices remain in +the existing `loopx-pw-locale` browser storage entry and survive reloads and +upgrades on the same origin. A different host/port, cleared browser storage, or a +fresh desktop webview starts detection again. When storage is blocked, language +changes last for the current session only. + +首次打开时,工作区按浏览器语言偏好顺序选择英文或简体中文;没有匹配语言或无法 +获取语言信息时使用英文,替代原来固定使用中文的默认行为。 +可在 **设置 → 语言** 手动选择,同一站点来源下的刷新和升级会保留选择。 +更换主机或端口、清除浏览器存储或使用新的桌面 WebView 后会重新检测;浏览器 +禁止存储时,手动选择仅在当前会话有效。 + +Validate this behavior with `npm run smoke:workspace-locale`; the same cases also +run in the full development and packaged personal-workspace browser smokes. + ## Fresh Clone Public Preview No private LoopX state is required for the first dashboard preview. The diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 705db3cee7..d4bf0fa8f0 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -31,6 +31,7 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-router": "rm -rf /tmp/loopx-personal-workspace-router-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-personal-workspace-router-smoke smoke/personal-workspace-router-smoke.ts src/features/personal-workspace/personal-workspace-router.ts && node /tmp/loopx-personal-workspace-router-smoke/smoke/personal-workspace-router-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs index 31a21ce911..c259c7f011 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs @@ -7,10 +7,11 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { cleanupBrowserSmoke, launchBrowser, startViteDashboardServer, waitForHttp } from "../../../../examples/dashboard-browser-smoke-support.mjs"; import { acceptance, contract, snapshot, verifiedContract } from "./goal-acceptance-contract-fixture.mjs"; +import { resolveTestPython } from "../../../../scripts/test-python.mjs"; const dashboardDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const root = resolve(dashboardDir, "../../.."); -const python = process.env.LOOPX_PYTHON ?? "python3"; +const python = resolveTestPython(); const port = Number(process.env.LOOPX_ACCEPTANCE_CONTRACT_PORT ?? 5297); const packaged = process.env.LOOPX_ACCEPTANCE_CONTRACT_PACKAGED === "1"; const payload = JSON.parse(execFileSync(python, ["-c", ` diff --git a/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs new file mode 100644 index 0000000000..4d5064ef18 --- /dev/null +++ b/apps/presentation/dashboard/smoke/workspace-locale-browser-smoke.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; + +// Exercise the shipped provider and Settings UI against the shared synthetic API. +export async function verifyWorkspaceLocales({ browser, url, installApi, outputDir }) { + const cases = [ + { name: "fresh English", locale: "en-US", expected: "en" }, + { name: "fresh Chinese", locale: "zh-CN", expected: "zh-CN" }, + { name: "Chinese region", locale: "zh-TW", expected: "zh-CN" }, + { name: "unsupported language", locale: "fr-FR", expected: "en" }, + { name: "ordered preferences", languages: ["fr-FR", "en-GB", "zh-CN"], expected: "en" }, + { name: "secondary Chinese", languages: ["fr-FR", "zh-CN", "en"], expected: "zh-CN" }, + { name: "language fallback", languages: [], locale: "zh-CN", expected: "zh-CN" }, + { name: "missing language", languages: [], language: "", expected: "en" }, + { name: "saved English", stored: "en", locale: "zh-CN", expected: "en" }, + { name: "saved Chinese", stored: "zh-CN", locale: "en-US", expected: "zh-CN" }, + { name: "invalid preference", stored: "invalid", locale: "en-US", expected: "en" }, + { name: "locale storage failure English", blocked: true, locale: "en-US", expected: "en" }, + { name: "locale storage failure Chinese", blocked: true, locale: "zh-CN", expected: "zh-CN" }, + ]; + for (const scenario of cases) { + const page = await browser.newPage({ locale: scenario.locale ?? "en-US" }); + try { + await installApi(page); + await page.addInitScript(({ stored, blocked, languages, language }) => { + if (stored !== undefined) localStorage.setItem("loopx-pw-locale", stored); + if (languages !== undefined) Object.defineProperty(navigator, "languages", { value: languages }); + if (language !== undefined) Object.defineProperty(navigator, "language", { value: language }); + if (blocked) { + const getItem = Storage.prototype.getItem; + const setItem = Storage.prototype.setItem; + Storage.prototype.getItem = function (key) { + if (key === "loopx-pw-locale") throw new DOMException("Storage unavailable", "SecurityError"); + return getItem.call(this, key); + }; + Storage.prototype.setItem = function (key, value) { + if (key === "loopx-pw-locale") throw new DOMException("Storage unavailable", "SecurityError"); + return setItem.call(this, key, value); + }; + } + }, scenario); + await page.goto(url, { waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + assert.equal(await page.locator("html").getAttribute("lang"), scenario.expected, scenario.name); + const label = scenario.expected === "en" ? "Settings" : "设置"; + assert.ok(await page.getByRole("button", { name: label, exact: true }).isVisible(), scenario.name); + if (!scenario.blocked) { + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-locale")), scenario.stored ?? null, + `${scenario.name}: detection must not overwrite a saved choice`); + } + if (scenario.blocked) { + await page.getByRole("button", { name: label, exact: true }).click(); + await page.getByRole("button", { name: /Language|语言/ }).click(); + const next = scenario.expected === "en" ? "zh-CN" : "en"; + await page.getByRole("radio", { name: next === "en" ? /English/ : /Simplified Chinese/ }).click(); + assert.equal(await page.locator("html").getAttribute("lang"), next, + "A locale write failure still permits changing the current session"); + } + console.log(`workspace locale: ${scenario.name}: ok`); + } finally { + await page.close(); + } + } + + const page = await browser.newPage({ locale: "en-US", viewport: { width: 1440, height: 960 } }); + try { + await installApi(page); + await page.goto(url, { waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + await page.screenshot({ path: resolve(outputDir, "locale-default-english.png") }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator(".personal-channel-scroll").evaluate((element) => { element.scrollTop = 0; }); + const clippedStats = await page.locator(".personal-digest-stats span").evaluateAll((stats) => + stats.filter((stat) => { + const rect = stat.getBoundingClientRect(); + return rect.left < 0 || rect.right > window.innerWidth; + }).length, + ); + assert.equal(clippedStats, 0, "English summary statistics fit the mobile viewport"); + await page.screenshot({ path: resolve(outputDir, "locale-default-english-mobile.png") }); + await page.setViewportSize({ width: 1440, height: 960 }); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: /Language/ }).click(); + await page.getByRole("radio", { name: /Simplified Chinese/ }).click(); + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-locale")), "zh-CN"); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + assert.equal(await page.locator("html").getAttribute("lang"), "zh-CN"); + await page.screenshot({ path: resolve(outputDir, "locale-saved-chinese.png") }); + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.getByRole("button", { name: /语言/ }).click(); + await page.getByRole("radio", { name: /English/ }).click(); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor(); + assert.equal(await page.locator("html").getAttribute("lang"), "en"); + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-locale")), "en"); + console.log("workspace locale: Settings choices survive reload in both directions: ok"); + } finally { + await page.close(); + } +} diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index a0403790c2..ff2a176686 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -876,10 +876,26 @@ export async function streamChatTurn( } export async function interruptChatTurn(sessionId: string, turnId: string) { - return requestJson<{ ok: true; session_id: string; turn_id: string; status: string }>( + const receipt = await requestJson<{ ok: true; session_id: string; turn_id: string; status: string }>( `/api/chat/sessions/${sessionId}/turns/${turnId}/interrupt`, { method: "POST", body: "{}" }, ); + if (receipt.ok !== true || receipt.session_id !== sessionId || receipt.turn_id !== turnId) { + throw new ChatApiError("中断回执与本次请求不一致,请刷新后查看。", { error_code: "interrupt_receipt_mismatch" }); + } + return receipt; +} + +export async function steerChatTurn(sessionId: string, turnId: string, message: string, ingressId: string) { + const receipt = await requestJson<{ ok: boolean; session_id: string; turn_id: string; client_ingress_id: string; status: string }>( + `/api/chat/sessions/${sessionId}/turns/${turnId}/steer`, + { method: "POST", body: JSON.stringify({ message, client_ingress_id: ingressId }) }, + ); + if (receipt.ok !== true || receipt.session_id !== sessionId || receipt.turn_id !== turnId + || receipt.client_ingress_id !== ingressId || receipt.status !== "delivered") { + throw new ChatApiError("追加指令的回执不匹配,请保留草稿并检查当前状态。", { error_code: "steer_receipt_mismatch" }); + } + return receipt; } export type LoopXModeSnapshot = { @@ -923,6 +939,28 @@ export function readLoopXTeamWork(sessionId: string, operationId: string) { method: "POST", body: JSON.stringify({operation: "read", operation_id: operationId}), }); } +export type ManagedGoalResultRow = { + todo_id: string; title: string; producer_agent_id: string; sha256: string; + content_type: string; size_bytes: number; completed_at?: string | null; +}; +export type ManagedGoalResultPage = { + ok: true; items: ManagedGoalResultRow[]; total: number; next_cursor: string | null; + unavailable_count: number; unavailable_todo_ids: string[]; +}; +export type ManagedGoalResultRead = { + ok: true; goal_id: string; todo_id: string; text: string; + result: {sha256: string; content_type: string; producer_agent_id: string}; +}; +export function fetchManagedGoalResults(goalId: string, cursor?: string) { + const params = new URLSearchParams({goal_id: goalId}); + if (cursor) params.set("cursor", cursor); + return requestJson(`/api/chat/goal-results?${params}`); +} +export function readManagedGoalResult(goalId: string, todoId: string) { + return requestJson( + `/api/chat/goal-results/${encodeURIComponent(todoId)}?goal_id=${encodeURIComponent(goalId)}`, + ); +} // Keep inventory and selected-operation labels consistent; unknown states stay unknown. export function delegationStateLabel(row: {status: string; worker_active?: boolean; recovery_required: boolean | null}, zh: boolean) { if (row.status === "unavailable") return zh ? "无法核验" : "Unavailable"; @@ -1007,7 +1045,8 @@ async function receiveChatTurnStreaming( options.onDelta?.(String(event.payload.text ?? "")); } if (event.kind === "agent.phase") { - options.onActivity?.(String(event.payload.label ?? "Agent 正在处理")); + const label = typeof event.payload.label === "string" ? event.payload.label.trim() : ""; + if (label) options.onActivity?.(label); } if (event.kind === "turn.completed") { finalResponse = event.payload.response; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 4bae190b7c..846c9586bd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -1,6 +1,7 @@ -import {Fragment} from "react"; +import {Fragment, useRef, useState} from "react"; +import { ChatApiError } from "../../data/chat.js"; import { CollaborationCard } from "./collaboration-card"; -import { Activity, Bot, Sparkles } from "lucide-react"; +import { Activity, Bot, Sparkles, Square } from "lucide-react"; import { AttentionRow } from "./cards/attention-row"; import { MarkdownText } from "./markdown"; @@ -10,7 +11,82 @@ import { ScheduleRow } from "./cards/schedule-row"; import { useWorkspaceI18n } from "./i18n"; import { ReturnDeliveryStatus } from "./return-delivery-status"; import {ManagerTeamResult} from "./manager-team-result"; -import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceTimelineItem } from "./personal-workspace-model"; +import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceMessage, WorkspaceTimelineItem } from "./personal-workspace-model"; + +function MessageActivity({ message, onInterruptTurn, onSteerTurn }: { + message: WorkspaceMessage; + onInterruptTurn?: (turnId: string) => Promise; + onSteerTurn?: (turnId: string, text: string, ingressId: string) => Promise; +}) { + const { locale, t } = useWorkspaceI18n(); + const zh = locale === "zh-CN"; + const [stopping, setStopping] = useState(false); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [steering, setSteering] = useState(false); + const [steerError, setSteerError] = useState(null); + const [steerReceipt, setSteerReceipt] = useState(false); + const request = useRef<{ text: string; id: string } | null>(null); + const activity = message.activity ?? []; + async function steer() { + const text = draft.trim(); + if (!text || !message.pending || !message.sourceTurnId || !onSteerTurn || steering) return; + // Retain the operation identity after a lost response; retry cannot deliver twice. + if (request.current?.text !== text) request.current = { text, id: crypto.randomUUID() }; + setSteering(true); + setSteerError(null); + try { + await onSteerTurn(message.sourceTurnId, text, request.current.id); + setDraft(""); setEditing(false); setSteerReceipt(true); request.current = null; + } catch (cause) { + const definitelyNotDelivered = cause instanceof ChatApiError && cause.payload.delivery_state === "not_delivered"; + if (definitelyNotDelivered) request.current = null; + const message = cause instanceof Error ? cause.message : (zh ? "未确认接收,草稿已保留。" : "Delivery unconfirmed. Draft retained."); + setSteerError(definitelyNotDelivered + ? (zh ? "本次未送达;请检查当前回合与执行器,条件恢复后可重试原文。" : "Not delivered; check the current turn and executor, then retry the unchanged draft.") + : message); + } finally { setSteering(false); } + } + async function interrupt() { + if (!message.sourceTurnId || !onInterruptTurn || stopping) return; + setStopping(true); + setError(null); + try { await onInterruptTurn(message.sourceTurnId); } + catch (cause) { setError(cause instanceof Error ? cause.message : (zh ? "中断失败,请重试。" : "Could not interrupt. Try again.")); } + finally { setStopping(false); } + } + return
+ {message.pending ?
+ {activity.at(-1) || t("timeline.pending")} + + {message.sourceTurnId && onSteerTurn ? : null} + {message.sourceTurnId && onInterruptTurn ? : null} + +
: null} + {editing ?
{ event.preventDefault(); void steer(); }}> +