diff --git a/.agents/skills/builtin-tool/SKILL.md b/.agents/skills/builtin-tool/SKILL.md index 74adc0b74c..93a26ae0e9 100644 --- a/.agents/skills/builtin-tool/SKILL.md +++ b/.agents/skills/builtin-tool/SKILL.md @@ -23,7 +23,7 @@ A builtin tool is a package the agent runtime can call. It ships **five faces**: | ------------------------------------------------------------------------------------ | --------------------------------------------- | | Where do files live? What does each face do? Wiring? | [architecture.md](references/architecture.md) | | How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](references/tool-design.md) | -| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](references/ui.md) | +| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui/](references/ui/README.md) | --- diff --git a/.agents/skills/builtin-tool/references/tool-design.md b/.agents/skills/builtin-tool/references/tool-design.md index f42287b93d..4fb15fdeb2 100644 --- a/.agents/skills/builtin-tool/references/tool-design.md +++ b/.agents/skills/builtin-tool/references/tool-design.md @@ -2,7 +2,7 @@ This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend. -For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md). +For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui/](ui/README.md). For where files live and how registries work, see [architecture.md](architecture.md). --- @@ -156,7 +156,7 @@ export const TaskManifest: BuiltinToolManifest = { executors: ['client', 'server'], /* Default human intervention policy for all APIs that don't specify one. - Pair with an Intervention component (see ui.md). */ + Pair with an Intervention component (see ui/intervention.md). */ humanIntervention: 'never' | 'always' | { /* extended config */ }, } ``` diff --git a/.agents/skills/builtin-tool/references/ui.md b/.agents/skills/builtin-tool/references/ui.md deleted file mode 100644 index 7f72b9e233..0000000000 --- a/.agents/skills/builtin-tool/references/ui.md +++ /dev/null @@ -1,744 +0,0 @@ -# Tool UI Surfaces - -A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files. - -| Surface | Required? | When the chat shows it | Registered in | -| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- | -| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` | -| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` | -| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` | -| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` | -| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` | -| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` | - -The two reference tools to read end-to-end: - -- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming). -- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks. - ---- - -## Tool Render 设计原则(中文草案) - -这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。 - -1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。 -2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。 -3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args`、`partialArgs` 和 `pluginState`,避免出现空白、跳变或只显示半截参数。 -4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `.loading` / `.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}` 与 `lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。 -5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。 -6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。 -7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。 -8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。 -9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。 -10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。 -11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。 -12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。 -13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox`、`createStaticStyles` 和 `cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。 -14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。 -15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。 - ---- - -## 0. Shared Style Rules - -These apply across every surface. - -### 0.1 Use `'use client'` at the top of every component file - -Tool surfaces are leaves in the chat tree and must not block server rendering. - -### 0.2 Prefer `createStaticStyles + cssVar.*` - -Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime. - -```tsx -import { createStaticStyles, cssVar } from 'antd-style'; - -const styles = createStaticStyles(({ css, cssVar }) => ({ - chip: css` - padding-block: 2px; - padding-inline: 8px; - border-radius: 999px; - color: ${cssVar.colorText}; - background: ${cssVar.colorFillTertiary}; - `, -})); -``` - -Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values. - -### 0.3 Use `@lobehub/ui`, not raw `antd` - -`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill. - -Memory note: `@lobehub/ui`'s `` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write ``. - -### 0.4 Always `memo` and set `displayName` - -```tsx -export const SearchInspector = memo>( - ({ args /* … */ }) => { - /* … */ - }, -); -SearchInspector.displayName = 'SearchInspector'; -export default SearchInspector; -``` - -### 0.5 Always type with `BuiltinXProps` generics - -Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `Params` and `State` from `types.ts`. - -### 0.6 Pull strings from `t('plugin')` - -```tsx -const { t } = useTranslation('plugin'); -t('builtins..apiName.'); -``` - -Every Inspector should default to `t('builtins..apiName.')` so it shows something while args stream in. - -### 0.7 Read store state from `@/store/chat`, not props - -Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId. - ---- - -## 1. Inspector — Header Chip (required) - -**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible. - -**Goal:** keep it to a single line. Show what's happening with as much context as is currently available. - -### Props (`BuiltinInspectorProps`) - -```ts -interface BuiltinInspectorProps { - apiName: string; - args: Arguments; // final args (only after the assistant stops streaming) - identifier: string; - isArgumentsStreaming?: boolean; // args still arriving - isLoading?: boolean; // args complete, executor running - partialArgs?: Arguments; // partial JSON during streaming - pluginState?: State; // executor's `state` after success - result?: { content: string | null; error?: any }; -} -``` - -### State machine - -| Phase | What's available | What to show | -| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | -| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` | -| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated | -| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated | -| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) | - -### Canonical example — Search - -`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`: - -```tsx -'use client'; - -import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types'; -import { Text } from '@lobehub/ui'; -import { cssVar, cx } from 'antd-style'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles'; - -export const SearchInspector = memo>( - ({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => { - const { t } = useTranslation('plugin'); - - const query = args?.query || partialArgs?.query || ''; - const resultCount = pluginState?.results?.length ?? 0; - const hasResults = resultCount > 0; - - if (isArgumentsStreaming && !query) { - return ( -
- {t('builtins.lobe-web-browsing.apiName.search')} -
- ); - } - - return ( -
- {t('builtins.lobe-web-browsing.apiName.search')}:  - {query && {query}} - {!isLoading && - !isArgumentsStreaming && - pluginState?.results && - (hasResults ? ( - ({resultCount}) - ) : ( - - ({t('builtins.lobe-web-browsing.inspector.noResults')}) - - ))} -
- ); - }, -); -SearchInspector.displayName = 'SearchInspector'; -export default SearchInspector; -``` - -### Inspector rules - -- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline). -- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`. -- Show the i18n title first so the row is non-empty during the earliest streaming phase. -- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream. -- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble. -- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching. -- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `.loading` and `.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern. - -### Inspector registry — `client/Inspector/index.ts` - -```ts -import type { BuiltinInspector } from '@lobechat/types'; - -import { TaskApiName } from '../../types'; -import { CreateTaskInspector } from './CreateTask'; -import { ListTasksInspector } from './ListTasks'; -/* … */ - -export const TaskInspectors: Record = { - [TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector, - [TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector, - /* one entry per ApiName */ -}; - -export { CreateTaskInspector } from './CreateTask'; -export { ListTasksInspector } from './ListTasks'; -/* re-export each */ -``` - ---- - -## 2. Render — Rich Result Card (optional) - -**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header. - -**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files. - -### Props (`BuiltinRenderProps`) - -```ts -interface BuiltinRenderProps { - apiName?: string; - args: Arguments; // final params from the LLM - content: Content; // executor's content string (or parsed) - identifier?: string; - messageId: string; // for store lookups - pluginError?: any; // from BuiltinToolResult.error - pluginState?: State; // executor's state - toolCallId?: string; -} -``` - -### Two patterns - -**Pattern A — Single-file Render** (web-browsing CrawlSinglePage): - -```tsx -// client/Render/CrawlSinglePage.tsx -import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types'; -import { memo } from 'react'; - -import PageContent from './PageContent'; - -const CrawlSinglePage = memo>( - ({ messageId, pluginState, args }) => ( - - ), -); -export default CrawlSinglePage; -``` - -**Pattern B — Folder with subcomponents** (web-browsing Search): - -``` -client/Render/Search/ -├── index.tsx # composes the subcomponents, handles error states -├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid' -├── SearchQuery.tsx # editable query header -└── SearchResult.tsx # result list -``` - -Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting. - -### Error handling in Render - -Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors: - -```tsx -if (pluginError) { - if (pluginError?.type === 'PluginSettingsInvalid') { - return ; - } - return ( - {JSON.stringify(pluginError.body, null, 2)}} - /> - ); -} -``` - -### Render rules - -- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream). -- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.** -- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything. -- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill. - -### Render registry — `client/Render/index.ts` - -```ts -import type { BuiltinRender } from '@lobechat/types'; - -import { TaskApiName } from '../../types'; -import CreateTaskRender from './CreateTask'; -import RunTasksRender from './RunTasks'; - -export const TaskRenders: Record = { - [TaskApiName.createTask]: CreateTaskRender as BuiltinRender, - [TaskApiName.runTasks]: RunTasksRender as BuiltinRender, - /* only the APIs with rich result UI — others fall back to text content */ -}; - -export { default as CreateTaskRender } from './CreateTask'; -export { default as RunTasksRender } from './RunTasks'; -``` - -### Render display control (rare) - -If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern. - ---- - -## 3. Placeholder — Skeleton Between Args and Result (optional) - -**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag. - -**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator). - -### Props (`BuiltinPlaceholderProps`) - -```ts -interface BuiltinPlaceholderProps = any> { - apiName: string; - args?: T; - identifier: string; -} -``` - -No `pluginState` — Placeholder lives entirely in the "executing" gap. - -### Canonical example — Search Placeholder - -`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`: - -```tsx -import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types'; -import { Flexbox, Icon, Skeleton } from '@lobehub/ui'; -import { createStaticStyles, cx } from 'antd-style'; -import { SearchIcon } from 'lucide-react'; -import { memo } from 'react'; - -import { useIsMobile } from '@/hooks/useIsMobile'; -import { shinyTextStyles } from '@/styles'; - -const styles = createStaticStyles(({ css, cssVar }) => ({ - query: cx( - css` - padding: 4px 8px; - border-radius: 8px; - font-size: 12px; - color: ${cssVar.colorTextSecondary}; - &:hover { - background: ${cssVar.colorFillTertiary}; - } - `, - shinyTextStyles.shinyText, - ), -})); - -export const Search = memo>(({ args }) => { - const { query } = args || {}; - const isMobile = useIsMobile(); - - return ( - - - - - {query ? query : } - - - - - {[1, 2, 3, 4, 5].map((id) => ( - - ))} - - - ); -}); -``` - -### Placeholder rules - -- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump. -- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes. -- Embed any args you have (e.g. the query text) — context helps the user know what's loading. -- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text. - -### Placeholder registry — `client/Placeholder/index.ts` - -```ts -import { WebBrowsingApiName } from '../../types'; -import CrawlMultiPages from './CrawlMultiPages'; -import CrawlSinglePage from './CrawlSinglePage'; -import { Search } from './Search'; - -export const WebBrowsingPlaceholders = { - [WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages, - [WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage, - [WebBrowsingApiName.search]: Search, -}; - -export { CrawlMultiPages, CrawlSinglePage, Search }; -``` - ---- - -## 4. Streaming — Live Output During Execution (optional) - -**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it. - -**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells. - -### Props (`BuiltinStreamingProps`) - -```ts -interface BuiltinStreamingProps { - apiName: string; - args: Arguments; - identifier: string; - messageId: string; // use to fetch the streaming buffer from store - toolCallId: string; -} -``` - -Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar). - -### Canonical example — RunCommandStreaming - -`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`: - -```tsx -'use client'; - -import type { BuiltinStreamingProps } from '@lobechat/types'; -import { Highlighter } from '@lobehub/ui'; -import { memo } from 'react'; - -interface RunCommandParams { - command?: string; - description?: string; - timeout?: number; -} - -export const RunCommandStreaming = memo>(({ args }) => { - const { command } = args || {}; - if (!command) return null; - - return ( - - {command} - - ); -}); -RunCommandStreaming.displayName = 'RunCommandStreaming'; -``` - -For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store: - -```tsx -const buffer = useChatStore((state) => - chatToolSelectors.streamingBuffer(messageId, toolCallId)(state), -); -``` - -### Streaming rules - -- Render `null` until you have something to display (avoids flash). -- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect. -- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically. - -### Streaming registry — `client/Streaming/index.ts` - -```ts -import { LocalSystemApiName } from '../..'; -import { RunCommandStreaming } from './RunCommand'; -import { WriteFileStreaming } from './WriteFile'; - -export const LocalSystemStreamings = { - [LocalSystemApiName.runCommand]: RunCommandStreaming, - [LocalSystemApiName.writeLocalFile]: WriteFileStreaming, -}; -``` - ---- - -## 5. Intervention — Approval / Edit-Before-Run (optional) - -**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels. - -**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts. - -### Props (`BuiltinInterventionProps`) - -```ts -interface BuiltinInterventionProps { - apiName?: string; - args: Arguments; - identifier?: string; - interactionMode?: 'approval' | 'custom'; - messageId: string; - - /** Called when the user edits the args; the approve action awaits this. */ - onArgsChange?: (args: Arguments) => void | Promise; - - /** Called on approve / skip / cancel. */ - onInteractionAction?: ( - action: - | { type: 'submit'; payload: Record } - | { type: 'skip'; payload?: Record; reason?: string } - | { type: 'cancel'; payload?: Record }, - ) => Promise; - - /** Register a callback to flush pending saves before approval. Returns cleanup. */ - registerBeforeApprove?: (id: string, callback: () => void | Promise) => () => void; -} -``` - -### Canonical example — RunCommand Intervention - -`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`: - -```tsx -import type { RunCommandParams } from '@lobechat/electron-client-ipc'; -import type { BuiltinInterventionProps } from '@lobechat/types'; -import { Flexbox, Highlighter, Text } from '@lobehub/ui'; -import { memo } from 'react'; - -const RunCommand = memo>(({ args }) => { - const { description, command, timeout } = args; - return ( - - - {description && {description}} - {timeout && ( - - timeout: {formatTimeout(timeout)} - - )} - - {command && ( - - {command} - - )} - - ); -}); -export default RunCommand; -``` - -### Intervention rules - -- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.). -- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function. -- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn. -- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`). - -### Intervention registry — `client/Intervention/index.ts` - -```ts -import { LocalSystemApiName } from '../..'; -import EditLocalFile from './EditLocalFile'; -import RunCommand from './RunCommand'; -import WriteFile from './WriteFile'; -/* … */ - -export const LocalSystemInterventions = { - [LocalSystemApiName.editLocalFile]: EditLocalFile, - [LocalSystemApiName.runCommand]: RunCommand, - [LocalSystemApiName.writeLocalFile]: WriteFile, - /* one entry per API that needs approval */ -}; -``` - ---- - -## 6. Portal — Full-Screen Detail View (optional) - -**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally. - -**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions. - -### Props (`BuiltinPortalProps`) - -```ts -interface BuiltinPortalProps, State = any> { - apiName?: string; - arguments: Arguments; - identifier: string; - messageId: string; - state: State; -} -``` - -### Canonical example — Web-Browsing Portal - -`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`: - -```tsx -import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types'; -import { memo } from 'react'; - -import { WebBrowsingApiName } from '../../types'; -import PageContent from './PageContent'; -import PageContents from './PageContents'; -import Search from './Search'; - -const Portal = memo(({ arguments: args, messageId, state, apiName }) => { - switch (apiName) { - case WebBrowsingApiName.search: - return ; - - case WebBrowsingApiName.crawlSinglePage: { - const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url); - return ; - } - - case WebBrowsingApiName.crawlMultiPages: - return ( - - ); - } - return null; -}); -export default Portal; -``` - -### Portal rules - -- One Portal per tool — the file is the routing layer, subcomponents implement each API's view. -- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`). -- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport. - -### Portal registry — `packages/builtin-tools/src/portals.ts` - -```ts -import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client'; -import { type BuiltinPortal } from '@lobechat/types'; - -export const BuiltinToolsPortals: Record = { - [WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal, -}; -``` - ---- - -## 7. `client/components/` — Shared Subcomponents - -Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder. - -Examples from `web-browsing/src/client/components/`: - -- `CategoryAvatar.tsx` — search category icon -- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header) -- `SearchBar.tsx` — editable query bar (used in Render and Portal) - -Examples from `local-system/src/client/components/`: - -- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render) -- `FilePathDisplay.tsx` — path with truncation (used everywhere) - -### Rules - -- Live under `client/components/`, exported via `client/components/index.ts`. -- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal. -- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them. - ---- - -## 8. `client/index.ts` — Package Public API - -Re-exports everything the registries need plus useful types/manifest: - -```ts -// Inspector — required -export { TaskInspectors } from './Inspector'; - -// Render — only if any API has one -export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render'; - -// Placeholder / Streaming / Intervention — only if used -export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder'; -export { LocalSystemStreamings } from './Streaming'; -export { LocalSystemInterventions } from './Intervention'; - -// Portal — single export per tool -export { default as WebBrowsingPortal } from './Portal'; - -// Reusable components if other packages need them -export { CategoryAvatar, EngineAvatar, SearchBar } from './components'; - -// Re-export manifest, identifier, types for convenience -export { TaskManifest, TaskIdentifier } from '../manifest'; -export * from '../types'; -``` - ---- - -## 9. Diagnostic Quick-Lookup - -| Symptom | Surface to check | | | -| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- | -| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | | -| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback | -| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | | -| Empty result card under header | Render returned `
` instead of `null` when no data | | | -| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | | -| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | | -| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | | -| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | | -| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | | -| Wrong color shade on `` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | | diff --git a/.agents/skills/builtin-tool/references/ui/README.md b/.agents/skills/builtin-tool/references/ui/README.md new file mode 100644 index 0000000000..ba11bd0957 --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/README.md @@ -0,0 +1,36 @@ +# Tool UI Surfaces + +A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files. + +| Surface | Required? | When the chat shows it | Registered in | +| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- | +| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` | +| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` | +| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` | +| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` | +| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` | +| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` | + +The two reference tools to read end-to-end: + +- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming). +- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks. + +--- + +## Files in this folder + +Read **principles** and **shared-rules** first — they apply to every surface. Then jump to the surface you're building. + +| File | What it covers | +| ---------------------------------- | ----------------------------------------------------------------------- | +| [principles.md](principles.md) | Design principles — when each surface exists and how far to take it | +| [shared-rules.md](shared-rules.md) | Cross-surface rules: component skeleton, styling, single-layer surfaces | +| [inspector.md](inspector.md) | Inspector — header chip (required) | +| [render.md](render.md) | Render — rich result card | +| [placeholder.md](placeholder.md) | Placeholder — skeleton between args and result | +| [streaming.md](streaming.md) | Streaming — live output during execution | +| [intervention.md](intervention.md) | Intervention — approval / edit-before-run | +| [portal.md](portal.md) | Portal — full-screen detail view | +| [composition.md](composition.md) | Shared subcomponents (`client/components/`) + package public API | +| [diagnostics.md](diagnostics.md) | Symptom → surface quick-lookup | diff --git a/.agents/skills/builtin-tool/references/ui/composition.md b/.agents/skills/builtin-tool/references/ui/composition.md new file mode 100644 index 0000000000..f06aecceff --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/composition.md @@ -0,0 +1,51 @@ +# Composition — Shared Components & Package API + +## `client/components/` — Shared Subcomponents + +Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder. + +Examples from `web-browsing/src/client/components/`: + +- `CategoryAvatar.tsx` — search category icon +- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header) +- `SearchBar.tsx` — editable query bar (used in Render and Portal) + +Examples from `local-system/src/client/components/`: + +- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render) +- `FilePathDisplay.tsx` — path with truncation (used everywhere) + +### Rules + +- Live under `client/components/`, exported via `client/components/index.ts`. +- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal. +- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them. + +--- + +## `client/index.ts` — Package Public API + +Re-exports everything the registries need plus useful types/manifest: + +```ts +// Inspector — required +export { TaskInspectors } from './Inspector'; + +// Render — only if any API has one +export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render'; + +// Placeholder / Streaming / Intervention — only if used +export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder'; +export { LocalSystemStreamings } from './Streaming'; +export { LocalSystemInterventions } from './Intervention'; + +// Portal — single export per tool +export { default as WebBrowsingPortal } from './Portal'; + +// Reusable components if other packages need them +export { CategoryAvatar, EngineAvatar, SearchBar } from './components'; + +// Re-export manifest, identifier, types for convenience +export { TaskManifest, TaskIdentifier } from '../manifest'; +export * from '../types'; +``` diff --git a/.agents/skills/builtin-tool/references/ui/diagnostics.md b/.agents/skills/builtin-tool/references/ui/diagnostics.md new file mode 100644 index 0000000000..2e13f1d42a --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/diagnostics.md @@ -0,0 +1,15 @@ +# Diagnostic Quick-Lookup + +| Symptom | Surface to check | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | +| Header shows the API name but no chips | Inspector missing `args?.X \|\| partialArgs?.X` fallback | +| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | +| Empty result card under header | Render returned `
` instead of `null` when no data | +| Render looks "complex" / card-in-card | Filled container (`colorFillQuaternary`) wrapping more filled boxes — flatten to single-layer, see [shared-rules.md](shared-rules.md) | +| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | +| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | +| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | +| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | +| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | +| Wrong color shade on `` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | diff --git a/.agents/skills/builtin-tool/references/ui/inspector.md b/.agents/skills/builtin-tool/references/ui/inspector.md new file mode 100644 index 0000000000..81881c09d4 --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/inspector.md @@ -0,0 +1,118 @@ +# Inspector — Header Chip (required) + +**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible. + +**Goal:** keep it to a single line. Show what's happening with as much context as is currently available. + +## Props (`BuiltinInspectorProps`) + +```ts +interface BuiltinInspectorProps { + apiName: string; + args: Arguments; // final args (only after the assistant stops streaming) + identifier: string; + isArgumentsStreaming?: boolean; // args still arriving + isLoading?: boolean; // args complete, executor running + partialArgs?: Arguments; // partial JSON during streaming + pluginState?: State; // executor's `state` after success + result?: { content: string | null; error?: any }; +} +``` + +## State machine + +| Phase | What's available | What to show | +| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | +| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` | +| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated | +| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated | +| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) | + +## Canonical example — Search + +`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`: + +```tsx +'use client'; + +import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types'; +import { Text } from '@lobehub/ui'; +import { cssVar, cx } from 'antd-style'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles'; + +export const SearchInspector = memo>( + ({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => { + const { t } = useTranslation('plugin'); + + const query = args?.query || partialArgs?.query || ''; + const resultCount = pluginState?.results?.length ?? 0; + const hasResults = resultCount > 0; + + if (isArgumentsStreaming && !query) { + return ( +
+ {t('builtins.lobe-web-browsing.apiName.search')} +
+ ); + } + + return ( +
+ {t('builtins.lobe-web-browsing.apiName.search')}:  + {query && {query}} + {!isLoading && + !isArgumentsStreaming && + pluginState?.results && + (hasResults ? ( + ({resultCount}) + ) : ( + + ({t('builtins.lobe-web-browsing.inspector.noResults')}) + + ))} +
+ ); + }, +); +SearchInspector.displayName = 'SearchInspector'; +export default SearchInspector; +``` + +## Inspector rules + +- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline). +- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`. +- Show the i18n title first so the row is non-empty during the earliest streaming phase. +- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream. +- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble. +- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching. +- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `.loading` and `.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern. + +## Inspector registry — `client/Inspector/index.ts` + +```ts +import type { BuiltinInspector } from '@lobechat/types'; + +import { TaskApiName } from '../../types'; +import { CreateTaskInspector } from './CreateTask'; +import { ListTasksInspector } from './ListTasks'; +/* … */ + +export const TaskInspectors: Record = { + [TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector, + [TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector, + /* one entry per ApiName */ +}; + +export { CreateTaskInspector } from './CreateTask'; +export { ListTasksInspector } from './ListTasks'; +/* re-export each */ +``` diff --git a/.agents/skills/builtin-tool/references/ui/intervention.md b/.agents/skills/builtin-tool/references/ui/intervention.md new file mode 100644 index 0000000000..3072c940bb --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/intervention.md @@ -0,0 +1,88 @@ +# Intervention — Approval / Edit-Before-Run (optional) + +**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels. + +**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts. + +## Props (`BuiltinInterventionProps`) + +```ts +interface BuiltinInterventionProps { + apiName?: string; + args: Arguments; + identifier?: string; + interactionMode?: 'approval' | 'custom'; + messageId: string; + + /** Called when the user edits the args; the approve action awaits this. */ + onArgsChange?: (args: Arguments) => void | Promise; + + /** Called on approve / skip / cancel. */ + onInteractionAction?: ( + action: + | { type: 'submit'; payload: Record } + | { type: 'skip'; payload?: Record; reason?: string } + | { type: 'cancel'; payload?: Record }, + ) => Promise; + + /** Register a callback to flush pending saves before approval. Returns cleanup. */ + registerBeforeApprove?: (id: string, callback: () => void | Promise) => () => void; +} +``` + +## Canonical example — RunCommand Intervention + +`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`: + +```tsx +import type { RunCommandParams } from '@lobechat/electron-client-ipc'; +import type { BuiltinInterventionProps } from '@lobechat/types'; +import { Flexbox, Highlighter, Text } from '@lobehub/ui'; +import { memo } from 'react'; + +const RunCommand = memo>(({ args }) => { + const { description, command, timeout } = args; + return ( + + + {description && {description}} + {timeout && ( + + timeout: {formatTimeout(timeout)} + + )} + + {command && ( + + {command} + + )} + + ); +}); +export default RunCommand; +``` + +## Intervention rules + +- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.). +- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function. +- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn. +- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`). + +## Intervention registry — `client/Intervention/index.ts` + +```ts +import { LocalSystemApiName } from '../..'; +import EditLocalFile from './EditLocalFile'; +import RunCommand from './RunCommand'; +import WriteFile from './WriteFile'; +/* … */ + +export const LocalSystemInterventions = { + [LocalSystemApiName.editLocalFile]: EditLocalFile, + [LocalSystemApiName.runCommand]: RunCommand, + [LocalSystemApiName.writeLocalFile]: WriteFile, + /* one entry per API that needs approval */ +}; +``` diff --git a/.agents/skills/builtin-tool/references/ui/placeholder.md b/.agents/skills/builtin-tool/references/ui/placeholder.md new file mode 100644 index 0000000000..fd0b68a11f --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/placeholder.md @@ -0,0 +1,93 @@ +# Placeholder — Skeleton Between Args and Result (optional) + +**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag. + +**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator). + +## Props (`BuiltinPlaceholderProps`) + +```ts +interface BuiltinPlaceholderProps = any> { + apiName: string; + args?: T; + identifier: string; +} +``` + +No `pluginState` — Placeholder lives entirely in the "executing" gap. + +## Canonical example — Search Placeholder + +`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`: + +```tsx +import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types'; +import { Flexbox, Icon, Skeleton } from '@lobehub/ui'; +import { createStaticStyles, cx } from 'antd-style'; +import { SearchIcon } from 'lucide-react'; +import { memo } from 'react'; + +import { useIsMobile } from '@/hooks/useIsMobile'; +import { shinyTextStyles } from '@/styles'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + query: cx( + css` + padding: 4px 8px; + border-radius: 8px; + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + &:hover { + background: ${cssVar.colorFillTertiary}; + } + `, + shinyTextStyles.shinyText, + ), +})); + +export const Search = memo>(({ args }) => { + const { query } = args || {}; + const isMobile = useIsMobile(); + + return ( + + + + + {query ? query : } + + + + + {[1, 2, 3, 4, 5].map((id) => ( + + ))} + + + ); +}); +``` + +## Placeholder rules + +- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump. +- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes. +- Embed any args you have (e.g. the query text) — context helps the user know what's loading. +- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text. + +## Placeholder registry — `client/Placeholder/index.ts` + +```ts +import { WebBrowsingApiName } from '../../types'; +import CrawlMultiPages from './CrawlMultiPages'; +import CrawlSinglePage from './CrawlSinglePage'; +import { Search } from './Search'; + +export const WebBrowsingPlaceholders = { + [WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages, + [WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage, + [WebBrowsingApiName.search]: Search, +}; + +export { CrawlMultiPages, CrawlSinglePage, Search }; +``` diff --git a/.agents/skills/builtin-tool/references/ui/portal.md b/.agents/skills/builtin-tool/references/ui/portal.md new file mode 100644 index 0000000000..4cb846981d --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/portal.md @@ -0,0 +1,71 @@ +# Portal — Full-Screen Detail View (optional) + +**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally. + +**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions. + +## Props (`BuiltinPortalProps`) + +```ts +interface BuiltinPortalProps, State = any> { + apiName?: string; + arguments: Arguments; + identifier: string; + messageId: string; + state: State; +} +``` + +## Canonical example — Web-Browsing Portal + +`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`: + +```tsx +import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types'; +import { memo } from 'react'; + +import { WebBrowsingApiName } from '../../types'; +import PageContent from './PageContent'; +import PageContents from './PageContents'; +import Search from './Search'; + +const Portal = memo(({ arguments: args, messageId, state, apiName }) => { + switch (apiName) { + case WebBrowsingApiName.search: + return ; + + case WebBrowsingApiName.crawlSinglePage: { + const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url); + return ; + } + + case WebBrowsingApiName.crawlMultiPages: + return ( + + ); + } + return null; +}); +export default Portal; +``` + +## Portal rules + +- One Portal per tool — the file is the routing layer, subcomponents implement each API's view. +- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`). +- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport. + +## Portal registry — `packages/builtin-tools/src/portals.ts` + +```ts +import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client'; +import { type BuiltinPortal } from '@lobechat/types'; + +export const BuiltinToolsPortals: Record = { + [WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal, +}; +``` diff --git a/.agents/skills/builtin-tool/references/ui/principles.md b/.agents/skills/builtin-tool/references/ui/principles.md new file mode 100644 index 0000000000..2f07268148 --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/principles.md @@ -0,0 +1,19 @@ +# Tool Render 设计原则(中文草案) + +这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。 + +1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。 +2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。 +3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args`、`partialArgs` 和 `pluginState`,避免出现空白、跳变或只显示半截参数。 +4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `.loading` / `.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}` 与 `lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。 +5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。 +6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。 +7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。 +8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。 +9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。 +10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。 +11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。 +12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。 +13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox`、`createStaticStyles` 和 `cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。具体的样式约定见 [shared-rules.md](shared-rules.md)。 +14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。 +15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。 diff --git a/.agents/skills/builtin-tool/references/ui/render.md b/.agents/skills/builtin-tool/references/ui/render.md new file mode 100644 index 0000000000..aaa40c11bd --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/render.md @@ -0,0 +1,101 @@ +# Render — Rich Result Card (optional) + +**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header. + +**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files. + +## Props (`BuiltinRenderProps`) + +```ts +interface BuiltinRenderProps { + apiName?: string; + args: Arguments; // final params from the LLM + content: Content; // executor's content string (or parsed) + identifier?: string; + messageId: string; // for store lookups + pluginError?: any; // from BuiltinToolResult.error + pluginState?: State; // executor's state + toolCallId?: string; +} +``` + +## Two patterns + +**Pattern A — Single-file Render** (web-browsing CrawlSinglePage): + +```tsx +// client/Render/CrawlSinglePage.tsx +import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types'; +import { memo } from 'react'; + +import PageContent from './PageContent'; + +const CrawlSinglePage = memo>( + ({ messageId, pluginState, args }) => ( + + ), +); +export default CrawlSinglePage; +``` + +**Pattern B — Folder with subcomponents** (web-browsing Search): + +``` +client/Render/Search/ +├── index.tsx # composes the subcomponents, handles error states +├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid' +├── SearchQuery.tsx # editable query header +└── SearchResult.tsx # result list +``` + +Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting. + +## Error handling in Render + +Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors: + +```tsx +if (pluginError) { + if (pluginError?.type === 'PluginSettingsInvalid') { + return ; + } + return ( + {JSON.stringify(pluginError.body, null, 2)}} + /> + ); +} +``` + +## Render rules + +- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream). +- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.** +- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything. +- **Keep the Render single-layer** — the tool card is already your surface, so don't open with your own filled container and then nest more filled boxes inside it. See [shared-rules.md](shared-rules.md) → "Stay single-layer". +- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill. + +## Render registry — `client/Render/index.ts` + +```ts +import type { BuiltinRender } from '@lobechat/types'; + +import { TaskApiName } from '../../types'; +import CreateTaskRender from './CreateTask'; +import RunTasksRender from './RunTasks'; + +export const TaskRenders: Record = { + [TaskApiName.createTask]: CreateTaskRender as BuiltinRender, + [TaskApiName.runTasks]: RunTasksRender as BuiltinRender, + /* only the APIs with rich result UI — others fall back to text content */ +}; + +export { default as CreateTaskRender } from './CreateTask'; +export { default as RunTasksRender } from './RunTasks'; +``` + +## Render display control (rare) + +If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern. diff --git a/.agents/skills/builtin-tool/references/ui/shared-rules.md b/.agents/skills/builtin-tool/references/ui/shared-rules.md new file mode 100644 index 0000000000..b96c20e102 --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/shared-rules.md @@ -0,0 +1,89 @@ +# Shared Style Rules + +These apply across every surface. + +## The component skeleton + +Every surface file is the same shape, so internalize it once instead of re-deriving it per rule. The skeleton below bakes in five mechanical conventions — copy it and fill the body: + +```tsx +'use client'; // (a) leaves of the chat tree must not block server rendering + +import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +// (b) type with BuiltinXProps — never widen to `any`. +// Args = the JSON Schema params, State = the executor's `state` field; +// they should match Params / State from types.ts. +export const SearchInspector = memo>( + ({ args, pluginState }) => { + const { t } = useTranslation('plugin'); // (c) all strings from the `plugin` namespace + + // (d) cross-cutting state (loading, streaming buffer) comes from the store, + // not props — props only carry args/state/messageId. + // const buffer = useChatStore((s) => chatToolSelectors.streamingBuffer(messageId)(s)); + + return {t('builtins..apiName.search')}; + }, +); +SearchInspector.displayName = 'SearchInspector'; // (e) always memo + displayName +export default SearchInspector; +``` + +- **(c)** Default an Inspector to `t('builtins..apiName.')` so the row is non-empty while args stream in. +- **(d)** Read the store via Zustand selectors inside the component; see [streaming.md](streaming.md) for the buffer selector. + +## Styling: `createStaticStyles + cssVar.*`, `@lobehub/ui` over `antd` + +Zero-runtime CSS-in-JS — styles compile once and read CSS variables at runtime: + +```tsx +import { createStaticStyles, cssVar } from 'antd-style'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + chip: css` + padding-block: 2px; + padding-inline: 8px; + border-radius: 999px; + color: ${cssVar.colorText}; + background: ${cssVar.colorFillTertiary}; + `, +})); +``` + +- Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values. +- Components come from `@lobehub/ui` (`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton`), not raw `antd`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill. +- Note: `` is a lighter shade than `colorTextSecondary`. For that exact token color, write ``. + +## Stay single-layer — don't nest filled cards + +The framework already wraps every Render / Intervention in a tool card, so that card **is** your surface. A Render that opens with its own `background: ${cssVar.colorFillQuaternary}` container is already one card deep; put another filled box inside it (`colorBgContainer` / `colorFillTertiary`) and you get the card-in-card look that reads as "complex" — two or three stacked fills for what is really a flat list of fields. + +- **The outermost wrapper carries no fill.** Use a flat container with only `padding-block: 4px` for breathing room; let the tool card provide the card. (See `Agent/index.tsx`'s `container`.) +- **At most one filled box, and only to delineate real content** — a Markdown preview, a diff, a code/result block. Labels, key–value fields, question/answer text, chips: render flat on the surface, separated by spacing or a hairline divider (`height: 1px; background: ${cssVar.colorFillSecondary}`), not by wrapping each in its own box. +- **A box on a flat surface needs a visible fill.** Once the outer fill is gone, an inner `colorBgContainer` box can vanish against the tool card (same color). Use `colorFillTertiary` for the one content box so it still reads as delineated. +- Don't wrap a single value in a box just to give it padding — that's the redundant-nesting smell (a `detailCard` around a `value` box around one string). + +```tsx +// ❌ card-in-card: filled container wrapping a filled preview box +container: css` + padding: 12px; + background: ${cssVar.colorFillQuaternary}; +`, +previewBox: css` + background: ${cssVar.colorBgContainer}; +`, + +// ✅ single-layer: flat container, one visible content box +container: css` + padding-block: 4px; +`, +previewBox: css` + background: ${cssVar.colorFillTertiary}; +`, +``` + +For the common "icon + file/title header, then one content box" shape, reuse `ToolResultCard` from `@lobechat/shared-tool-ui/components` instead of rebuilding it — it's already single-layer (flat wrapper, one `colorFillTertiary` content box) and is what CC `Read` / `Grep` / `Glob` / `Write` / `WebSearch` / `WebFetch` render through. + +The exception is a deliberate **panel** pattern — an `` with a header bar + list rows (CC `TodoWrite` / `Task`). There the single outlined block is the panel and the header fill is a header bar, not a nested card. One structured panel is fine; stacked decorative fills are not. diff --git a/.agents/skills/builtin-tool/references/ui/streaming.md b/.agents/skills/builtin-tool/references/ui/streaming.md new file mode 100644 index 0000000000..65f5e3eb73 --- /dev/null +++ b/.agents/skills/builtin-tool/references/ui/streaming.md @@ -0,0 +1,83 @@ +# Streaming — Live Output During Execution (optional) + +**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it. + +**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells. + +## Props (`BuiltinStreamingProps`) + +```ts +interface BuiltinStreamingProps { + apiName: string; + args: Arguments; + identifier: string; + messageId: string; // use to fetch the streaming buffer from store + toolCallId: string; +} +``` + +Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar). + +## Canonical example — RunCommandStreaming + +`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`: + +```tsx +'use client'; + +import type { BuiltinStreamingProps } from '@lobechat/types'; +import { Highlighter } from '@lobehub/ui'; +import { memo } from 'react'; + +interface RunCommandParams { + command?: string; + description?: string; + timeout?: number; +} + +export const RunCommandStreaming = memo>(({ args }) => { + const { command } = args || {}; + if (!command) return null; + + return ( + + {command} + + ); +}); +RunCommandStreaming.displayName = 'RunCommandStreaming'; +``` + +For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store: + +```tsx +const buffer = useChatStore((state) => + chatToolSelectors.streamingBuffer(messageId, toolCallId)(state), +); +``` + +## Streaming rules + +- Render `null` until you have something to display (avoids flash). +- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect. +- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically. + +## Streaming registry — `client/Streaming/index.ts` + +```ts +import { LocalSystemApiName } from '../..'; +import { RunCommandStreaming } from './RunCommand'; +import { WriteFileStreaming } from './WriteFile'; + +export const LocalSystemStreamings = { + [LocalSystemApiName.runCommand]: RunCommandStreaming, + [LocalSystemApiName.writeLocalFile]: WriteFileStreaming, +}; +``` diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index 51bc364898..b2aa649979 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -1,6 +1,6 @@ --- name: pr -description: "Create a PR for the current branch (targets `canary` by default). Use when the user asks to create a pull request, submit a PR, or says 'pr'. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', '提 PR', '提个 PR', '新建 PR'." +description: "Create a PR for the current branch (targets `canary` by default), including splitting one cross-layer branch into ordered stacked PRs so a lower layer (db / shared package / server TRPC) merges before its callers (desktop / CLI / UI). Use when the user asks to create / submit a PR, or to split a branch because clients call a server contract that isn't on the trunk yet. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', 'split this PR', 'stacked PR', 'backend should merge first', '提 PR', '提个 PR', '新建 PR', '拆 PR', '后端先合', '分层合并'." user-invocable: true --- @@ -71,3 +71,82 @@ Use `.github/PULL_REQUEST_TEMPLATE.md` as the body structure. Key sections: - **Language**: All PR content must be in English - If a PR already exists for the branch, inform the user instead of creating a duplicate + +--- + +# Stacked PRs (cross-layer feature) + +The steps above create **one** PR for the current branch. When a single branch lands across layers — `packages/database` schema/model → a shared `packages/*` lib → `src/server` TRPC → `apps/desktop` + `apps/cli` callers → `src/features` UI — shipping it as one PR can't merge safely: the clients call an endpoint that doesn't exist on the trunk until the same PR merges, so any partial/rollback or independent review breaks. Split it into **ordered PRs**, lower layer first. + +## The ordering rule + +A PR may only merge **after** every layer it calls is already on the trunk. + +- The **server contract** (new TRPC procedure, changed return shape, new table/model) merges first. +- The **callers** (desktop, CLI, UI) merge after — they invoke that contract. +- Tie-break with one question: _"if this merged alone to `canary` right now, would it build and behave?"_ If no, it belongs in a later PR. + +## Which file goes in which PR + +The non-obvious calls: + +- **Frontend that adapts to a contract change goes WITH the server PR.** If you widen a TRPC return shape (e.g. `listDevices` now returns `platform: string | null`), the component consuming it must change in the _same_ PR — otherwise the server PR breaks the build on its own. Contract + its in-repo consumers ship together. +- **A new shared package goes with its consumer**, not the server, unless the server imports it too. A `@lobechat/*` package imported only by desktop/CLI ships in the client PR. Don't carry an unused package in the lower PR. +- **Workspace dep declarations** (`package.json` `workspace:*`, `pnpm-workspace.yaml`) travel with the code that imports the package. + +## The git recipe — split an existing full branch + +Starting point: one branch (`feat/x`) with a single commit `` containing everything, already pushed (so it's also safe on the remote). + +```bash +# 1. Safety nets — make the full work unloseable before rewriting anything +git branch backup/x-full # local ref to the full commit +git branch feat/x-clients # the higher-layer branch starts here + +# 2. Rewrite the lower-layer branch to lower-layer files only +git checkout feat/x # this becomes the SERVER PR +git reset --hard origin/canary +git checkout -- # stages just those paths +git commit -m "✨ feat(...): " +git push --force-with-lease origin feat/x # never --force; never push to canary + +# 3. Build the higher-layer branch STACKED on the lower branch +git checkout feat/x-clients +git reset --hard feat/x # base = the just-rewritten server HEAD +git checkout backup/x-full -- # only the remaining paths +git commit -m "✨ feat(...): " +git push -u origin feat/x-clients +``` + +Then open the higher PR **based on the lower branch**, not the trunk: + +```bash +gh pr create --base feat/x --head feat/x-clients --title "…" --body "…" +``` + +`--base feat/x` keeps the diff client-only (no server files leak in) and makes it physically impossible to merge the clients before the server. **After the server PR merges to `canary`, retarget the client PR's base to `canary`** (GitHub usually auto-retargets when the base branch merges; note it in the PR body so a human confirms). + +## Verify the dependency actually holds + +The whole point is the higher layer needs the lower one. Prove it: on the stacked higher branch, type-check the caller and confirm the symbol the lower layer introduced resolves. + +```bash +cd apps/cli && bun run type-check 2>&1 | grep -iE "connect\.ts|device\.register" +# empty (re: your change) = the stacked base supplies device.register ✓ +``` + +Filter to your touched files — this repo's standalone type-check emits pre-existing env noise (`__ELECTRON__`, `@/types/llm`, unbuilt `@lobechat/types`) that isn't yours. + +## PR + Linear bookkeeping + +- **Each PR closes only its own layer's issues.** Server PR: `Closes LOBE-`. Client PR: `Closes LOBE- / / `. Don't let one PR's body claim another layer's issue. +- Both PRs are `Part of LOBE-`. +- On PR creation, move each closed sub-issue to **In Review** (not Done) and add a completion comment — see the `linear` skill. + +## Gotchas + +- **Never push to `canary`.** A split branch cut with `git checkout -b feat/x origin/canary` _tracks_ `origin/canary`, so a bare `git push` targets canary. Always `git push origin feat/x` with the explicit branch name. +- **`--force-with-lease`, not `--force`** when rewriting the lower branch — it aborts if the remote moved under you. +- **Back up before `reset --hard`.** Step 1's `backup/x-full` + the pushed remote branch mean the full commit is referenced by ≥3 refs before you rewrite anything. Verify with `git branch --contains `. +- **Lockfiles:** this monorepo commits no root `pnpm-lock.yaml`, so a new `workspace:*` dep needs no lockfile churn. In a repo that _does_ commit one, regenerate it on each branch after the split. +- **Don't over-split.** Two PRs (contract / callers) is usually enough. A UI page that only reads an existing endpoint can be its own later PR, but don't fragment a single layer across PRs for its own sake. diff --git a/apps/desktop/resources/locales/zh-CN/menu.json b/apps/desktop/resources/locales/zh-CN/menu.json index 40a679f2cf..9ac727b100 100644 --- a/apps/desktop/resources/locales/zh-CN/menu.json +++ b/apps/desktop/resources/locales/zh-CN/menu.json @@ -56,9 +56,11 @@ "help.about": "关于", "help.githubRepo": "GitHub 仓库", "help.openConfigDir": "配置目录", + "help.openHeteroAgentDir": "打开 HeteroAgent 目录", "help.openLogsDir": "打开日志目录", "help.reportIssue": "反馈问题", "help.title": "帮助", + "help.toggleHeteroTracing": "记录 Agent CLI 调试日志", "help.visitWebsite": "打开官网", "history.back": "后退", "history.forward": "前进", diff --git a/apps/desktop/src/main/__mocks__/electron.ts b/apps/desktop/src/main/__mocks__/electron.ts new file mode 100644 index 0000000000..ca1513ae85 --- /dev/null +++ b/apps/desktop/src/main/__mocks__/electron.ts @@ -0,0 +1,68 @@ +/** + * Default global `electron` mock (registered in `setup.ts`). + * + * Provides a fully-formed `app` (paths + readiness) plus light stubs for the + * other commonly-imported namespaces. The point is that modules which touch + * electron at import time — notably `@/const/dir`'s eager `app.getAppPath()` / + * `app.getPath('userData')` — can be imported from ANY test without each suite + * re-stubbing these basics. This keeps production code free to use plain + * value-style path constants instead of lazy getter functions. + * + * Test files that need specific behavior still declare their own + * `vi.mock('electron', …)`, which takes precedence per-file over this default. + */ +import { vi } from 'vitest'; + +export const app = { + getAppPath: vi.fn(() => '/mock/app'), + getLocale: vi.fn(() => 'en-US'), + getName: vi.fn(() => 'LobeHub'), + getPath: vi.fn((name: string) => `/mock/${name}`), + getVersion: vi.fn(() => '0.0.0-test'), + isPackaged: false, + on: vi.fn(), + quit: vi.fn(), + requestSingleInstanceLock: vi.fn(() => true), + setName: vi.fn(), + whenReady: vi.fn(() => Promise.resolve()), +}; + +export const BrowserWindow = Object.assign(vi.fn(), { + getAllWindows: vi.fn(() => []), + getFocusedWindow: vi.fn(() => null), +}); + +export const Menu = { + buildFromTemplate: vi.fn(() => ({})), + setApplicationMenu: vi.fn(), +}; + +export const ipcMain = { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn() }; + +export const shell = { + openExternal: vi.fn(() => Promise.resolve()), + openPath: vi.fn(() => Promise.resolve('')), +}; + +export const dialog = { showMessageBox: vi.fn(), showOpenDialog: vi.fn() }; + +export const nativeTheme = { on: vi.fn(), shouldUseDarkColors: false, themeSource: 'system' }; + +export const protocol = { handle: vi.fn(), registerSchemesAsPrivileged: vi.fn() }; + +export const clipboard = { readText: vi.fn(() => ''), writeText: vi.fn() }; + +export const nativeImage = { createEmpty: vi.fn(), createFromPath: vi.fn() }; + +export default { + app, + BrowserWindow, + clipboard, + dialog, + ipcMain, + Menu, + nativeImage, + nativeTheme, + protocol, + shell, +}; diff --git a/apps/desktop/src/main/__mocks__/setup.ts b/apps/desktop/src/main/__mocks__/setup.ts index 48b9b48e1a..4240efedfe 100644 --- a/apps/desktop/src/main/__mocks__/setup.ts +++ b/apps/desktop/src/main/__mocks__/setup.ts @@ -5,3 +5,9 @@ import { vi } from 'vitest'; // Mock node-mac-permissions before any imports vi.mock('node-mac-permissions', () => import('./node-mac-permissions')); + +// Default electron mock: gives every suite a ready `app` (paths + readiness) +// so modules with import-time electron access (e.g. `@/const/dir`) load safely +// without per-suite stubbing. A test's own `vi.mock('electron', …)` overrides +// this per-file. +vi.mock('electron', () => import('./electron')); diff --git a/apps/desktop/src/main/const/heteroAgent.ts b/apps/desktop/src/main/const/heteroAgent.ts new file mode 100644 index 0000000000..7f735e275a --- /dev/null +++ b/apps/desktop/src/main/const/heteroAgent.ts @@ -0,0 +1,13 @@ +/** + * Heterogeneous-agent (CC / Codex) working-directory segment names, relative to + * `appStoragePath`. Kept in this side-effect-free module (no electron import) + * so lightweight importers — the menu impls, the controller — get a single + * source of truth without dragging in `@/const/dir`'s load-time `app.getPath` + * calls. + * + * - `/files` — downloaded-file cache + * - `/tracing` — CLI trace sessions (packaged / opted-in) + */ +export const HETERO_AGENT_DIR = 'heteroAgent'; +export const HETERO_AGENT_FILES_DIR = `${HETERO_AGENT_DIR}/files`; +export const HETERO_AGENT_TRACING_DIR = `${HETERO_AGENT_DIR}/tracing`; diff --git a/apps/desktop/src/main/const/store.ts b/apps/desktop/src/main/const/store.ts index f6b9443b02..ec9c13877a 100644 --- a/apps/desktop/src/main/const/store.ts +++ b/apps/desktop/src/main/const/store.ts @@ -34,6 +34,7 @@ export const STORE_DEFAULTS: ElectronMainStore = { gatewayDeviceName: '', gatewayEnabled: true, gatewayUrl: 'https://device-gateway.lobehub.com', + heteroTracingEnabled: false, imessageBridgeConfigs: [], locale: 'auto', localFileWorkspaceRoots: [], diff --git a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts index fcc4c82889..71098b106e 100644 --- a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts +++ b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts @@ -206,6 +206,7 @@ export default class GatewayConnectionCtr extends ControllerModule { prompt: request.prompt, resumeSessionId: request.resumeSessionId, serverUrl, + systemContext: request.systemContext, topicId: request.topicId, }); diff --git a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts index f0282ebc1b..ebd01bf592 100644 --- a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts +++ b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts @@ -28,6 +28,7 @@ import { } from '@lobechat/heterogeneous-agents/spawn'; import { app as electronApp, BrowserWindow } from 'electron'; +import { HETERO_AGENT_FILES_DIR, HETERO_AGENT_TRACING_DIR } from '@/const/heteroAgent'; import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent'; import type { HeterogeneousAgentBuildPlan, @@ -62,7 +63,7 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [ ] as const; /** Directory under appStoragePath for caching downloaded files */ -const FILE_CACHE_DIR = 'heteroAgent/files'; +const FILE_CACHE_DIR = HETERO_AGENT_FILES_DIR; const CLI_TRACE_DIR = '.heerogeneous-tracing'; const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...'; const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/; @@ -434,7 +435,32 @@ export default class HeterogeneousAgentCtr extends ControllerModule { } private get shouldTraceCliOutput(): boolean { - return process.env.NODE_ENV !== 'test' && !electronApp.isPackaged; + if (process.env.NODE_ENV === 'test') return false; + // Dev builds always trace. Packaged builds trace only when the user has + // flipped the Help-menu developer toggle — so production issues can be + // captured on demand without polluting normal runs. + if (!electronApp.isPackaged) return true; + return this.app.storeManager.get('heteroTracingEnabled', false); + } + + /** + * Root directory for CLI trace sessions. + * + * When the user has explicitly opted in via the `heteroTracingEnabled` + * Help-menu toggle, centralize traces under the app storage dir + * (`/heteroAgent/tracing`) — this is the only path packaged + * builds ever trace through, and it keeps traces out of the user's real + * project directory while staying reachable from one stable Help-menu entry. + * + * Otherwise (a plain dev run with the toggle off) keep writing into the + * working directory (`cwd/.heerogeneous-tracing`) — devs expect traces to + * show up alongside the repo they're running in. + */ + private resolveTraceRootDir(cwd: string): string { + if (this.app.storeManager.get('heteroTracingEnabled', false)) { + return path.join(this.app.appStoragePath, HETERO_AGENT_TRACING_DIR); + } + return path.join(cwd, CLI_TRACE_DIR); } private formatTraceTimestamp(date: Date): string { @@ -501,7 +527,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule { } const createdAt = new Date(); - const rootDir = path.join(cwd, CLI_TRACE_DIR); + const rootDir = this.resolveTraceRootDir(cwd); const agentDir = path.join(rootDir, this.sanitizeTracePathSegment(session.agentType)); const traceId = `${this.formatTraceTimestamp(createdAt)}-${this.sanitizeTracePathSegment( session.sessionId, @@ -1266,10 +1292,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule { prompt: string; resumeSessionId?: string; serverUrl: string; + systemContext?: string; topicId: string; }): void { - const { agentType, cwd, jwt, operationId, prompt, resumeSessionId, serverUrl, topicId } = - params; + const { + agentType, + cwd, + jwt, + operationId, + prompt, + resumeSessionId, + serverUrl, + systemContext, + topicId, + } = params; const workDir = cwd ?? process.cwd(); const args = [ @@ -1305,7 +1341,17 @@ export default class HeterogeneousAgentCtr extends ControllerModule { stdio: ['pipe', 'inherit', 'inherit'], }); - child.stdin.write(JSON.stringify(prompt)); + // When systemContext is provided, send a content-block array so CC sees the + // context block first, then the user's actual message — mirrors + // spawnHeteroSandbox. lh handles JSON arrays via coerceJsonPrompt, so no lh + // changes are required. + const stdinPayload = systemContext + ? JSON.stringify([ + { text: systemContext, type: 'text' }, + { text: prompt, type: 'text' }, + ]) + : JSON.stringify(prompt); + child.stdin.write(stdinPayload); child.stdin.end(); child.on('error', (err) => { diff --git a/apps/desktop/src/main/controllers/__tests__/GatewayConnectionCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/GatewayConnectionCtr.test.ts index eb223befba..02b06eb355 100644 --- a/apps/desktop/src/main/controllers/__tests__/GatewayConnectionCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/GatewayConnectionCtr.test.ts @@ -95,6 +95,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => { operationId = 'op-1', prompt = 'hello', jwt = 'mock-jwt', + extra: Record = {}, ) { this.emit('agent_run_request', { agentType, @@ -103,6 +104,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => { prompt, topicId: 'topic-1', type: 'agent_run_request', + ...extra, }); } @@ -733,6 +735,22 @@ describe('GatewayConnectionCtr', () => { }, ); + it('forwards cwd and systemContext from the request to spawnLhHeteroExec', async () => { + const client = await connectAndOpen(); + client.simulateAgentRunRequest('claude-code', 'op-ctx', 'hi', 'mock-jwt', { + cwd: '/Users/alice/repo', + systemContext: 'WORKSPACE CONTEXT', + }); + await vi.advanceTimersByTimeAsync(0); + + expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/Users/alice/repo', + systemContext: 'WORKSPACE CONTEXT', + }), + ); + }); + it('sends accepted ack and spawns lh hetero exec', async () => { const client = await connectAndOpen(); client.simulateAgentRunRequest('openclaw', 'op-xyz'); diff --git a/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts index 1ad0aba249..b415724685 100644 --- a/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts @@ -5,6 +5,9 @@ import path from 'node:path'; import { PassThrough } from 'node:stream'; import { HeterogeneousAgentSessionErrorCode } from '@lobechat/electron-client-ipc'; +// `electron` is mocked below; this binding is the mock object so tests can +// flip `isPackaged` to exercise the packaged-build tracing gate. +import { app as electronAppMock } from 'electron'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr'; @@ -23,6 +26,7 @@ const { mockGetAllWindows } = vi.hoisted(() => ({ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => mockGetAllWindows() }, app: { + getAppPath: vi.fn(() => '/fake/appPath'), getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)), isPackaged: false, on: vi.fn(), @@ -331,13 +335,14 @@ describe('HeterogeneousAgentCtr', () => { sessionOverrides: Record = {}, stdoutLines: string[] = [], sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {}, + storeGet?: (key: string, defaultValue?: any) => any, ) => { const { proc, writes } = createFakeProc({ stdoutLines }); nextFakeProc = proc; const ctr = new HeterogeneousAgentCtr({ appStoragePath, - storeManager: { get: vi.fn() }, + storeManager: { get: storeGet ? vi.fn(storeGet) : vi.fn() }, } as any); const { sessionId } = await ctr.startSession({ agentType: 'codex', @@ -620,6 +625,85 @@ describe('HeterogeneousAgentCtr', () => { } }); + it('centralizes to heteroAgent/tracing in dev too when the toggle is on', async () => { + const originalNodeEnv = process.env.NODE_ENV; + // Dev (isPackaged stays false), but the user opted in via the toggle. + process.env.NODE_ENV = 'development'; + + try { + const prompt = 'trace this opted-in dev run'; + const rawLine = `${JSON.stringify({ + thread_id: 'thread_codex_dev_optin', + type: 'thread.started', + })}\n`; + await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {}, (key: string) => + key === 'heteroTracingEnabled' ? true : undefined, + ); + + const agentTraceRoot = path.join(appStoragePath, 'heteroAgent', 'tracing', 'codex'); + const traceDirs = await readdir(agentTraceRoot); + expect(traceDirs).toHaveLength(1); + + // Toggle wins over the dev cwd default. + await expect(readdir(path.join(appStoragePath, '.heerogeneous-tracing'))).rejects.toThrow(); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + it('traces to the centralized heteroAgent/tracing dir in packaged builds when the toggle is on', async () => { + const originalNodeEnv = process.env.NODE_ENV; + // The gate short-circuits to `false` under NODE_ENV=test, so simulate a + // real packaged production process. + process.env.NODE_ENV = 'production'; + (electronAppMock as any).isPackaged = true; + + try { + const prompt = 'trace this packaged run'; + const rawLine = `${JSON.stringify({ + thread_id: 'thread_codex_packaged', + type: 'thread.started', + })}\n`; + await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {}, (key: string) => + key === 'heteroTracingEnabled' ? true : undefined, + ); + + // Centralized under appStoragePath/heteroAgent/tracing — NOT in the cwd. + const traceRoot = path.join(appStoragePath, 'heteroAgent', 'tracing'); + const agentTraceRoot = path.join(traceRoot, 'codex'); + const traceDirs = await readdir(agentTraceRoot); + expect(traceDirs).toHaveLength(1); + + const traceDir = path.join(agentTraceRoot, traceDirs[0]); + await expect(readFile(path.join(traceDir, 'stdout.jsonl'), 'utf8')).resolves.toBe(rawLine); + + // The dev-style cwd location must NOT be written in packaged mode. + await expect(readdir(path.join(appStoragePath, '.heerogeneous-tracing'))).rejects.toThrow(); + } finally { + process.env.NODE_ENV = originalNodeEnv; + (electronAppMock as any).isPackaged = false; + } + }); + + it('does not trace in packaged builds when the toggle is off', async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + (electronAppMock as any).isPackaged = true; + + try { + await runSendPrompt('no trace please', { cwd: appStoragePath }, [], {}, (key: string) => + key === 'heteroTracingEnabled' ? false : undefined, + ); + + await expect( + readdir(path.join(appStoragePath, 'heteroAgent', 'tracing')), + ).rejects.toThrow(); + } finally { + process.env.NODE_ENV = originalNodeEnv; + (electronAppMock as any).isPackaged = false; + } + }); + it('skips trace creation (and never auto-creates the cwd) when the cwd is missing', async () => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'development'; diff --git a/apps/desktop/src/main/locales/default/menu.ts b/apps/desktop/src/main/locales/default/menu.ts index 1ebba043d4..d2988adaec 100644 --- a/apps/desktop/src/main/locales/default/menu.ts +++ b/apps/desktop/src/main/locales/default/menu.ts @@ -56,9 +56,11 @@ const menu = { 'help.about': 'About', 'help.githubRepo': 'GitHub Repository', 'help.openConfigDir': 'Open Config Directory', + 'help.openHeteroAgentDir': 'Open HeteroAgent Directory', 'help.openLogsDir': 'Open Logs Directory', 'help.reportIssue': 'Send Feedback', 'help.title': 'Help', + 'help.toggleHeteroTracing': 'Record Agent CLI Trace Logs', 'help.visitWebsite': 'Open Website', 'history.back': 'Back', 'history.forward': 'Forward', diff --git a/apps/desktop/src/main/menus/impls/linux.test.ts b/apps/desktop/src/main/menus/impls/linux.test.ts index 2f86b2e5e1..b9c8a70a5c 100644 --- a/apps/desktop/src/main/menus/impls/linux.test.ts +++ b/apps/desktop/src/main/menus/impls/linux.test.ts @@ -108,6 +108,10 @@ const createMockApp = () => { updaterManager: { checkForUpdates: vi.fn(), }, + storeManager: { + get: vi.fn(), + set: vi.fn(), + }, } as unknown as App; }; diff --git a/apps/desktop/src/main/menus/impls/linux.ts b/apps/desktop/src/main/menus/impls/linux.ts index f2dfb01966..7d5be040e1 100644 --- a/apps/desktop/src/main/menus/impls/linux.ts +++ b/apps/desktop/src/main/menus/impls/linux.ts @@ -1,7 +1,10 @@ +import path from 'node:path'; + import type { MenuItemConstructorOptions } from 'electron'; import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron'; import { isDev } from '@/const/env'; +import { HETERO_AGENT_DIR } from '@/const/heteroAgent'; import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types'; import { BaseMenuPlatform } from './BaseMenuPlatform'; @@ -214,6 +217,25 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform { label: t('help.githubRepo'), }, { type: 'separator' }, + { + click: () => { + const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR); + console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`); + shell.openPath(heteroAgentPath).catch((err) => { + console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err); + }); + }, + label: t('help.openHeteroAgentDir'), + }, + { + checked: this.app.storeManager.get('heteroTracingEnabled', false), + click: (item) => { + this.app.storeManager.set('heteroTracingEnabled', item.checked); + }, + label: t('help.toggleHeteroTracing'), + type: 'checkbox', + }, + { type: 'separator' }, { click: () => { const commonT = this.app.i18n.ns('common'); diff --git a/apps/desktop/src/main/menus/impls/macOS.test.ts b/apps/desktop/src/main/menus/impls/macOS.test.ts index 74908297e7..85f7e8c741 100644 --- a/apps/desktop/src/main/menus/impls/macOS.test.ts +++ b/apps/desktop/src/main/menus/impls/macOS.test.ts @@ -94,7 +94,9 @@ const createMockApp = () => { rebuildAppMenu: vi.fn(), }, storeManager: { + get: vi.fn(), openInEditor: vi.fn(), + set: vi.fn(), }, } as unknown as App; }; diff --git a/apps/desktop/src/main/menus/impls/macOS.ts b/apps/desktop/src/main/menus/impls/macOS.ts index cda9544c97..de40281068 100644 --- a/apps/desktop/src/main/menus/impls/macOS.ts +++ b/apps/desktop/src/main/menus/impls/macOS.ts @@ -4,6 +4,7 @@ import type { MenuItemConstructorOptions } from 'electron'; import { app, BrowserWindow, clipboard, Menu, shell } from 'electron'; import { isDev } from '@/const/env'; +import { HETERO_AGENT_DIR } from '@/const/heteroAgent'; import NotificationCtr from '@/controllers/NotificationCtr'; import SystemController from '@/controllers/SystemCtr'; @@ -294,6 +295,25 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform { }, label: t('help.openConfigDir'), }, + { + click: () => { + const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR); + console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`); + shell.openPath(heteroAgentPath).catch((err) => { + console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err); + }); + }, + label: t('help.openHeteroAgentDir'), + }, + { type: 'separator' }, + { + checked: this.app.storeManager.get('heteroTracingEnabled', false), + click: (item) => { + this.app.storeManager.set('heteroTracingEnabled', item.checked); + }, + label: t('help.toggleHeteroTracing'), + type: 'checkbox', + }, ], }, ]; diff --git a/apps/desktop/src/main/menus/impls/windows.test.ts b/apps/desktop/src/main/menus/impls/windows.test.ts index 023593ceeb..94bc2f90ac 100644 --- a/apps/desktop/src/main/menus/impls/windows.test.ts +++ b/apps/desktop/src/main/menus/impls/windows.test.ts @@ -85,6 +85,10 @@ const createMockApp = () => { getUpdaterState: vi.fn(() => ({ stage: 'idle' })), installNow: vi.fn(), }, + storeManager: { + get: vi.fn(), + set: vi.fn(), + }, } as unknown as App; }; diff --git a/apps/desktop/src/main/menus/impls/windows.ts b/apps/desktop/src/main/menus/impls/windows.ts index fd895207f0..a5d9e32051 100644 --- a/apps/desktop/src/main/menus/impls/windows.ts +++ b/apps/desktop/src/main/menus/impls/windows.ts @@ -1,7 +1,10 @@ +import path from 'node:path'; + import type { MenuItemConstructorOptions } from 'electron'; import { app, BrowserWindow, clipboard, Menu, shell } from 'electron'; import { isDev } from '@/const/env'; +import { HETERO_AGENT_DIR } from '@/const/heteroAgent'; import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types'; import { BaseMenuPlatform } from './BaseMenuPlatform'; @@ -211,6 +214,25 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform { }, label: t('help.githubRepo'), }, + { type: 'separator' }, + { + click: () => { + const heteroAgentPath = path.join(this.app.appStoragePath, HETERO_AGENT_DIR); + console.info(`[Menu] Opening HeteroAgent directory: ${heteroAgentPath}`); + shell.openPath(heteroAgentPath).catch((err) => { + console.error(`[Menu] Error opening path ${heteroAgentPath}:`, err); + }); + }, + label: t('help.openHeteroAgentDir'), + }, + { + checked: this.app.storeManager.get('heteroTracingEnabled', false), + click: (item) => { + this.app.storeManager.set('heteroTracingEnabled', item.checked); + }, + label: t('help.toggleHeteroTracing'), + type: 'checkbox', + }, ], }, ]; diff --git a/apps/desktop/src/main/types/store.ts b/apps/desktop/src/main/types/store.ts index b9b5432980..1beb3ac109 100644 --- a/apps/desktop/src/main/types/store.ts +++ b/apps/desktop/src/main/types/store.ts @@ -19,6 +19,12 @@ export interface ElectronMainStore { gatewayDeviceName: string; gatewayEnabled: boolean; gatewayUrl: string; + /** + * Developer toggle: when true, hetero-agent (CC / Codex) CLI raw streams are + * traced to disk even in packaged production builds. Dev builds always trace + * regardless of this flag. Exposed via the Help menu checkbox. + */ + heteroTracingEnabled: boolean; imessageBridgeConfigs: ImessageBridgeConfig[]; locale: string; localFileWorkspaceRoots: string[]; diff --git a/locales/en-US/setting.json b/locales/en-US/setting.json index 52c34377e2..7937f53d89 100644 --- a/locales/en-US/setting.json +++ b/locales/en-US/setting.json @@ -271,6 +271,11 @@ "devices.actions.edit": "Edit", "devices.actions.remove": "Remove", "devices.channel.connected": "Connected {{time}}", + "devices.currentBadge": "This device", + "devices.detail.connections": "Connections", + "devices.detail.noRecent": "No recent directories", + "devices.detail.recentDirs": "Recent directories", + "devices.edit.browse": "Browse…", "devices.edit.cancel": "Cancel", "devices.edit.defaultCwd": "Default working directory", "devices.edit.defaultCwdPlaceholder": "e.g. /Users/me/projects", diff --git a/locales/zh-CN/setting.json b/locales/zh-CN/setting.json index ec9ad855c7..3c2f016a17 100644 --- a/locales/zh-CN/setting.json +++ b/locales/zh-CN/setting.json @@ -271,6 +271,11 @@ "devices.actions.edit": "编辑", "devices.actions.remove": "移除", "devices.channel.connected": "已连接 {{time}}", + "devices.currentBadge": "当前设备", + "devices.detail.connections": "连接通道", + "devices.detail.noRecent": "暂无最近目录", + "devices.detail.recentDirs": "最近目录", + "devices.edit.browse": "浏览…", "devices.edit.cancel": "取消", "devices.edit.defaultCwd": "默认工作目录", "devices.edit.defaultCwdPlaceholder": "例如 /Users/me/projects", diff --git a/packages/builtin-tool-agent-management/src/client/Render/CreateAgent/index.tsx b/packages/builtin-tool-agent-management/src/client/Render/CreateAgent/index.tsx index 9d2bb7a798..49c20b6651 100644 --- a/packages/builtin-tool-agent-management/src/client/Render/CreateAgent/index.tsx +++ b/packages/builtin-tool-agent-management/src/client/Render/CreateAgent/index.tsx @@ -42,9 +42,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ color: ${cssVar.colorTextTertiary}; `, container: css` - padding: 12px; - border-radius: 8px; - background: ${cssVar.colorFillQuaternary}; + padding-block: 4px; `, field: css` margin-block-end: 8px; @@ -80,9 +78,9 @@ export const CreateAgentRender = memo diff --git a/packages/builtin-tool-agent-management/src/client/Render/GetAgentDetail/index.tsx b/packages/builtin-tool-agent-management/src/client/Render/GetAgentDetail/index.tsx index 439ce3c2fb..e45162ecca 100644 --- a/packages/builtin-tool-agent-management/src/client/Render/GetAgentDetail/index.tsx +++ b/packages/builtin-tool-agent-management/src/client/Render/GetAgentDetail/index.tsx @@ -10,9 +10,7 @@ import type { GetAgentDetailParams, GetAgentDetailState } from '../../../types'; const styles = createStaticStyles(({ css, cssVar }) => ({ container: css` - padding: 12px; - border-radius: 8px; - background: ${cssVar.colorFillQuaternary}; + padding-block: 4px; `, field: css` margin-block-end: 8px; diff --git a/packages/builtin-tool-claude-code/src/client/Render/AskUserQuestion/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/AskUserQuestion/index.tsx index c2d179ae4b..c3f56c7174 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/AskUserQuestion/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/AskUserQuestion/index.tsx @@ -2,9 +2,10 @@ import type { BuiltinRenderProps } from '@lobechat/types'; import { Flexbox, Icon, Text } from '@lobehub/ui'; -import { createStaticStyles, cx } from 'antd-style'; +import { createStaticStyles } from 'antd-style'; import { Check, PenLine } from 'lucide-react'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import type { AskUserQuestionArgs, AskUserQuestionItem } from '../../../types'; @@ -16,29 +17,36 @@ interface AskUserQuestionState { const styles = createStaticStyles(({ css, cssVar }) => ({ answer: css` + line-height: 1.6; color: ${cssVar.colorText}; `, - answerRow: css` - padding-block: 6px; - padding-inline: 10px; - border-radius: 6px; - background: ${cssVar.colorBgContainer}; - `, check: css` flex-shrink: 0; + margin-block-start: 3px; color: ${cssVar.colorPrimary}; `, container: css` - padding: 12px; - border-radius: ${cssVar.borderRadiusLG}; - background: ${cssVar.colorFillQuaternary}; + padding-block: 4px; `, - header: css` + description: css` + font-size: 13px; + line-height: 1.6; + color: ${cssVar.colorTextTertiary}; + `, + divider: css` + align-self: stretch; + height: 1px; + background: ${cssVar.colorFillSecondary}; + `, + label: css` font-size: 12px; - color: ${cssVar.colorTextSecondary}; + color: ${cssVar.colorTextTertiary}; `, question: css` + font-size: 15px; font-weight: 500; + line-height: 1.5; + color: ${cssVar.colorText}; `, })); @@ -48,46 +56,57 @@ interface QABlockProps { } /** - * One question/answer pair for the completed Render. The original question - * stays visible (header + body); the answer renders as one card per picked - * option (multi-select fans out into multiple rows). When `answer` is - * absent — older messages persisted before added structured - * storage — we show a `—` placeholder so the layout stays uniform. + * One question/answer pair for the completed Render, laid out as a single + * flat surface (no nested cards): a "Question" label + the question text, + * a hairline divider, then a "Selected" label + the picked option(s). Each + * pick is one check-prefixed line with its description underneath; multi-select + * fans out into multiple lines. When `answer` is absent — older messages + * persisted before structured storage — we show a `—` placeholder so the + * layout stays uniform. */ const QABlock = memo(({ question, answer }) => { + const { t } = useTranslation('plugin'); const labels: string[] = Array.isArray(answer) ? answer : answer ? [answer] : []; const optionByLabel = new Map(question.options.map((o) => [o.label, o])); return ( - - {question.header && {question.header}} - {question.question} - {labels.length > 0 ? ( - - {labels.map((label) => { - const opt = optionByLabel.get(label); - return ( - - - - {label} + + + + {t('builtins.lobe-claude-code.askUserQuestion.question')} + +
{question.question}
+
+ +
+ + + + {t('builtins.lobe-claude-code.askUserQuestion.selected')} + + {labels.length > 0 ? ( + + {labels.map((label) => { + const opt = optionByLabel.get(label); + return ( + + + + {label} + {opt?.description && opt.description !== label && ( - {opt.description} + + {opt.description} + )} - - ); - })} - - ) : ( - - )} + ); + })} + + ) : ( + + )} + ); }); @@ -106,10 +125,15 @@ QABlock.displayName = 'CCAskUserQuestionQABlock'; * `setInterventionAnswers` in `conversationControl` at submit time. If the * key is missing (older messages, or skipped/cancelled flows where there's * nothing to show), we fall back to the question list with a status hint. + * + * Single-layer surface: the framework's tool card already provides the + * containing card, so this Render stays flat (no own background) to avoid the + * card-in-card look. */ const AskUserQuestion = memo< BuiltinRenderProps >(({ args, pluginError, pluginState }) => { + const { t } = useTranslation('plugin'); const questions = args?.questions ?? []; const answers = pluginState?.askUserAnswers; const freeform = answers?.['__freeform__']; @@ -118,35 +142,43 @@ const AskUserQuestion = memo< // Escape-mode reply: the user opted out of the multi-choice form and // wrote freeform text instead. The form picks are intentionally absent, - // so render the questions for context (header + body only) plus the - // typed reply as one card — Q&A pairs would render as empty rows. + // so render the questions for context (label + body) plus the typed reply + // as a check-style line — Q&A pairs would render as empty rows. if (freeformText) { return ( - + {questions.map((q, idx) => ( - - {q.header && {q.header}} - {q.question} + + + {t('builtins.lobe-claude-code.askUserQuestion.question')} + +
{q.question}
))} - - - {freeformText} +
+ + + {t('builtins.lobe-claude-code.askUserQuestion.reply')} + + + + {freeformText} + {isError && ( - (No answer received — model continued without their input.) + {t('builtins.lobe-claude-code.askUserQuestion.noAnswer')} )} ); } return ( - + {questions.map((q, idx) => ( ))} {isError && ( - (No answer received — model continued without their input.) + {t('builtins.lobe-claude-code.askUserQuestion.noAnswer')} )} ); diff --git a/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx index 88027fd4db..9d0515eab8 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx @@ -10,9 +10,9 @@ import { memo, useMemo } from 'react'; const styles = createStaticStyles(({ css, cssVar }) => ({ path: css` + min-width: 0; font-size: 12px; color: ${cssVar.colorTextTertiary}; - word-break: break-all; `, })); @@ -39,6 +39,7 @@ const stripLineNumbers = (text: string): string => { const Read = memo>(({ args, content }) => { const filePath = args?.file_path || ''; const fileName = filePath ? path.basename(filePath) : ''; + const dir = filePath ? path.dirname(filePath) : ''; const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : ''; const source = useMemo(() => stripLineNumbers(content || ''), [content]); @@ -49,9 +50,9 @@ const Read = memo>(({ args, content }) => { header={ <> {fileName || 'Read'} - {filePath && filePath !== fileName && ( + {dir && dir !== '.' && ( - {filePath} + {dir} )} diff --git a/packages/builtin-tool-claude-code/src/client/Render/Skill/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Skill/index.tsx index 21e2ef96c8..0300b7cecf 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/Skill/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/Skill/index.tsx @@ -10,9 +10,7 @@ import type { SkillArgs } from '../../../types'; const styles = createStaticStyles(({ css, cssVar }) => ({ container: css` - padding: 8px; - border-radius: ${cssVar.borderRadiusLG}; - background: ${cssVar.colorFillQuaternary}; + padding-block: 4px; `, header: css` padding-inline: 4px; @@ -25,7 +23,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ padding-inline: 8px; border-radius: 8px; - background: ${cssVar.colorBgContainer}; + background: ${cssVar.colorFillTertiary}; `, })); diff --git a/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx b/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx index b07212b556..c45c5b52d5 100644 --- a/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx +++ b/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx @@ -10,14 +10,12 @@ import { LocalFile, LocalFolder } from '@/features/LocalFile'; const styles = createStaticStyles(({ css, cssVar }) => ({ container: css` - padding: 8px; - border-radius: ${cssVar.borderRadiusLG}; - background: ${cssVar.colorFillQuaternary}; + padding-block: 4px; `, previewBox: css` overflow: hidden; border-radius: 8px; - background: ${cssVar.colorBgContainer}; + background: ${cssVar.colorFillTertiary}; `, })); diff --git a/packages/builtin-tool-web-onboarding/src/client/Render/SaveUserQuestion/index.tsx b/packages/builtin-tool-web-onboarding/src/client/Render/SaveUserQuestion/index.tsx index 513ba12575..bb4f6de151 100644 --- a/packages/builtin-tool-web-onboarding/src/client/Render/SaveUserQuestion/index.tsx +++ b/packages/builtin-tool-web-onboarding/src/client/Render/SaveUserQuestion/index.tsx @@ -59,15 +59,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ color: ${cssVar.colorTextSecondary}; `, value: css` - padding-block: 10px; - padding-inline: 12px; - border-radius: 10px; - font-size: 14px; font-weight: 500; color: ${cssVar.colorText}; - - background: ${cssVar.colorFillQuaternary}; `, })); diff --git a/packages/device-gateway-client/src/http.ts b/packages/device-gateway-client/src/http.ts index e52b9e2f3a..e5804ee0e4 100644 --- a/packages/device-gateway-client/src/http.ts +++ b/packages/device-gateway-client/src/http.ts @@ -127,6 +127,7 @@ export class GatewayHttpClient { operationId: string; prompt: string; resumeSessionId?: string; + systemContext?: string; timeout?: number; topicId: string; userId: string; diff --git a/packages/device-gateway-client/src/types.ts b/packages/device-gateway-client/src/types.ts index 2dc6eb4a75..bca1307602 100644 --- a/packages/device-gateway-client/src/types.ts +++ b/packages/device-gateway-client/src/types.ts @@ -137,6 +137,13 @@ export interface AgentRunRequestMessage { operationId: string; prompt: string; resumeSessionId?: string; + /** + * Static context injected before the user prompt (workspace conventions, + * conversation history on resume). The desktop sends it to `lh hetero exec` + * as the first text block of a content-block array. Optional — omitted for + * older servers that don't build a device-specific context. + */ + systemContext?: string; topicId: string; type: 'agent_run_request'; } diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts index e1d9ece4d3..630d26cb4b 100644 --- a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts @@ -96,6 +96,86 @@ describe('ClaudeCodeAdapter', () => { }); }); + it('classifies a 429 "not your usage limit" server throttle as overloaded, not rate_limit', () => { + const adapter = new ClaudeCodeAdapter(); + const rawError = + 'API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited'; + + adapter.adapt({ subtype: 'init', type: 'system' }); + // CC still emits a generic rate_limit_event (rejected, no resetsAt) for + // this transient throttle — it must NOT tip the classifier toward the + // user-facing usage-limit guide. + adapter.adapt({ + rate_limit_info: { isUsingOverage: false, status: 'rejected' }, + type: 'rate_limit_event', + }); + + const events = adapter.adapt({ + api_error_status: 429, + is_error: true, + result: rawError, + type: 'result', + }); + + expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']); + expect(events[1].data).toMatchObject({ + agentType: 'claude-code', + clearEchoedContent: true, + code: 'overloaded', + message: rawError, + stderr: rawError, + }); + }); + + it('treats a 429 with no reset window in rate_limit_event as overloaded, not rate_limit', () => { + const adapter = new ClaudeCodeAdapter(); + // Generic "Rate limited" wording + a rate_limit_event that carries no + // resetsAt / rateLimitType. The structured signal — not the 429 status + // or the "rate limit" substring — decides: no reset window → transient + // server throttle → overloaded. + const rawError = 'API Error: 429 · Rate limited'; + + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + rate_limit_info: { status: 'rejected' }, + type: 'rate_limit_event', + }); + + const events = adapter.adapt({ + api_error_status: 429, + is_error: true, + result: rawError, + type: 'result', + }); + + expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']); + expect(events[1].data).toMatchObject({ code: 'overloaded', message: rawError }); + }); + + it('classifies a user quota limit from rateLimitType alone (no resetsAt)', () => { + const adapter = new ClaudeCodeAdapter(); + const rawError = 'API Error: 429 · Rate limited'; + + adapter.adapt({ subtype: 'init', type: 'system' }); + // rateLimitType is itself a user-quota signal even without resetsAt. + adapter.adapt({ + rate_limit_info: { rateLimitType: 'seven_day', status: 'rejected' }, + type: 'rate_limit_event', + }); + + const events = adapter.adapt({ + api_error_status: 429, + is_error: true, + result: rawError, + type: 'result', + }); + + expect(events[1].data).toMatchObject({ + code: 'rate_limit', + rateLimitInfo: { rateLimitType: 'seven_day' }, + }); + }); + it('classifies rate-limit failures from paired rate_limit_event + result events', () => { const adapter = new ClaudeCodeAdapter(); const rawError = "You've hit your limit · resets 9am (Asia/Shanghai)"; diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.ts index 249a31ff1d..756ca9f797 100644 --- a/packages/heterogeneous-agents/src/adapters/claudeCode.ts +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.ts @@ -183,14 +183,54 @@ const CLI_AUTH_REQUIRED_PATTERNS = [ /\b401\b/, ] as const; -const CLI_RATE_LIMIT_PATTERNS = [/you'?ve hit your limit/i, /rate limit/i] as const; +/** + * Genuinely user-side limit wording. Used only as the text fallback for + * batch CLI / sandbox runs that don't emit a structured `rate_limit_event` + * (so {@link isUserQuotaRateLimit} can't fire). The ambiguous bare + * `rate limit` / `rate limited` substring is deliberately NOT here — it also + * appears in Anthropic's transient server throttle, so leaning on it would + * reintroduce the very misclassification this set exists to avoid. + */ +const CLI_USER_RATE_LIMIT_PATTERNS = [ + /you'?ve hit your limit/i, + /usage limit reached/i, + /\blimit reached\b/i, +] as const; + +/** + * Anthropic's server-side transient throttle. CC surfaces this as a 429 with + * a message that explicitly disclaims the user's plan limit ("not your usage + * limit") — e.g. `API Error: Server is temporarily limiting requests (not your + * usage limit) · Rate limited`. It clears on its own in moments, so it must be + * classified as `overloaded` (retry UX), NOT `rate_limit` (which renders a + * misleading "usage limit reached" reset-time guide). + */ +const CLI_SERVER_THROTTLE_PATTERNS = [ + /not your usage limit/i, + /server is temporarily limiting requests/i, +] as const; const CLI_OVERLOADED_PATTERNS = [ /overloaded_error/i, /\boverloaded\b/i, /api error:\s*529\b/i, + ...CLI_SERVER_THROTTLE_PATTERNS, ] as const; +/** + * The one reliable discriminator between a user-side plan/quota limit and a + * transient server throttle: only the genuine user limit carries a concrete + * reset window in the structured `rate_limit_event` — `resetsAt` (epoch + * seconds) and/or a named `rateLimitType` (e.g. `seven_day`). Anthropic's + * transient throttle emits a rate_limit_event too, but with just + * `status: 'rejected'` and no reset info. Status codes (429 / 529) alone are + * ambiguous, so this structured signal — not the HTTP status, not the message + * text — is what decides whether we show the "usage limit reached, resets at + * X" guide vs the "temporarily overloaded, retry" guide. + */ +const isUserQuotaRateLimit = (info?: HeterogeneousRateLimitInfo): boolean => + !!info && (info.resetsAt != null || info.rateLimitType != null); + const getCliResultMessage = (result: unknown): string | undefined => { if (typeof result === 'string') return result; if ( @@ -248,10 +288,18 @@ const toRateLimitInfo = (value: unknown): HeterogeneousRateLimitInfo | undefined const getOverloadedTerminalError = ( result: unknown, apiErrorStatus?: unknown, + rateLimitInfo?: HeterogeneousRateLimitInfo, ): HeterogeneousTerminalErrorData | undefined => { const rawMessage = getCliResultMessage(result); + // A real user-quota limit is the rate-limit classifier's job — never steal + // it here, even if it happened to ride in on a 429/529. + if (isUserQuotaRateLimit(rateLimitInfo)) return; + const looksOverloaded = + // Both 529 (upstream overloaded) and a 429 with no quota signal (transient + // server throttle) are momentary server-side conditions — same retry UX. apiErrorStatus === 529 || + apiErrorStatus === 429 || (!!rawMessage && CLI_OVERLOADED_PATTERNS.some((pattern) => pattern.test(rawMessage))); if (!looksOverloaded || !rawMessage) return; @@ -269,15 +317,24 @@ const getOverloadedTerminalError = ( const getRateLimitTerminalError = ( result: unknown, rateLimitInfo?: HeterogeneousRateLimitInfo, - apiErrorStatus?: unknown, ): HeterogeneousTerminalErrorData | undefined => { const rawMessage = getCliResultMessage(result); - const looksLikeRateLimit = - apiErrorStatus === 429 || - !!rateLimitInfo || - (!!rawMessage && CLI_RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(rawMessage))); - if (!looksLikeRateLimit || !rawMessage) return; + // Primary signal: the structured rate_limit_event carries a concrete reset + // window → this is the user's plan/quota limit. Fallback (batch runs with no + // rate_limit_event): clearly user-side wording that doesn't disclaim the + // limit. Everything else — bare 429, "rate limited", server throttle — is + // left to the overloaded classifier so it gets the retry UX, not a + // misleading "usage limit reached, resets at X" guide. + const looksLikeServerThrottle = + !!rawMessage && CLI_SERVER_THROTTLE_PATTERNS.some((pattern) => pattern.test(rawMessage)); + const looksLikeUserLimit = + isUserQuotaRateLimit(rateLimitInfo) || + (!!rawMessage && + !looksLikeServerThrottle && + CLI_USER_RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(rawMessage))); + + if (!looksLikeUserLimit || !rawMessage) return; return { agentType: 'claude-code', @@ -1166,16 +1223,16 @@ export class ClaudeCodeAdapter implements AgentEventAdapter { } const resultMessage = getCliResultMessage(raw.result) || 'Agent execution failed'; - const rateLimitError = getRateLimitTerminalError( - raw.result, - this.pendingRateLimitInfo, - raw.api_error_status, - ); + const rateLimitError = getRateLimitTerminalError(raw.result, this.pendingRateLimitInfo); const finalEvent: HeterogeneousAgentEvent = raw.is_error ? this.makeEvent( 'error', rateLimitError || - getOverloadedTerminalError(raw.result, raw.api_error_status) || + getOverloadedTerminalError( + raw.result, + raw.api_error_status, + this.pendingRateLimitInfo, + ) || getAuthRequiredTerminalError(raw.result) || { error: resultMessage, message: resultMessage, diff --git a/packages/shared-tool-ui/src/components/ToolResultCard.tsx b/packages/shared-tool-ui/src/components/ToolResultCard.tsx index e5874b235a..37ba94918a 100644 --- a/packages/shared-tool-ui/src/components/ToolResultCard.tsx +++ b/packages/shared-tool-ui/src/components/ToolResultCard.tsx @@ -7,9 +7,7 @@ import { memo, type ReactNode } from 'react'; const styles = createStaticStyles(({ css, cssVar }) => ({ container: css` - padding: 8px; - border-radius: ${cssVar.borderRadiusLG}; - background: ${cssVar.colorFillQuaternary}; + padding-block: 4px; `, header: css` padding-inline: 4px; @@ -19,7 +17,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ overflow: hidden; padding: 8px; border-radius: 8px; - background: ${cssVar.colorBgContainer}; + background: ${cssVar.colorFillTertiary}; `, })); diff --git a/packages/types/src/tool/builtin.ts b/packages/types/src/tool/builtin.ts index d58ad996c7..1dd7f49852 100644 --- a/packages/types/src/tool/builtin.ts +++ b/packages/types/src/tool/builtin.ts @@ -302,7 +302,7 @@ export interface BuiltinInspectorProps { isLoading?: boolean; partialArgs?: Arguments; pluginState?: State; - result?: { content: string | null; error?: any }; + result?: { content: string | null; error?: any; state?: any }; } export type BuiltinInspector = (props: BuiltinInspectorProps) => ReactNode; diff --git a/src/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory.tsx b/src/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory.tsx new file mode 100644 index 0000000000..a00a5a83d4 --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { Flexbox, Icon, Input, Popover, Tooltip } from '@lobehub/ui'; +import { confirmModal } from '@lobehub/ui/base-ui'; +import { createStaticStyles, cssVar, cx } from 'antd-style'; +import { CheckIcon, ChevronDownIcon, FolderIcon } from 'lucide-react'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { lambdaQuery } from '@/libs/trpc/client'; +import { useAgentStore } from '@/store/agent'; +import { agentByIdSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; +import { topicSelectors } from '@/store/chat/selectors'; + +import { useUpdateDeviceCwd } from './useUpdateDeviceCwd'; + +const styles = createStaticStyles(({ css }) => ({ + button: css` + cursor: pointer; + + display: flex; + gap: 6px; + align-items: center; + + padding-block: 2px; + padding-inline: 4px; + border-radius: 4px; + + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + + transition: background 0.2s; + + &:hover { + background: ${cssVar.colorFillTertiary}; + } + `, + dirItem: css` + cursor: pointer; + + padding-block: 6px; + padding-inline: 8px; + border-radius: ${cssVar.borderRadius}; + + transition: background-color 0.2s; + + &:hover { + background: ${cssVar.colorFillTertiary}; + } + `, + dirItemActive: css` + background: ${cssVar.colorFillTertiary}; + `, + dirName: css` + font-size: 13px; + font-weight: 500; + color: ${cssVar.colorText}; + `, + dirPath: css` + overflow: hidden; + + font-size: 11px; + color: ${cssVar.colorTextDescription}; + text-overflow: ellipsis; + white-space: nowrap; + `, + scrollContainer: css` + overflow-y: auto; + max-height: 320px; + `, + sectionTitle: css` + padding-block: 6px 2px; + padding-inline: 8px; + + font-size: 11px; + font-weight: 500; + color: ${cssVar.colorTextQuaternary}; + text-transform: uppercase; + letter-spacing: 0.5px; + `, +})); + +const getDirName = (path: string) => path.split('/').findLast(Boolean) || path; + +interface DeviceWorkingDirectoryProps { + agentId: string; +} + +/** + * Working-directory picker for runs dispatched to a remote device + * (`executionTarget='device'`). Unlike the desktop picker, the device's + * filesystem isn't browsable from here, so the cwd comes from the device's + * `recentCwds` (persisted via the registry) plus a manual path field. A pick is + * pinned to the active topic (override) and persisted back to the device + * (`defaultCwd` + `recentCwds`) so it seeds future topics and the recent list. + */ +const DeviceWorkingDirectory = memo(({ agentId }) => { + const { t } = useTranslation(['plugin', 'chat']); + const [open, setOpen] = useState(false); + const [input, setInput] = useState(''); + + const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId)); + const boundDeviceId = agencyConfig?.boundDeviceId; + + const { data: devices } = lambdaQuery.device.listDevices.useQuery(undefined, { + staleTime: 30_000, + }); + const device = useMemo( + () => devices?.find((d) => d.deviceId === boundDeviceId), + [devices, boundDeviceId], + ); + const recentCwds = device?.recentCwds ?? []; + + const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); + // Mirror the server's resolution (topic override > device.defaultCwd). + const effectiveDir = topicWorkingDirectory || device?.defaultCwd || ''; + + const activeTopicId = useChatStore((s) => s.activeTopicId); + const activeTopic = useChatStore((s) => + s.activeTopicId ? topicSelectors.getTopicById(s.activeTopicId)(s) : undefined, + ); + const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata); + const updateDeviceCwd = useUpdateDeviceCwd(); + + const commitDir = useCallback( + async (path: string) => { + const newPath = path.trim(); + if (!newPath || !boundDeviceId) return; + + const commit = async () => { + // Pin this topic to the chosen cwd (override wins server-side), and + // persist to the device so defaultCwd + recentCwds stay in sync. + if (activeTopicId) await updateTopicMetadata(activeTopicId, { workingDirectory: newPath }); + await updateDeviceCwd(boundDeviceId, newPath, recentCwds); + setInput(''); + setOpen(false); + }; + + // Changing a topic's cwd invalidates its pinned CC session (sessions are + // keyed per-cwd), so warn before the implicit reset — same as the local picker. + const priorSessionId = activeTopic?.metadata?.heteroSessionId; + const priorCwd = activeTopic?.metadata?.workingDirectory; + if (priorSessionId && priorCwd && priorCwd !== newPath) { + confirmModal({ + cancelText: t('heteroAgent.switchCwd.cancel', { ns: 'chat' }), + content: t('heteroAgent.switchCwd.content', { ns: 'chat' }), + okText: t('heteroAgent.switchCwd.ok', { ns: 'chat' }), + onOk: commit, + title: t('heteroAgent.switchCwd.title', { ns: 'chat' }), + }); + return; + } + + await commit(); + }, + [ + activeTopicId, + activeTopic, + boundDeviceId, + recentCwds, + t, + updateDeviceCwd, + updateTopicMetadata, + ], + ); + + const content = ( + +
{t('localSystem.workingDirectory.recent')}
+
+ {recentCwds.length === 0 ? ( + + {t('localSystem.workingDirectory.noRecent')} + + ) : ( + recentCwds.map((path) => { + const isActive = path === effectiveDir; + return ( + void commitDir(path)} + > + + +
{getDirName(path)}
+
{path}
+
+ {isActive ? ( + + ) : null} +
+ ); + }) + )} +
+ setInput(e.target.value)} + onPressEnter={() => void commitDir(input)} + /> +
+ ); + + const displayName = effectiveDir + ? getDirName(effectiveDir) + : t('localSystem.workingDirectory.notSet'); + + const trigger = ( +
+ + {displayName} + +
+ ); + + return ( + +
+ {open ? ( + trigger + ) : ( + + {trigger} + + )} +
+
+ ); +}); + +DeviceWorkingDirectory.displayName = 'DeviceWorkingDirectory'; + +export default DeviceWorkingDirectory; diff --git a/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx b/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx index 4bdea25fae..6cb1a9601b 100644 --- a/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx +++ b/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx @@ -7,14 +7,17 @@ import { CheckIcon, FolderIcon, FolderOpenIcon, GitBranchIcon, XIcon } from 'luc import { memo, type ReactNode, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { lambdaQuery } from '@/libs/trpc/client'; import { electronSystemService } from '@/services/electron/system'; import { useAgentStore } from '@/store/agent'; import { agentByIdSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { topicSelectors } from '@/store/chat/selectors'; +import { useElectronStore } from '@/store/electron'; import { addRecentDir, getRecentDirs, type RecentDirEntry, removeRecentDir } from './recentDirs'; import { useRepoType } from './useRepoType'; +import { useUpdateDeviceCwd } from './useUpdateDeviceCwd'; const styles = createStaticStyles(({ css }) => ({ chooseFolderItem: css` @@ -154,6 +157,20 @@ const WorkingDirectoryContent = memo(({ agentId, o const updateAgentRuntimeEnvConfig = useAgentStore((s) => s.updateAgentRuntimeEnvConfigById); const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata); + // Local runs execute on this very machine, so also record the chosen dir in + // its device-registry `recentCwds` — keeps the settings detail view + future + // device-mode picker in sync. recentCwds only; the device default is untouched. + const useFetchDeviceInfo = useElectronStore((s) => s.useFetchGatewayDeviceInfo); + const gatewayDeviceInfo = useElectronStore((s) => s.gatewayDeviceInfo); + useFetchDeviceInfo(); + const currentDeviceId = gatewayDeviceInfo?.deviceId; + const { data: allDevices } = lambdaQuery.device.listDevices.useQuery(undefined, { + staleTime: 30_000, + }); + const deviceRecentCwds = + allDevices?.find((d) => d.deviceId === currentDeviceId)?.recentCwds ?? []; + const updateDeviceCwd = useUpdateDeviceCwd(); + const [recentDirs, setRecentDirs] = useState(getRecentDirs); const displayDirs = useMemo(() => { @@ -178,6 +195,10 @@ const WorkingDirectoryContent = memo(({ agentId, o await updateAgentRuntimeEnvConfig(agentId, { workingDirectory: newPath }); } setRecentDirs(addRecentDir(entry)); + // Record on this machine's device registry (recentCwds only). + if (currentDeviceId) { + void updateDeviceCwd(currentDeviceId, newPath, deviceRecentCwds, { setDefault: false }); + } onClose?.(); }; @@ -205,8 +226,11 @@ const WorkingDirectoryContent = memo(({ agentId, o activeTopicId, activeTopic, agentId, + currentDeviceId, + deviceRecentCwds, t, updateAgentRuntimeEnvConfig, + updateDeviceCwd, updateTopicMetadata, onClose, ], diff --git a/src/features/ChatInput/RuntimeConfig/deviceCwd.test.ts b/src/features/ChatInput/RuntimeConfig/deviceCwd.test.ts new file mode 100644 index 0000000000..821f507979 --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/deviceCwd.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { nextRecentCwds, RECENT_CWDS_MAX } from './deviceCwd'; + +describe('nextRecentCwds', () => { + it('prepends a new path as most-recent', () => { + expect(nextRecentCwds('/b', ['/a'])).toEqual(['/b', '/a']); + }); + + it('moves an existing path to the front without duplicating it', () => { + expect(nextRecentCwds('/a', ['/a', '/b', '/c'])).toEqual(['/a', '/b', '/c']); + expect(nextRecentCwds('/c', ['/a', '/b', '/c'])).toEqual(['/c', '/a', '/b']); + }); + + it('caps the list length', () => { + const current = Array.from({ length: RECENT_CWDS_MAX }, (_, i) => `/p${i}`); + const result = nextRecentCwds('/new', current); + expect(result).toHaveLength(RECENT_CWDS_MAX); + expect(result[0]).toBe('/new'); + expect(result).not.toContain(`/p${RECENT_CWDS_MAX - 1}`); // oldest dropped + }); + + it('trims the input and ignores a blank path', () => { + expect(nextRecentCwds(' /a ', ['/b'])).toEqual(['/a', '/b']); + expect(nextRecentCwds(' ', ['/b'])).toEqual(['/b']); + expect(nextRecentCwds('', ['/b'])).toEqual(['/b']); + }); + + it('defaults to an empty current list', () => { + expect(nextRecentCwds('/a')).toEqual(['/a']); + }); +}); diff --git a/src/features/ChatInput/RuntimeConfig/deviceCwd.ts b/src/features/ChatInput/RuntimeConfig/deviceCwd.ts new file mode 100644 index 0000000000..b7d33b1a4d --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/deviceCwd.ts @@ -0,0 +1,21 @@ +/** Max number of recent working directories persisted per device. Matches the + * `recentCwds` cap enforced by the `device.updateDevice` tRPC input. */ +export const RECENT_CWDS_MAX = 20; + +/** + * Compute the next `recentCwds` list after the user picks `cwd`: move it to the + * front (most-recent-first), drop any earlier duplicate, and cap the length. + * Blank paths are ignored (returns the list unchanged). + * + * The server stores `recentCwds` verbatim — there is no server-side dedupe or + * cap — so the client owns this logic. + */ +export const nextRecentCwds = ( + cwd: string, + current: readonly string[] = [], + max: number = RECENT_CWDS_MAX, +): string[] => { + const trimmed = cwd.trim(); + if (!trimmed) return [...current]; + return [trimmed, ...current.filter((p) => p !== trimmed)].slice(0, max); +}; diff --git a/src/features/ChatInput/RuntimeConfig/useUpdateDeviceCwd.ts b/src/features/ChatInput/RuntimeConfig/useUpdateDeviceCwd.ts new file mode 100644 index 0000000000..366fb336c3 --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/useUpdateDeviceCwd.ts @@ -0,0 +1,63 @@ +import { useCallback } from 'react'; + +import { lambdaQuery } from '@/libs/trpc/client'; + +import { nextRecentCwds } from './deviceCwd'; + +/** + * Persist a working-directory choice to a device's registry record + * (`defaultCwd` + `recentCwds`) with an **optimistic** update of the + * `listDevices` cache, so the picker reflects the pick instantly and the + * server's `device.defaultCwd` (read by the hetero device-dispatch branch) + * stays in sync. Rolls back on error. + */ +export const useUpdateDeviceCwd = () => { + const utils = lambdaQuery.useUtils(); + + const mutation = lambdaQuery.device.updateDevice.useMutation({ + onMutate: async ({ defaultCwd, deviceId, recentCwds }) => { + // Optimistic write: cancel in-flight refetches so they don't clobber it, + // then patch the touched device in place. onSettled re-fetches the truth + // afterwards (on both success and error), so a failed write self-corrects + // without a manual rollback. + await utils.device.listDevices.cancel(); + utils.device.listDevices.setData(undefined, (old) => { + if (!old) return old; + // `listDevices` returns a union (registered device | online-only ghost); + // spreading widens the touched item out of its branch, so re-assert the + // query's own element type rather than fight the literal union. + return old.map((device) => + device.deviceId === deviceId + ? { + ...device, + defaultCwd: defaultCwd ?? device.defaultCwd, + recentCwds: recentCwds ?? device.recentCwds, + } + : device, + ) as typeof old; + }); + }, + onSettled: () => utils.device.listDevices.invalidate(), + }); + + return useCallback( + ( + deviceId: string, + cwd: string, + currentRecentCwds: readonly string[] = [], + // Local-mode runs only want to record the dir in the recent list, not + // repoint the device's default working directory. + options: { setDefault?: boolean } = {}, + ) => { + const trimmed = cwd.trim(); + if (!trimmed) return; + const setDefault = options.setDefault ?? true; + return mutation.mutateAsync({ + ...(setDefault ? { defaultCwd: trimmed } : {}), + deviceId, + recentCwds: nextRecentCwds(trimmed, currentRecentCwds), + }); + }, + [mutation], + ); +}; diff --git a/src/features/DevPanel/RenderGallery/ApiList.tsx b/src/features/DevPanel/RenderGallery/ApiList.tsx new file mode 100644 index 0000000000..35eda0042e --- /dev/null +++ b/src/features/DevPanel/RenderGallery/ApiList.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { Flexbox, Text } from '@lobehub/ui'; +import { createStaticStyles, cx } from 'antd-style'; +import { memo, useEffect, useRef } from 'react'; + +import type { ApiEntry } from './useDevtoolsEntries'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + column: css` + display: flex; + flex-direction: column; + flex-shrink: 0; + + width: 240px; + height: 100%; + border-inline-end: 1px solid ${cssVar.colorBorderSecondary}; + + background: ${cssVar.colorBgContainer}; + `, + dot: css` + flex-shrink: 0; + + width: 5px; + height: 5px; + border-radius: 999px; + + background: ${cssVar.colorTextQuaternary}; + `, + dotActive: css` + background: ${cssVar.colorPrimary}; + `, + header: css` + flex-shrink: 0; + padding-block: 14px 10px; + padding-inline: 16px; + border-block-end: 1px solid ${cssVar.colorBorderSecondary}; + `, + item: css` + cursor: pointer; + + overflow: hidden; + flex-shrink: 0; + gap: 8px; + align-items: center; + + height: 30px; + padding-inline: 10px; + border-radius: 6px; + + color: ${cssVar.colorTextSecondary}; + + transition: + background 0.15s, + color 0.15s; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillQuaternary}; + } + `, + itemActive: css` + color: ${cssVar.colorText}; + background: ${cssVar.colorFillSecondary}; + + &:hover { + background: ${cssVar.colorFillSecondary}; + } + `, + /** Namespace prefix of an mcp__ name — muted, and the part that elides. */ + labelHead: css` + overflow: hidden; + flex: 0 1 auto; + + color: ${cssVar.colorTextQuaternary}; + text-overflow: ellipsis; + white-space: nowrap; + `, + labelRow: css` + overflow: hidden; + display: flex; + flex: 1; + align-items: baseline; + + min-width: 0; + + font-family: ${cssVar.fontFamilyCode}; + font-size: 13px; + `, + /** Trailing action segment — always kept visible. */ + labelTail: css` + flex-shrink: 0; + white-space: nowrap; + `, + list: css` + overflow: auto; + flex: 1; + gap: 2px; + + min-height: 0; + padding-block: 8px; + padding-inline: 8px; + `, +})); + +/** + * Split a name at its last `__` so the long `mcp____` namespace can + * elide from the middle (`mcp__claude_ai_Li…get_diff`) — keeping both the + * `mcp` signal up front and the distinguishing action at the end, instead of + * truncating one or the other away. Non-namespaced names are all tail. + */ +const splitName = (name: string): { head: string; tail: string } => { + const cut = name.lastIndexOf('__'); + if (cut === -1) return { head: '', tail: name }; + return { head: name.slice(0, cut + 2), tail: name.slice(cut + 2) }; +}; + +interface ApiListProps { + activeApiName?: string; + apis: ApiEntry[]; + onSelect: (apiName: string) => void; +} + +/** + * Middle column for the render gallery: a dense jump-list of the current + * toolset's APIs. Clicking scrolls the matching `ToolPreview` card into view + * and pins a URL hash (`#api-`) so a specific render is deep-linkable; + * the active item is driven by the scrollspy in `ToolPage`. The leading dot + * lights up when the API ships a Render. + */ +const ApiList = memo(({ apis, activeApiName, onSelect }) => { + const listRef = useRef(null); + + // Keep the highlighted item visible as the scrollspy walks down the right + // pane — otherwise the list stays pinned at the top and you lose your place. + useEffect(() => { + if (!activeApiName) return; + const el = listRef.current?.querySelector(`[data-api="${CSS.escape(activeApiName)}"]`); + el?.scrollIntoView({ block: 'nearest' }); + }, [activeApiName]); + + return ( + + ); +}); + +ApiList.displayName = 'DevtoolsApiList'; + +export default ApiList; diff --git a/src/features/DevPanel/RenderGallery/MessageList.tsx b/src/features/DevPanel/RenderGallery/MessageList.tsx new file mode 100644 index 0000000000..8fd9088275 --- /dev/null +++ b/src/features/DevPanel/RenderGallery/MessageList.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { LOADING_FLAT } from '@lobechat/const'; +import type { ChatToolPayload, UIChatMessage } from '@lobechat/types'; +import { Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo, useMemo } from 'react'; + +import { + type ConversationContext, + ConversationProvider, + MessageItem, +} from '@/features/Conversation'; +import { MessageActionProvider } from '@/features/Conversation/Messages/Contexts/MessageActionProvider'; +import { dataSelectors, useConversationStore } from '@/features/Conversation/store'; + +import { DEVTOOLS_AGENT_ID } from './fixtures'; +import { deriveFixtureProps, type LifecycleMode } from './lifecycleMode'; +import type { ApiEntry } from './useDevtoolsEntries'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + empty: css` + padding-block: 48px; + color: ${cssVar.colorTextTertiary}; + text-align: center; + `, + thread: css` + width: 100%; + max-width: 820px; + margin-inline: auto; + padding-block: 8px 48px; + padding-inline: 12px; + border-radius: 14px; + + background: ${cssVar.colorBgContainer}; + `, +})); + +const coerceContent = (value: unknown): string => { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +/** + * Build the **flat** DB-shaped messages a real conversation produces, then let + * `parse()` (conversation-flow, via `ConversationProvider.replaceMessages`) + * synthesize the `assistantGroup` / tool grouping exactly as it does in chat — + * instead of hand-rolling the grouped shape. Each render-bearing API becomes: + * + * assistant { content, tools: [tool_use] } → tool { tool_call_id, result… } + * + * The whole sequence is one parentId chain so it reads as a single conversation. + * Lifecycle state is carried on the tool result message the same way the real + * pipeline carries it: + * - success → tool message `content` + `pluginState` + * - error → tool message `pluginError` + * - intervention → tool message `pluginIntervention.status = 'pending'` + * - streaming → `LOADING_FLAT` content + unterminated `arguments` JSON on the tool_use + * - loading / placeholder → `LOADING_FLAT` content + * + * Every API emits a tool message even for the unfinished states (content + * `LOADING_FLAT`) — the tool_use → tool_result link is what lets + * conversation-flow chain the turns into ONE assistantGroup; without it the + * unfinished modes fall back to one orphaned group per tool. + */ +const buildMessages = (apis: ApiEntry[], mode: LifecycleMode, now: number): UIChatMessage[] => { + const renderable = apis.filter( + (api) => api.render || api.streaming || api.placeholder || api.intervention, + ); + + const messages: UIChatMessage[] = []; + + for (const api of renderable) { + const variant = api.fixture.variants[0]; + const derived = deriveFixtureProps(variant, mode); + const key = `${api.identifier}-${api.apiName}`; + const assistantId = `devtools-asst-${key}`; + const toolCallId = `devtools-tool-${key}`; + + const toolUse: ChatToolPayload = { + apiName: api.apiName, + // Streaming: drop the closing brace so args fail to parse → "still typing". + arguments: + mode === 'streaming' + ? JSON.stringify(derived.partialArgs ?? {}).replace(/\}$/, '') + : JSON.stringify(derived.args), + id: toolCallId, + identifier: api.identifier, + source: api.apiName.startsWith('mcp__') ? 'mcp' : 'builtin', + type: 'builtin', + }; + + // Chain onto the previous turn's last message so the whole thread is one + // conversation; the first assistant has no parent (conversation root). + messages.push({ + content: api.description || variant.description || '', + createdAt: now, + id: assistantId, + parentId: messages.at(-1)?.id, + role: 'assistant', + tools: [toolUse], + updatedAt: now, + }); + + // Always emit the paired tool result — it's the tool_use → tool_result link + // that lets conversation-flow chain every turn into ONE assistantGroup. + // Unfinished states use LOADING_FLAT so the tool still reads as in-flight. + messages.push({ + content: mode === 'success' ? coerceContent(derived.content) : LOADING_FLAT, + createdAt: now, + id: `devtools-toolmsg-${key}`, + parentId: assistantId, + pluginError: mode === 'error' ? derived.pluginError : undefined, + pluginIntervention: mode === 'intervention' ? { status: 'pending' } : undefined, + pluginState: mode === 'success' ? derived.pluginState : undefined, + role: 'tool', + tool_call_id: toolCallId, + updatedAt: now, + }); + } + + return messages; +}; + +const InnerList = memo(() => { + const ids = useConversationStore(dataSelectors.displayMessageIds); + return ( + +
+ {ids.map((id, index) => ( + + ))} +
+
+ ); +}); + +InnerList.displayName = 'DevtoolsAggregateInnerList'; + +interface MessageListProps { + apis: ApiEntry[]; + mode: LifecycleMode; +} + +/** + * Aggregate preview tab: renders every render-bearing API as a tool call inside + * the **real** `Conversation` renderer. Flat fixture messages are seeded through + * `ConversationProvider` (`skipFetch`) so conversation-flow's `parse()` performs + * the real `assistantGroup` grouping — the preview is byte-for-byte what ships + * in chat. Inspector-only tools (most MCP entries) are dropped to keep the + * thread about the renders. + */ +const MessageList = memo(({ apis, mode }) => { + // One stable timestamp per (apis, mode) render so message identity is steady. + const messages = useMemo(() => buildMessages(apis, mode, Date.now()), [apis, mode]); + const context = useMemo( + () => ({ agentId: DEVTOOLS_AGENT_ID, topicId: 'devtools-aggregate' }), + [], + ); + + if (messages.length === 0) { + return No renderable APIs in this toolset.; + } + + return ( + + + + ); +}); + +MessageList.displayName = 'DevtoolsMessageList'; + +export default MessageList; diff --git a/src/features/DevPanel/RenderGallery/ToolPage.tsx b/src/features/DevPanel/RenderGallery/ToolPage.tsx index 7b911ebfe6..a9ed60451b 100644 --- a/src/features/DevPanel/RenderGallery/ToolPage.tsx +++ b/src/features/DevPanel/RenderGallery/ToolPage.tsx @@ -2,24 +2,46 @@ import { Flexbox, Segmented, Tag, Text } from '@lobehub/ui'; import { createStaticStyles } from 'antd-style'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; +import ApiList from './ApiList'; import { LIFECYCLE_MODE_LABEL, LIFECYCLE_MODES, type LifecycleMode } from './lifecycleMode'; +import MessageList from './MessageList'; import ToolPreview from './ToolPreview'; -import { useDevtoolsEntries } from './useDevtoolsEntries'; +import { toApiAnchor, useDevtoolsEntries } from './useDevtoolsEntries'; -const STORAGE_KEY = 'devtools-render-gallery:lifecycle-mode'; +const MODE_STORAGE_KEY = 'devtools-render-gallery:lifecycle-mode'; +const VIEW_STORAGE_KEY = 'devtools-render-gallery:view'; + +type GalleryView = 'api' | 'aggregate'; const isLifecycleMode = (value: string | null): value is LifecycleMode => !!value && (LIFECYCLE_MODES as string[]).includes(value); +const isGalleryView = (value: string | null): value is GalleryView => + value === 'api' || value === 'aggregate'; + const styles = createStaticStyles(({ css, cssVar }) => ({ body: css` gap: 24px; max-width: 1200px; padding: 28px; `, + content: css` + position: relative; + overflow: auto; + flex: 1; + + /* keep a jumped-to card clear of the sticky lifecycle bar */ + & [id^='api-'] { + scroll-margin-block-start: 80px; + } + `, + controlGroup: css` + gap: 8px; + align-items: center; + `, empty: css` flex: 1; gap: 6px; @@ -37,7 +59,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ z-index: 2; inset-block-start: 0; - gap: 12px; + gap: 16px; align-items: center; padding-block: 10px; @@ -56,19 +78,94 @@ const DevtoolsToolPage = () => { const toolset = identifier ? toolsetMap.get(identifier) : undefined; const [mode, setMode] = useState('success'); + const [view, setView] = useState('api'); + const [activeApi, setActiveApi] = useState(); + const scrollRef = useRef(null); - // Hydrate from localStorage so the choice survives navigation between toolsets. + // Hydrate from localStorage so the choices survive navigation between toolsets. useEffect(() => { if (typeof window === 'undefined') return; - const stored = window.localStorage.getItem(STORAGE_KEY); - if (isLifecycleMode(stored)) setMode(stored); + const storedMode = window.localStorage.getItem(MODE_STORAGE_KEY); + if (isLifecycleMode(storedMode)) setMode(storedMode); + const storedView = window.localStorage.getItem(VIEW_STORAGE_KEY); + if (isGalleryView(storedView)) setView(storedView); }, []); useEffect(() => { if (typeof window === 'undefined') return; - window.localStorage.setItem(STORAGE_KEY, mode); + window.localStorage.setItem(MODE_STORAGE_KEY, mode); }, [mode]); + useEffect(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(VIEW_STORAGE_KEY, view); + }, [view]); + + // Scrollspy (per-API view only): highlight the API-list item for the card the + // reader is on — the last card whose top has crossed a trigger line just under + // the sticky bar. A plain scroll listener (rAF-throttled) is used instead of + // an IntersectionObserver so the boundary cases stay exact: at the very bottom + // the last card can't reach the trigger line, and at the very top the first + // card sits above it, so both ends are pinned explicitly. + useEffect(() => { + const root = scrollRef.current; + if (!root || !toolset || view !== 'api') return; + + const apiNames = toolset.apis.map((api) => api.apiName); + + // Honor a deep-link hash (#api-) on load; otherwise start at the top. + const hash = window.location.hash.replace(/^#/, ''); + const linked = apiNames.find((name) => toApiAnchor(name) === hash); + if (linked) { + setActiveApi(linked); + const card = root.querySelector(`#${CSS.escape(toApiAnchor(linked))}`); + requestAnimationFrame(() => card?.scrollIntoView({ block: 'start' })); + } else { + setActiveApi(apiNames[0]); + root.scrollTo({ top: 0 }); + } + + const TRIGGER = 96; // px below the scroll-area top — clears the sticky bar + let frame = 0; + + const compute = () => { + frame = 0; + if (root.scrollTop <= 0) return setActiveApi(apiNames[0]); + if (root.scrollTop + root.clientHeight >= root.scrollHeight - 2) + return setActiveApi(apiNames.at(-1)); + + const rootTop = root.getBoundingClientRect().top; + let current = apiNames[0]; + for (const name of apiNames) { + const el = document.getElementById(toApiAnchor(name)); + if (!el) continue; + if (el.getBoundingClientRect().top - rootTop <= TRIGGER) current = name; + else break; + } + setActiveApi(current); + }; + + const onScroll = () => { + if (frame) return; + frame = requestAnimationFrame(compute); + }; + + root.addEventListener('scroll', onScroll, { passive: true }); + return () => { + root.removeEventListener('scroll', onScroll); + if (frame) cancelAnimationFrame(frame); + }; + }, [toolset, view]); + + const handleSelect = (apiName: string) => { + setActiveApi(apiName); + const root = scrollRef.current; + const card = root?.querySelector(`#${CSS.escape(toApiAnchor(apiName))}`); + card?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Pin a shareable anchor without spamming browser history. + window.history.replaceState(null, '', `#${toApiAnchor(apiName)}`); + }; + if (!toolset) { return ( @@ -83,42 +180,68 @@ const DevtoolsToolPage = () => { } return ( - - - - - {toolset.toolsetName} - - {toolset.identifier} - - {toolset.apis.length} API{toolset.apis.length === 1 ? '' : 's'} - + + {view === 'api' && ( + + )} +
+ + + + + {toolset.toolsetName} + + {toolset.identifier} + + {toolset.apis.length} API{toolset.apis.length === 1 ? '' : 's'} + + + {toolset.toolsetDescription && ( + + {toolset.toolsetDescription} + + )} + + + + + + View + + setView(value as GalleryView)} + /> + + + + Lifecycle + + ({ + label: LIFECYCLE_MODE_LABEL[value], + value, + }))} + onChange={(value) => setMode(value as LifecycleMode)} + /> + + + + {view === 'api' && + toolset.apis.map((api) => ( + + ))} - {toolset.toolsetDescription && ( - - {toolset.toolsetDescription} - - )} - - - - Lifecycle - - ({ - label: LIFECYCLE_MODE_LABEL[value], - value, - }))} - onChange={(value) => setMode(value as LifecycleMode)} - /> - - - {toolset.apis.map((api) => ( - - ))} + {view === 'aggregate' && } +
); }; diff --git a/src/features/DevPanel/RenderGallery/ToolPreview.tsx b/src/features/DevPanel/RenderGallery/ToolPreview.tsx index 049871f328..f0743aebf8 100644 --- a/src/features/DevPanel/RenderGallery/ToolPreview.tsx +++ b/src/features/DevPanel/RenderGallery/ToolPreview.tsx @@ -1,16 +1,16 @@ 'use client'; -import { Block, Flexbox, Segmented, Tag, Text } from '@lobehub/ui'; +import { Flexbox, Segmented, Tag, Text } from '@lobehub/ui'; import { createStaticStyles } from 'antd-style'; -import { Component, type ReactNode, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { bodyKindForMode, deriveFixtureProps, - type FixtureBodyKind, type LifecycleMode, type ToolRenderFixtureVariant, } from './lifecycleMode'; +import { ToolBodySlot, ToolInspectorSlot } from './toolSurfaces'; import type { ApiEntry } from './useDevtoolsEntries'; import { toApiAnchor } from './useDevtoolsEntries'; @@ -24,7 +24,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ border-radius: 20px; background: ${cssVar.colorBgContainer}; - box-shadow: ${cssVar.boxShadowSecondary}; `, cardBody: css` padding: 20px; @@ -62,15 +61,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ font-size: 12px; color: ${cssVar.colorTextTertiary}; `, - missingShell: css` - padding-block: 12px; - padding-inline: 16px; - border: 1px dashed ${cssVar.colorBorderSecondary}; - border-radius: 12px; - - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - `, previewShell: css` padding: 16px; border-radius: 16px; @@ -82,51 +72,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ `, })); -class RenderBoundary extends Component< - { children: ReactNode; label: string }, - { error?: Error | undefined } -> { - constructor(props: { children: ReactNode; label: string }) { - super(props); - this.state = { error: undefined }; - } - - static getDerivedStateFromError(error: Error) { - return { error }; - } - - override render() { - if (!this.state.error) return this.props.children; - - return ( - - - - {this.props.label} crashed - - - {this.state.error.message} - - - - ); - } -} - -const coerceInspectorContent = (value: unknown): string | null => { - if (value === null || value === undefined) return null; - if (typeof value === 'string') return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -}; - -const Missing = ({ kind }: { kind: string }) => ( -
No {kind} component registered for this API.
-); - interface ToolPreviewProps { api: ApiEntry; mode: LifecycleMode; @@ -136,12 +81,6 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => { const messageId = `devtools-${api.identifier}-${api.apiName}`; const toolCallId = `${messageId}-tool`; - const Inspector = api.inspector; - const Render = api.render; - const Streaming = api.streaming; - const Placeholder = api.placeholder; - const Intervention = api.intervention; - const variants = api.fixture.variants; const [activeVariantId, setActiveVariantId] = useState(variants[0]?.id ?? 'default'); const activeVariant: ToolRenderFixtureVariant = @@ -149,94 +88,6 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => { const derived = useMemo(() => deriveFixtureProps(activeVariant, mode), [activeVariant, mode]); - const targetBodyKind: FixtureBodyKind = bodyKindForMode(mode); - - const inspectorResult = { - content: coerceInspectorContent(activeVariant.content), - error: derived.pluginError, - state: derived.pluginState, - }; - - const bodyContent = (() => { - switch (targetBodyKind) { - case 'streaming': { - if (Streaming) { - return ( - - - - ); - } - // No dedicated Streaming slot — fall back to Render shown in streaming state. - if (Render) { - return ( - - - - ); - } - return ; - } - case 'placeholder': { - return Placeholder ? ( - - - - ) : ( - - ); - } - case 'intervention': { - return Intervention ? ( - - - - ) : ( - - ); - } - default: { - return Render ? ( - - - - ) : ( - - ); - } - } - })(); - return ( @@ -272,22 +123,7 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => {
- {Inspector ? ( - - - - ) : ( - - )} +
@@ -296,9 +132,17 @@ const ToolPreview = ({ api, mode }: ToolPreviewProps) => { Body - {targetBodyKind} + {bodyKindForMode(mode)}
-
{bodyContent}
+
+ +
diff --git a/src/features/DevPanel/RenderGallery/fixtures/claude-code.ts b/src/features/DevPanel/RenderGallery/fixtures/claude-code.ts index 185937b38f..1cf34c6770 100644 --- a/src/features/DevPanel/RenderGallery/fixtures/claude-code.ts +++ b/src/features/DevPanel/RenderGallery/fixtures/claude-code.ts @@ -75,6 +75,14 @@ export default defineFixtures({ description: 'Look up deferred tools by name or keyword.', name: 'ToolSearch', }, + { + description: 'Fetch a URL and answer a prompt about it.', + name: 'WebFetch', + }, + { + description: 'Search the web.', + name: 'WebSearch', + }, { description: 'Write a new file.', name: 'Write', @@ -366,6 +374,22 @@ export default defineFixtures({ args: { max_results: 5, query: 'select:Read,Edit,Grep' }, content: 'Loaded 3 deferred tool schemas: Read, Edit, Grep.', }), + WebFetch: single({ + args: { + prompt: 'Summarize the key changes in the latest release.', + url: 'https://github.com/lobehub/lobe-chat/releases/latest', + }, + content: + '## LobeChat v1.0\n\n- New agent runtime with tool streaming\n- Faster cold start\n- Fixed a memory leak in the chat store', + }), + WebSearch: single({ + args: { + allowed_domains: ['developer.mozilla.org'], + query: 'CSS container queries browser support', + }, + content: + '1. Container queries — MDN — developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment\n2. Can I use: CSS Container Queries — caniuse.com/css-container-queries', + }), Write: single({ args: { content: "export const previewEnabled = process.env.NODE_ENV === 'development';\n", diff --git a/src/features/DevPanel/RenderGallery/fixtures/index.ts b/src/features/DevPanel/RenderGallery/fixtures/index.ts index 2627968cd7..74cdafedcd 100644 --- a/src/features/DevPanel/RenderGallery/fixtures/index.ts +++ b/src/features/DevPanel/RenderGallery/fixtures/index.ts @@ -1,6 +1,7 @@ 'use client'; import { builtinTools } from '@lobechat/builtin-tools'; +import { DEFAULT_INBOX_AVATAR } from '@lobechat/const'; import type { BuiltinToolManifest, LobeChatPluginApi } from '@lobechat/types'; import type { ToolRenderFixture } from '../lifecycleMode'; @@ -40,6 +41,19 @@ export interface ToolRenderMeta { export const DEVTOOLS_GROUP_ID = 'devtools-preview-group'; +/** + * Identity for the seeded Aggregate-preview conversation. The fixture messages + * resolve their avatar/name through this agentId, so seeding `agentMap` with + * this meta makes the preview turn read as "Lobe AI" instead of the + * unresolved-agent fallback ("Unnamed Assistant"). + */ +export const DEVTOOLS_AGENT_ID = 'devtools-render-gallery'; + +export const DEVTOOLS_AGENT_META = { + avatar: DEFAULT_INBOX_AVATAR, + title: 'Lobe AI', +}; + export const DEVTOOLS_GROUP_DETAIL = { agents: [ { diff --git a/src/features/DevPanel/RenderGallery/fixtures/lobe-agent-management.ts b/src/features/DevPanel/RenderGallery/fixtures/lobe-agent-management.ts index c28c919fe1..1353d75f16 100644 --- a/src/features/DevPanel/RenderGallery/fixtures/lobe-agent-management.ts +++ b/src/features/DevPanel/RenderGallery/fixtures/lobe-agent-management.ts @@ -7,6 +7,7 @@ export default defineFixtures({ fixtures: { callAgent: single({ args: { + agentId: 'agent_workspace_helper', instruction: 'Review the `/devtools` route and list any preview cards that still need richer fixtures.', }, @@ -22,6 +23,10 @@ export default defineFixtures({ }, }), duplicateAgent: single({ + args: { + agentId: 'agent_workspace_helper', + newTitle: 'Workspace Helper Copy', + }, pluginState: { newAgentId: 'agent_preview_clone', sourceAgentId: 'agent_workspace_helper', @@ -29,6 +34,9 @@ export default defineFixtures({ }, }), getAgentDetail: single({ + args: { + agentId: 'agent_preview_specialist', + }, pluginState: { config: { model: 'gpt-5.4', @@ -46,6 +54,11 @@ export default defineFixtures({ }, }), installPlugin: single({ + args: { + agentId: 'agent_preview_specialist', + identifier: 'lobe-cloud-sandbox', + source: 'official', + }, pluginState: { installed: true, pluginId: 'lobe-cloud-sandbox', @@ -53,6 +66,10 @@ export default defineFixtures({ }, }), searchAgent: single({ + args: { + keyword: 'preview', + source: 'all', + }, pluginState: { agents: [ { @@ -76,6 +93,7 @@ export default defineFixtures({ }), updateAgent: single({ args: { + agentId: 'agent_preview_specialist', config: JSON.stringify({ model: 'gpt-5.4', systemRole: 'Prioritize maintainable developer tooling and preview coverage.', @@ -88,6 +106,7 @@ export default defineFixtures({ }), updatePrompt: single({ args: { + agentId: 'agent_preview_specialist', prompt: 'When asked for a visual check, prefer building a reusable preview harness before taking a screenshot.', }, diff --git a/src/features/DevPanel/RenderGallery/fixtures/lobe-local-system.ts b/src/features/DevPanel/RenderGallery/fixtures/lobe-local-system.ts index cb4e171d23..2428570ffe 100644 --- a/src/features/DevPanel/RenderGallery/fixtures/lobe-local-system.ts +++ b/src/features/DevPanel/RenderGallery/fixtures/lobe-local-system.ts @@ -5,23 +5,25 @@ import { defineFixtures, single, variants } from './_helpers'; export default defineFixtures({ identifier: 'lobe-local-system', fixtures: { - editLocalFile: single({ + editFile: single({ args: { path: '/workspace/src/spa/router/desktopRouter.config.tsx' }, pluginState: { diffText: "--- a/workspace/src/spa/router/desktopRouter.config.tsx\n+++ b/workspace/src/spa/router/desktopRouter.config.tsx\n@@ -1,3 +1,7 @@\n export const desktopRoutes = [\n+ {\n+ path: 'devtools',\n+ },\n ];\n", }, }), - listLocalFiles: single({ + listFiles: single({ + args: { path: '/workspace' }, pluginState: { files: [ - { isDirectory: true, name: 'src' }, - { isDirectory: false, name: 'package.json', size: 1320 }, - { isDirectory: false, name: 'README.md', size: 4096 }, + { isDirectory: true, name: 'src', path: '/workspace/src' }, + { isDirectory: false, name: 'package.json', path: '/workspace/package.json', size: 1320 }, + { isDirectory: false, name: 'README.md', path: '/workspace/README.md', size: 4096 }, ], + totalCount: 3, }, }), - moveLocalFiles: single({ + moveFiles: single({ args: { items: [ { @@ -31,7 +33,7 @@ export default defineFixtures({ ], }, }), - readLocalFile: single({ + readFile: single({ args: { path: '/workspace/src/routes/(main)/devtools/index.tsx' }, pluginState: { content: @@ -54,7 +56,7 @@ export default defineFixtures({ success: true, }, }), - searchLocalFiles: variants([ + searchFiles: variants([ { args: { keywords: 'quarterly report sample' }, label: 'Multiple matches', @@ -97,7 +99,7 @@ export default defineFixtures({ }, }, ]), - writeLocalFile: single({ + writeFile: single({ args: { content: 'export const devtoolsEnabled = process.env.NODE_ENV === "development";\n', path: '/workspace/src/routes/(main)/devtools/flags.ts', diff --git a/src/features/DevPanel/RenderGallery/index.tsx b/src/features/DevPanel/RenderGallery/index.tsx index 79bd4eb98d..aee5182e91 100644 --- a/src/features/DevPanel/RenderGallery/index.tsx +++ b/src/features/DevPanel/RenderGallery/index.tsx @@ -5,16 +5,26 @@ import { createStaticStyles } from 'antd-style'; import { useEffect } from 'react'; import { Outlet, useNavigate, useParams } from 'react-router-dom'; +import { useAgentStore } from '@/store/agent'; import { useAgentGroupStore } from '@/store/agentGroup'; -import { DEVTOOLS_GROUP_DETAIL, DEVTOOLS_GROUP_ID } from './fixtures'; +import { + DEVTOOLS_AGENT_ID, + DEVTOOLS_AGENT_META, + DEVTOOLS_GROUP_DETAIL, + DEVTOOLS_GROUP_ID, +} from './fixtures'; import Sidebar from './Sidebar'; import { toToolsetPath, useDevtoolsEntries } from './useDevtoolsEntries'; const styles = createStaticStyles(({ css, cssVar }) => ({ main: css` - overflow: auto; + overflow: hidden; flex: 1; + + min-width: 0; + min-height: 0; + background: radial-gradient(circle at top, ${cssVar.colorFillTertiary} 0%, transparent 35%), ${cssVar.colorBgLayout}; @@ -22,7 +32,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ page: css` overflow: hidden; width: 100%; - height: 100%; + + /* Bind to the viewport directly so the columns scroll internally regardless + of whether the mounting route provides a bounded height. */ + height: 100dvh; `, })); @@ -42,11 +55,19 @@ const DevtoolsLayout = () => { }, }); + // Seed the Aggregate-preview agent meta so its turns read as "Lobe AI" + // (avatar + name) instead of the unresolved-agent fallback. + const previousAgentMap = useAgentStore.getState().agentMap; + useAgentStore.setState({ + agentMap: { ...previousAgentMap, [DEVTOOLS_AGENT_ID]: DEVTOOLS_AGENT_META as any }, + }); + return () => { useAgentGroupStore.setState({ activeGroupId: previousGroupState.activeGroupId, groupMap: previousGroupState.groupMap, }); + useAgentStore.setState({ agentMap: previousAgentMap }); }; }, []); diff --git a/src/features/DevPanel/RenderGallery/toolSurfaces.tsx b/src/features/DevPanel/RenderGallery/toolSurfaces.tsx new file mode 100644 index 0000000000..b6871901c4 --- /dev/null +++ b/src/features/DevPanel/RenderGallery/toolSurfaces.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { Block, Flexbox, Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { Component, memo, type ReactNode } from 'react'; + +import { + bodyKindForMode, + type DerivedFixtureProps, + type LifecycleMode, + type ToolRenderFixtureVariant, +} from './lifecycleMode'; +import type { ApiEntry } from './useDevtoolsEntries'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + missingShell: css` + padding-block: 12px; + padding-inline: 16px; + border: 1px dashed ${cssVar.colorBorderSecondary}; + border-radius: 12px; + + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + `, +})); + +/** Catches a render/inspector crash so one bad fixture can't blank the page. */ +export class RenderBoundary extends Component< + { children: ReactNode; label: string }, + { error?: Error | undefined } +> { + constructor(props: { children: ReactNode; label: string }) { + super(props); + this.state = { error: undefined }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + override render() { + if (!this.state.error) return this.props.children; + + return ( + + + + {this.props.label} crashed + + + {this.state.error.message} + + + + ); + } +} + +const Missing = ({ kind }: { kind: string }) => ( +
No {kind} component registered for this API.
+); + +const coerceInspectorContent = (value: unknown): string | null => { + if (value === null || value === undefined) return null; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +interface ToolInspectorSlotProps { + api: ApiEntry; + derived: DerivedFixtureProps; + variant: ToolRenderFixtureVariant; +} + +/** Renders the API's Inspector with the lifecycle-derived props, or a Missing hint. */ +export const ToolInspectorSlot = memo(({ api, derived, variant }) => { + const Inspector = api.inspector; + if (!Inspector) return ; + + return ( + + + + ); +}); + +ToolInspectorSlot.displayName = 'ToolInspectorSlot'; + +interface ToolBodySlotProps { + api: ApiEntry; + derived: DerivedFixtureProps; + /** Aggregate flow renders nothing for an absent slot instead of a Missing hint. */ + hideMissing?: boolean; + messageId: string; + mode: LifecycleMode; + toolCallId: string; +} + +/** + * Renders the API's body surface for the active lifecycle mode — the dedicated + * Streaming / Placeholder / Intervention component when the mode targets one, + * otherwise the Render. Streaming falls back to the Render shown mid-stream when + * no Streaming slot exists. + */ +export const ToolBodySlot = memo( + ({ api, derived, mode, messageId, toolCallId, hideMissing }) => { + const missing = (kind: string) => (hideMissing ? null : ); + + const renderSlot = () => + api.render ? ( + + + + ) : ( + missing('render') + ); + + switch (bodyKindForMode(mode)) { + case 'streaming': { + if (api.streaming) { + return ( + + + + ); + } + // No dedicated Streaming slot — fall back to the Render shown mid-stream. + return api.render ? renderSlot() : missing('streaming'); + } + case 'placeholder': { + return api.placeholder ? ( + + + + ) : ( + missing('placeholder') + ); + } + case 'intervention': { + return api.intervention ? ( + + + + ) : ( + missing('intervention') + ); + } + default: { + return renderSlot(); + } + } + }, +); + +ToolBodySlot.displayName = 'ToolBodySlot'; diff --git a/src/features/DevPanel/RenderGallery/useDevtoolsEntries.ts b/src/features/DevPanel/RenderGallery/useDevtoolsEntries.ts index 2aa108658f..bf2fdf6440 100644 --- a/src/features/DevPanel/RenderGallery/useDevtoolsEntries.ts +++ b/src/features/DevPanel/RenderGallery/useDevtoolsEntries.ts @@ -42,6 +42,18 @@ export interface DevtoolsEntries { toolsetMap: Map; } +/** Toolsets that still ship renders but are deprecated — hidden from the gallery. */ +const DEPRECATED_TOOLSETS = new Set(['lobe-notebook']); + +/** + * Legacy `*Local*` aliases (e.g. `grepLocalFiles`, `listLocalFiles`) only stay + * registered so historical DB messages keep rendering after the rename — they + * have no manifest/fixture, so they show up as empty cards. Current local-system + * API names carry no `Local` marker, so hiding by that marker is safe. + */ +const isDeprecatedApi = (identifier: string, apiName: string) => + identifier === 'lobe-local-system' && apiName.includes('Local'); + export const toToolsetPath = (identifier: string) => `/devtools/${encodeURIComponent(identifier)}`; export const toApiAnchor = (apiName: string) => `api-${apiName}`; @@ -106,6 +118,9 @@ export const useDevtoolsEntries = (): DevtoolsEntries => render, streaming, } of byKey.values()) { + if (DEPRECATED_TOOLSETS.has(identifier)) continue; + if (isDeprecatedApi(identifier, apiName)) continue; + const meta = getToolRenderMeta(identifier, apiName); const fixture = getToolRenderFixture(identifier, apiName, meta.api); diff --git a/src/locales/default/plugin.ts b/src/locales/default/plugin.ts index ce810502d0..9ac7321a59 100644 --- a/src/locales/default/plugin.ts +++ b/src/locales/default/plugin.ts @@ -63,6 +63,11 @@ export default { 'builtins.lobe-agent.title': 'Lobe Agent', 'builtins.lobe-claude-code.agent.instruction': 'Instruction', 'builtins.lobe-claude-code.agent.result': 'Result', + 'builtins.lobe-claude-code.askUserQuestion.noAnswer': + 'No answer received — model continued without their input.', + 'builtins.lobe-claude-code.askUserQuestion.question': 'Question', + 'builtins.lobe-claude-code.askUserQuestion.reply': 'Reply', + 'builtins.lobe-claude-code.askUserQuestion.selected': 'Selected', 'builtins.lobe-claude-code.task.create.completed': 'Task created: ', 'builtins.lobe-claude-code.task.create.loading': 'Creating task: ', 'builtins.lobe-claude-code.task.getLabel': 'View task #{{taskId}}', diff --git a/src/locales/default/setting.ts b/src/locales/default/setting.ts index a2f3d68c88..9ea5131471 100644 --- a/src/locales/default/setting.ts +++ b/src/locales/default/setting.ts @@ -328,6 +328,11 @@ export default { 'devices.actions.edit': 'Edit', 'devices.actions.remove': 'Remove', 'devices.channel.connected': 'Connected {{time}}', + 'devices.currentBadge': 'This device', + 'devices.detail.connections': 'Connections', + 'devices.detail.noRecent': 'No recent directories', + 'devices.detail.recentDirs': 'Recent directories', + 'devices.edit.browse': 'Browse…', 'devices.edit.cancel': 'Cancel', 'devices.edit.defaultCwd': 'Default working directory', 'devices.edit.defaultCwdPlaceholder': 'e.g. /Users/me/projects', diff --git a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx index 39556b1a60..8f4ca3a819 100644 --- a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx +++ b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'; import { useAgentId } from '@/features/ChatInput/hooks/useAgentId'; import CloudRepoSwitcher from '@/features/ChatInput/RuntimeConfig/CloudRepoSwitcher'; +import DeviceWorkingDirectory from '@/features/ChatInput/RuntimeConfig/DeviceWorkingDirectory'; import GitStatus from '@/features/ChatInput/RuntimeConfig/GitStatus'; import HeteroDeviceSwitcher from '@/features/ChatInput/RuntimeConfig/HeteroDeviceSwitcher'; import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType'; @@ -84,6 +85,12 @@ const WorkingDirectoryBar = memo(() => { const enableExecutionDeviceSwitcher = useUserStore( labPreferSelectors.enableExecutionDeviceSwitcher, ); + const agencyConfig = useAgentStore((s) => + agentId ? agentByIdSelectors.getAgencyConfigById(agentId)(s) : undefined, + ); + // Runs dispatched to a remote device can't browse the local filesystem — use + // the device-scoped picker (recent dirs + manual input) instead. + const isDeviceMode = agencyConfig?.executionTarget === 'device' && !!agencyConfig?.boundDeviceId; const repoType = useRepoType(effectiveWorkingDirectory); @@ -101,7 +108,11 @@ const WorkingDirectoryBar = memo(() => { {enableExecutionDeviceSwitcher && } - + {isDeviceMode ? ( + + ) : ( + + )} ); @@ -139,28 +150,37 @@ const WorkingDirectoryBar = memo(() => { {enableExecutionDeviceSwitcher && } - setOpen(false)} />} - open={open} - placement="bottomLeft" - styles={{ content: { padding: 4 } }} - trigger="click" - onOpenChange={setOpen} - > -
- {open ? ( - dirButton - ) : ( - - {dirButton} - + {isDeviceMode ? ( + // A remote device's filesystem isn't browsable from here — use the + // device-scoped picker (recent dirs + manual input) instead of the + // local folder picker + git status. + + ) : ( + <> + setOpen(false)} />} + open={open} + placement="bottomLeft" + styles={{ content: { padding: 4 } }} + trigger="click" + onOpenChange={setOpen} + > +
+ {open ? ( + dirButton + ) : ( + + {dirButton} + + )} +
+
+ {effectiveWorkingDirectory && repoType && ( + )} -
-
- {effectiveWorkingDirectory && repoType && ( - + )}
{fullAccessBadge} diff --git a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/index.tsx b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/index.tsx index 15e4807313..706d5c0dfd 100644 --- a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/index.tsx @@ -44,20 +44,27 @@ const HeterogeneousChatInput = memo(() => { const navigate = useNavigate(); const providerType = useAgentStore(agentSelectors.currentAgentHeterogeneousProviderType); + const executionTarget = useAgentStore(agentSelectors.currentAgentExecutionTarget); const isRemoteAgent = !!providerType && isRemoteHeterogeneousType(providerType); - const { status, refresh } = useRemoteAgentDeviceGuard({ enabled: isRemoteAgent }); + // A run goes to an `lh connect` device when the provider is a remote-only type + // (openclaw / hermes) OR a local-CLI type (claude-code / codex) explicitly + // targeted at a device. Either way the bound device must be online before we + // let the user send — guard it here instead of failing at dispatch time. + const isDeviceExecution = isRemoteAgent || executionTarget === 'device'; + + const { status, refresh } = useRemoteAgentDeviceGuard({ enabled: isDeviceExecution }); const goToAgentProfile = () => { if (params.aid) navigate(urlJoin('/agent', params.aid, 'profile')); }; const deviceBlocked = - isRemoteAgent && + isDeviceExecution && (status === 'device-offline' || status === 'platform-unavailable' || status === 'no-device'); const renderDeviceGuard = () => { - if (!isRemoteAgent || !deviceBlocked) return null; + if (!deviceBlocked) return null; let title: string; let desc: string; @@ -69,7 +76,9 @@ const HeterogeneousChatInput = memo(() => { title = t('platformAgent.deviceGuard.deviceOffline.title'); desc = t('platformAgent.deviceGuard.deviceOffline.desc'); } else { - const name = HETEROGENEOUS_TYPE_LABELS[providerType] ?? providerType; + // `platform-unavailable` only arises for remote-typed agents (the guard's + // capability check), so providerType is always set here — fall back safely. + const name = (providerType && HETEROGENEOUS_TYPE_LABELS[providerType]) || providerType || ''; title = t('platformAgent.deviceGuard.platformUnavailable.title', { name }); desc = t('platformAgent.deviceGuard.platformUnavailable.desc', { name }); } @@ -97,11 +106,13 @@ const HeterogeneousChatInput = memo(() => { ); }; - const inputDisabled = (!isConfigured && !isRemoteAgent) || deviceBlocked; + // Device execution doesn't use the cloud sandbox, so it doesn't need cloud + // credentials — only the sandbox path gates on `isConfigured`. + const inputDisabled = (!isConfigured && !isDeviceExecution) || deviceBlocked; return ( - {!isRemoteAgent && !isConfigured && ( + {!isDeviceExecution && !isConfigured && ( ({ + container: css` + padding-block: 16px; + padding-inline: 20px; + `, + dot: css` + width: 6px; + height: 6px; + border-radius: 50%; + `, + header: css` + padding-block-end: 16px; + border-block-end: 1px solid ${cssVar.colorBorderSecondary}; + `, + label: css` + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + `, + path: css` + overflow: hidden; + + font-family: ${cssVar.fontFamilyCode}; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + `, + recentRow: css` + padding-block: 6px; + padding-inline: 8px; + border-radius: ${cssVar.borderRadius}; + + &:hover { + background: ${cssVar.colorFillTertiary}; + } + `, + removeBtn: css` + cursor: pointer; + flex: none; + color: ${cssVar.colorTextQuaternary}; + + &:hover { + color: ${cssVar.colorText}; + } + `, +})); + +interface DeviceDetailPanelProps { + device: DeviceListItem; + isCurrent?: boolean; + onClose: () => void; +} + +const DeviceDetailPanel = memo(({ device, isCurrent, onClose }) => { + const { t } = useTranslation('setting'); + const utils = lambdaQuery.useUtils(); + + const [name, setName] = useState(device.friendlyName ?? ''); + const [cwd, setCwd] = useState(device.defaultCwd ?? ''); + + const update = lambdaQuery.device.updateDevice.useMutation({ + onSuccess: () => utils.device.listDevices.invalidate(), + }); + + // Only the machine you're on can browse its own filesystem natively. + const canBrowse = !!isCurrent && isDesktop; + + // Render the device's live connections straight from `device.channels` — one + // row per connection; an empty array means offline. + const channels = device.channels ?? []; + + const isDirty = name !== (device.friendlyName ?? '') || cwd !== (device.defaultCwd ?? ''); + + const handleSave = async () => { + const trimmed = cwd.trim(); + await update.mutateAsync({ + defaultCwd: trimmed || null, + deviceId: device.deviceId, + friendlyName: name.trim() || null, + // Setting a default cwd also seeds the recent list. + recentCwds: trimmed ? nextRecentCwds(trimmed, device.recentCwds) : device.recentCwds, + }); + }; + + const handleBrowse = async () => { + const result = await electronSystemService.selectFolder({ + defaultPath: cwd.trim() || undefined, + title: t('devices.edit.defaultCwd'), + }); + if (result?.path) setCwd(result.path); + }; + + const handleRemoveRecent = (path: string) => { + update.mutate({ + deviceId: device.deviceId, + recentCwds: device.recentCwds.filter((p) => p !== path), + }); + }; + + return ( + + {/* ─── Header ─── */} + + {getDeviceIcon(device.platform)} + + {device.friendlyName || device.hostname || device.deviceId} + + {isCurrent && {t('devices.currentBadge')}} + + + + {/* ─── Connections ─── */} + + {t('devices.detail.connections')} + {channels.length > 0 ? ( + channels.map((channel, index) => ( + + + {channel.channel && {channel.channel}} + + {t('devices.channel.connected', { time: dayjs(channel.connectedAt).fromNow() })} + + + )) + ) : ( + + + + {t('devices.status.offline')} ·{' '} + {t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() })} + + + )} + + + {/* ─── Name ─── */} + + {t('devices.edit.friendlyName')} + setName(e.target.value)} + /> + + + {/* ─── Default working directory ─── */} + + {t('devices.edit.defaultCwd')} + + setCwd(e.target.value)} + /> + {canBrowse && ( + + )} + + + + {/* ─── Recent directories ─── */} + + {t('devices.detail.recentDirs')} + {device.recentCwds.length === 0 ? ( + + {t('devices.detail.noRecent')} + + ) : ( + device.recentCwds.map((path) => ( + + setCwd(path)} + > + {path} + + handleRemoveRecent(path)} + /> + + )) + )} + + + {/* ─── Save ─── */} + {isDirty && ( + + + + )} + + ); +}); + +DeviceDetailPanel.displayName = 'DeviceDetailPanel'; + +export default DeviceDetailPanel; diff --git a/src/routes/(main)/settings/devices/features/DeviceItem.tsx b/src/routes/(main)/settings/devices/features/DeviceItem.tsx index 2802965bdb..be4a417477 100644 --- a/src/routes/(main)/settings/devices/features/DeviceItem.tsx +++ b/src/routes/(main)/settings/devices/features/DeviceItem.tsx @@ -1,17 +1,11 @@ 'use client'; -import { ActionIcon, DropdownMenu, Flexbox, Icon, Input, Tag, Text, Tooltip } from '@lobehub/ui'; -import { confirmModal, Modal } from '@lobehub/ui/base-ui'; -import { createStaticStyles, cssVar } from 'antd-style'; +import { ActionIcon, DropdownMenu, Flexbox, Icon, Tag, Text, Tooltip } from '@lobehub/ui'; +import { confirmModal } from '@lobehub/ui/base-ui'; +import { createStaticStyles, cssVar, cx } from 'antd-style'; import dayjs from 'dayjs'; -import { - FolderIcon, - MoreVerticalIcon, - PencilLineIcon, - Trash2Icon, - TriangleAlertIcon, -} from 'lucide-react'; -import { memo, useState } from 'react'; +import { FolderIcon, MoreVerticalIcon, Trash2Icon, TriangleAlertIcon } from 'lucide-react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { lambdaQuery } from '@/libs/trpc/client'; @@ -40,10 +34,6 @@ export interface DeviceListItem { } const styles = createStaticStyles(({ css }) => ({ - channels: css` - margin-block-start: 4px; - padding-inline-start: 30px; - `, cwd: css` overflow: hidden; font-family: ${cssVar.fontFamilyCode}; @@ -67,55 +57,52 @@ const styles = createStaticStyles(({ css }) => ({ color: ${cssVar.colorTextSecondary}; `, row: css` + cursor: pointer; + padding-block: 12px; padding-inline: 12px; border-radius: ${cssVar.borderRadiusLG}; + transition: background 0.2s; + &:hover { background: ${cssVar.colorFillTertiary}; } `, + rowActive: css` + background: ${cssVar.colorFillSecondary}; + + &:hover { + background: ${cssVar.colorFillSecondary}; + } + `, })); -const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => { +interface DeviceItemProps { + device: DeviceListItem; + isCurrent?: boolean; + onSelect: () => void; + selected?: boolean; +} + +const DeviceItem = memo(({ device, isCurrent, onSelect, selected }) => { const { t } = useTranslation('setting'); const utils = lambdaQuery.useUtils(); - const [editOpen, setEditOpen] = useState(false); - const [name, setName] = useState(''); - const [cwd, setCwd] = useState(''); - - const updateDevice = lambdaQuery.device.updateDevice.useMutation({ - onSuccess: () => utils.device.listDevices.invalidate(), - }); const removeDevice = lambdaQuery.device.removeDevice.useMutation({ onSuccess: () => utils.device.listDevices.invalidate(), }); const displayName = device.friendlyName || device.hostname || device.deviceId; const isFallback = device.identitySource === 'fallback'; - // `channels` may be absent when the backend predates the channel-aware - // `listDevices` shape; fall back to a single synthetic channel when online. - const channels = - device.channels ?? - (device.online - ? [{ channel: null, connectedAt: device.lastSeen, hostname: null, platform: null }] - : []); - - const openEdit = () => { - setName(device.friendlyName ?? ''); - setCwd(device.defaultCwd ?? ''); - setEditOpen(true); - }; - - const handleSave = async () => { - await updateDevice.mutateAsync({ - defaultCwd: cwd.trim() || null, - deviceId: device.deviceId, - friendlyName: name.trim() || null, - }); - setEditOpen(false); - }; + // Online when the device has at least one live connection in `device.channels`. + const channels = device.channels ?? []; + const online = channels.length > 0; + const statusTooltip = online + ? t('devices.channel.connected', { + time: dayjs(channels[0]?.connectedAt ?? device.lastSeen).fromNow(), + }) + : t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() }); const handleRemove = () => confirmModal({ @@ -129,66 +116,43 @@ const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => { }); return ( - <> - - - {getDeviceIcon(device.platform)} - - - - - {displayName} - - {isFallback && ( - - }>{t('devices.fallbackBadge')} - - )} - - {device.defaultCwd && ( - - - - {device.defaultCwd} - - + + + {getDeviceIcon(device.platform)} + + + + + {displayName} + + + + + {isCurrent && {t('devices.currentBadge')}} + {isFallback && ( + + }>{t('devices.fallbackBadge')} + )} - - {channels.length > 0 ? ( - channels.map((channel, index) => ( - - - - {channel.channel ? `${channel.channel} · ` : ''} - {t('devices.channel.connected', { time: dayjs(channel.connectedAt).fromNow() })} - - - )) - ) : ( - - - - {t('devices.status.offline')} ·{' '} - {t('devices.lastSeen', { time: dayjs(device.lastSeen).fromNow() })} - - - )} - + {device.defaultCwd && ( + + + + {device.defaultCwd} + + + )} + + e.stopPropagation()}> , - key: 'edit', - label: t('devices.actions.edit'), - onClick: openEdit, - }, - { type: 'divider' }, { danger: true, icon: , @@ -200,37 +164,8 @@ const DeviceItem = memo<{ device: DeviceListItem }>(({ device }) => { > - - setEditOpen(false)} - onOk={handleSave} - > - - - {t('devices.edit.friendlyName')} - setName(e.target.value)} - /> - - - {t('devices.edit.defaultCwd')} - setCwd(e.target.value)} - /> - - - - + + ); }); diff --git a/src/routes/(main)/settings/devices/features/DeviceList.tsx b/src/routes/(main)/settings/devices/features/DeviceList.tsx index 853465c08c..8997303d06 100644 --- a/src/routes/(main)/settings/devices/features/DeviceList.tsx +++ b/src/routes/(main)/settings/devices/features/DeviceList.tsx @@ -1,16 +1,21 @@ 'use client'; +import { isDesktop } from '@lobechat/const'; import { Flexbox, Skeleton, Text } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; -import { memo } from 'react'; +import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { lambdaQuery } from '@/libs/trpc/client'; +import { useElectronStore } from '@/store/electron'; +import DeviceDetailPanel from './DeviceDetailPanel'; import DeviceItem from './DeviceItem'; const styles = createStaticStyles(({ css }) => ({ - container: css` + detailCol: css` + align-self: stretch; + min-width: 0; border: 1px solid ${cssVar.colorBorderSecondary}; border-radius: ${cssVar.borderRadiusLG}; `, @@ -19,6 +24,11 @@ const styles = createStaticStyles(({ css }) => ({ color: ${cssVar.colorTextTertiary}; text-align: center; `, + listCol: css` + min-width: 0; + border: 1px solid ${cssVar.colorBorderSecondary}; + border-radius: ${cssVar.borderRadiusLG}; + `, })); const DeviceList = memo(() => { @@ -27,6 +37,18 @@ const DeviceList = memo(() => { staleTime: 30_000, }); + // Identify which row is the machine the user is on right now (desktop only — + // the web client isn't itself a registered device), so it can be badged and + // offered a native folder picker for its working directory. + const useFetchDeviceInfo = useElectronStore((s) => s.useFetchGatewayDeviceInfo); + const gatewayDeviceInfo = useElectronStore((s) => s.gatewayDeviceInfo); + useFetchDeviceInfo(); + const currentDeviceId = isDesktop ? gatewayDeviceInfo?.deviceId : undefined; + + // No device is selected by default — the detail panel only appears once the + // user clicks a row. + const [selectedId, setSelectedId] = useState(); + if (isLoading) return ; if (!devices || devices.length === 0) @@ -36,11 +58,35 @@ const DeviceList = memo(() => { ); + const selected = selectedId ? devices.find((d) => d.deviceId === selectedId) : undefined; + const isCurrent = (id: string) => !!currentDeviceId && id === currentDeviceId; + return ( - - {devices.map((device) => ( - - ))} + + + {devices.map((device) => ( + + setSelectedId((prev) => (prev === device.deviceId ? undefined : device.deviceId)) + } + /> + ))} + + {selected && ( + + {/* keyed on deviceId so the form state resets when the selection changes */} + setSelectedId(undefined)} + /> + + )} ); }); diff --git a/src/server/services/aiAgent/index.ts b/src/server/services/aiAgent/index.ts index fae89c93e7..146f63aaa4 100644 --- a/src/server/services/aiAgent/index.ts +++ b/src/server/services/aiAgent/index.ts @@ -46,6 +46,7 @@ import { AgentModel } from '@/database/models/agent'; import { AgentOperationModel } from '@/database/models/agentOperation'; import { AgentSkillModel } from '@/database/models/agentSkill'; import { AiModelModel } from '@/database/models/aiModel'; +import { DeviceModel } from '@/database/models/device'; import { FileModel } from '@/database/models/file'; import { MessageModel } from '@/database/models/message'; import { PluginModel } from '@/database/models/plugin'; @@ -946,9 +947,39 @@ export class AiAgentService { userMessageId: userMsg?.id ?? parentMessageId ?? '', }; } + // Resolve the working directory for the run: a topic-level override + // wins, else the device's user-configured defaultCwd. The device row + // lives in the DB (the gateway only knows live connections), so read + // it directly rather than via deviceProxy. + const boundDevice = await new DeviceModel(this.db, this.userId).findByDeviceId( + dispatchDeviceId, + ); + // Prefer the topic's own pinned cwd — an existing topic carries it in + // `metadata.workingDirectory`, whereas `initialTopicMetadata` is only + // populated for a brand-new topic. Fall back to the device default. + const deviceCwd = + topic?.metadata?.workingDirectory || + appContext?.initialTopicMetadata?.workingDirectory || + boundDevice?.defaultCwd || + undefined; + + // A device is the user's own persistent machine — build a + // device-specific context instead of reusing the cloud-sandbox one + // (which describes an ephemeral /workspace + pre-cloned repos and + // would mislead the agent). + const { buildRemoteDeviceHeteroContext } = + await import('@/server/services/heterogeneousAgent/remoteDeviceHeteroContext'); + const deviceSystemContext = buildRemoteDeviceHeteroContext({ + agentSystemContext: agentConfig.agencyConfig?.heterogeneousProvider?.systemContext, + conversationHistory, + cwd: deviceCwd, + }); + const result = await deviceProxy.dispatchAgentRun({ ...heteroParams, + cwd: deviceCwd, deviceId: dispatchDeviceId, + systemContext: deviceSystemContext, }); if (!result.success) { log('execAgent: hetero device dispatch failed: %s', result.error); diff --git a/src/server/services/heterogeneousAgent/remoteDeviceHeteroContext.test.ts b/src/server/services/heterogeneousAgent/remoteDeviceHeteroContext.test.ts new file mode 100644 index 0000000000..54b5dc2617 --- /dev/null +++ b/src/server/services/heterogeneousAgent/remoteDeviceHeteroContext.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { buildRemoteDeviceHeteroContext } from './remoteDeviceHeteroContext'; + +describe('buildRemoteDeviceHeteroContext', () => { + it('returns undefined when there is nothing to inject', () => { + expect(buildRemoteDeviceHeteroContext({})).toBeUndefined(); + expect(buildRemoteDeviceHeteroContext({ agentSystemContext: ' ' })).toBeUndefined(); + expect(buildRemoteDeviceHeteroContext({ conversationHistory: [] })).toBeUndefined(); + }); + + it('puts the agent static context first', () => { + const result = buildRemoteDeviceHeteroContext({ agentSystemContext: 'Follow the repo rules.' }); + expect(result).toBe('Follow the repo rules.'); + }); + + it('describes the working directory without cloud-sandbox boilerplate', () => { + const result = buildRemoteDeviceHeteroContext({ cwd: '/Users/alice/projects/app' }); + expect(result).toContain('/Users/alice/projects/app'); + expect(result).toContain("user's own machine"); + // Must NOT leak the cloud-sandbox context. + expect(result).not.toContain('/workspace'); + expect(result).not.toContain('ephemeral'); + expect(result).not.toContain('cloud sandbox'); + }); + + it('trims a blank cwd instead of emitting an empty workspace note', () => { + expect(buildRemoteDeviceHeteroContext({ cwd: ' ' })).toBeUndefined(); + }); + + it('appends and truncates prior conversation turns', () => { + const result = buildRemoteDeviceHeteroContext({ + conversationHistory: [ + { content: 'a'.repeat(2000), role: 'user' }, + { content: 'short reply', role: 'assistant' }, + ], + }); + expect(result).toContain(''); + expect(result).toContain('… [truncated]'); // user turn exceeds the 1 KB cap + expect(result).toContain('short reply'); + }); + + it('orders sections: agent context → workspace → history', () => { + const result = buildRemoteDeviceHeteroContext({ + agentSystemContext: 'AGENT_CTX', + conversationHistory: [{ content: 'HIST', role: 'user' }], + cwd: '/repo', + })!; + expect(result.indexOf('AGENT_CTX')).toBeLessThan(result.indexOf('/repo')); + expect(result.indexOf('/repo')).toBeLessThan(result.indexOf('')); + }); +}); diff --git a/src/server/services/heterogeneousAgent/remoteDeviceHeteroContext.ts b/src/server/services/heterogeneousAgent/remoteDeviceHeteroContext.ts new file mode 100644 index 0000000000..7d38dd39a3 --- /dev/null +++ b/src/server/services/heterogeneousAgent/remoteDeviceHeteroContext.ts @@ -0,0 +1,71 @@ +import type { ConversationHistoryEntry } from './cloudHeteroContext'; + +/** + * Builds the system context injected before every user prompt for hetero runs + * dispatched to a **remote device** (`lh connect`), as opposed to a cloud + * sandbox. + * + * Unlike {@link buildCloudHeteroContext}, this deliberately strips all the + * cloud-sandbox boilerplate (ephemeral `/workspace`, pre-cloned repo list, + * "commit-or-lose-your-work" warnings, injected GITHUB_TOKEN). A device is the + * user's own persistent machine with their real filesystem and credentials, so + * none of that applies — injecting it would actively mislead the agent. + * + * What remains is only what's genuinely useful on a device: + * - the agent-level static context (workspace conventions / rules), and + * - prior conversation turns when a session is resumed without a native session + * file. + * + * Returns `undefined` when there's nothing meaningful to inject, so the caller + * can omit the extra content block entirely. + */ +export function buildRemoteDeviceHeteroContext(params: { + /** Static systemContext from HeterogeneousProviderConfig.systemContext (agent-level). */ + agentSystemContext?: string; + /** + * Recent conversation turns to inject when resuming a session whose native + * context is unavailable (e.g. a fresh CLI process on the device). + */ + conversationHistory?: ConversationHistoryEntry[]; + /** Working directory the agent will run in, surfaced so it can orient itself. */ + cwd?: string; +}): string | undefined { + const { agentSystemContext, conversationHistory, cwd } = params; + + const parts: string[] = []; + + // --- Agent-level static context (highest priority, goes first) --- + if (agentSystemContext?.trim()) { + parts.push(agentSystemContext.trim()); + } + + // --- Device workspace note (minimal — it's the user's real machine) --- + if (cwd?.trim()) { + parts.push( + [ + '## Workspace', + `You are running on the user's own machine. Your working directory is \`${cwd.trim()}\`.`, + 'This is a persistent local filesystem — changes are not lost when the task ends, so', + 'there is no need to commit or push purely to preserve your work.', + ].join('\n'), + ); + } + + // --- Previous conversation context (injected when session was reset) --- + // Mirrors buildCloudHeteroContext truncation: user 1 KB, assistant 2 KB. + if (conversationHistory && conversationHistory.length > 0) { + const USER_MAX = 1024; + const ASST_MAX = 2048; + const entries = conversationHistory.map((entry) => { + const limit = entry.role === 'user' ? USER_MAX : ASST_MAX; + const body = + entry.content.length > limit + ? `${entry.content.slice(0, limit)}… [truncated]` + : entry.content; + return `<${entry.role}>\n${body}\n`; + }); + parts.push(`\n${entries.join('\n')}\n`); + } + + return parts.length > 0 ? parts.join('\n\n') : undefined; +} diff --git a/src/server/services/toolExecution/deviceProxy.ts b/src/server/services/toolExecution/deviceProxy.ts index 4893c8eb9e..3f87700e5b 100644 --- a/src/server/services/toolExecution/deviceProxy.ts +++ b/src/server/services/toolExecution/deviceProxy.ts @@ -85,6 +85,7 @@ export class DeviceProxy { operationId: string; prompt: string; resumeSessionId?: string; + systemContext?: string; topicId: string; userId: string; }): Promise<{ error?: string; success: boolean }> { diff --git a/src/store/agent/selectors/selectors.ts b/src/store/agent/selectors/selectors.ts index 30e10c675c..fde7ce0348 100644 --- a/src/store/agent/selectors/selectors.ts +++ b/src/store/agent/selectors/selectors.ts @@ -291,11 +291,15 @@ const canCurrentAgentPublishToCommunity = (s: AgentStoreState): boolean => const currentAgentHeterogeneousProviderType = (s: AgentStoreState) => currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider?.type; +const currentAgentExecutionTarget = (s: AgentStoreState) => + currentAgentConfig(s)?.agencyConfig?.executionTarget; + const getAgentDocumentsById = (agentId: string) => (s: AgentStoreState) => s.agentDocumentsMap[agentId]; export const agentSelectors = { canCurrentAgentPublishToCommunity, + currentAgentExecutionTarget, currentAgentHeterogeneousProviderType, currentAgentAvatar, currentAgentBackgroundColor,