diff --git a/adapters/common/attachment/__tests__/image-block-watcher.test.ts b/adapters/common/attachment/__tests__/image-block-watcher.test.ts index 01d0775b..2fac9ef9 100644 --- a/adapters/common/attachment/__tests__/image-block-watcher.test.ts +++ b/adapters/common/attachment/__tests__/image-block-watcher.test.ts @@ -76,4 +76,40 @@ describe('ImageBlockWatcher', () => { const all = w.drain() expect(all.length).toBe(2) }) + + it('reset() clears buffer, seen set, and accumulated list', () => { + const w = new ImageBlockWatcher() + w.feed('![a](/tmp/a.png)') + w.reset() + // After reset, drain() is empty + expect(w.drain().length).toBe(0) + // And re-feeding the same image yields a fresh emit (dedup state cleared) + const out = w.feed('![a](/tmp/a.png)') + expect(out.length).toBe(1) + }) + + it('skips relative paths (cannot be resolved safely)', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![rel](relative/path.png) and ![ok](/tmp/ok.png)') + expect(out.length).toBe(1) + const source = out[0]!.source + expect(source.kind).toBe('path') + if (source.kind === 'path') expect(source.path).toBe('/tmp/ok.png') + }) + + it('extracts multiple images from a single feed chunk in order', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![a](/tmp/a.png) ![b](https://x/b.png) ![c](data:image/png;base64,QQ==)') + expect(out.length).toBe(3) + expect(out[0]!.source.kind).toBe('path') + expect(out[1]!.source.kind).toBe('url') + expect(out[2]!.source.kind).toBe('base64') + }) + + it('rejects malformed data URI (not base64)', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![bad](data:image/png,ABC)') + // Not in `;base64,` form → classify returns null → skipped + expect(out.length).toBe(0) + }) }) diff --git a/adapters/feishu/__tests__/streaming-card.test.ts b/adapters/feishu/__tests__/streaming-card.test.ts index 1e40c388..bf83a8f0 100644 --- a/adapters/feishu/__tests__/streaming-card.test.ts +++ b/adapters/feishu/__tests__/streaming-card.test.ts @@ -102,23 +102,13 @@ describe('buildInitialStreamingCard', () => { const card = buildInitialStreamingCard() as any expect(card.schema).toBe('2.0') expect(card.config.streaming_mode).toBe(true) - // 第二个元素是空的 streaming_content 目标(loading 在首位以避免顶部空 padding) - const streaming = card.body.elements[1] - expect(streaming.tag).toBe('markdown') - expect(streaming.content).toBe('') - expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID) - }) - - it('loading 提示元素在首位(避免空 streaming_content 挤出顶部 padding)', () => { - const card = buildInitialStreamingCard() as any + // 唯一元素:streaming_content,初始内容为 loading 提示 const elements = card.body.elements as any[] - expect(elements.length).toBe(2) - const loading = elements[0] - expect(loading.tag).toBe('markdown') - expect(loading.content).toContain('正在思考中') - expect(loading.text_size).toBe('notation') - // loading 元素不能有 element_id(那是给 streaming_content 独占的) - expect(loading.element_id).toBeUndefined() + expect(elements.length).toBe(1) + const streaming = elements[0] + expect(streaming.tag).toBe('markdown') + expect(streaming.content).toContain('正在思考中') + expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID) }) }) @@ -176,8 +166,8 @@ describe('StreamingCard: ensureCreated (CardKit 主路径)', () => { const cardJson = JSON.parse(calls[0]!.args.data.data) expect(cardJson.schema).toBe('2.0') expect(cardJson.config.streaming_mode).toBe(true) - // loading 元素在首位,streaming_content 占位在第二 - expect(cardJson.body.elements[1].element_id).toBe(STREAMING_ELEMENT_ID) + // 唯一元素即 streaming_content + expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID) // IM message 引用 card_id const content = JSON.parse(calls[1]!.args.data.content) diff --git a/adapters/feishu/streaming-card.ts b/adapters/feishu/streaming-card.ts index 5f625318..eadc8b94 100644 --- a/adapters/feishu/streaming-card.ts +++ b/adapters/feishu/streaming-card.ts @@ -31,17 +31,11 @@ import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style /** 初始流式卡片:Schema 2.0 + streaming_mode + element_id。 * - * 包含两个 markdown 元素: - * - 独立的 loading 提示: "☁️ 正在思考中..." 小号灰字。放在**首位**紧贴 - * 卡片顶部 —— 否则空的 streaming_content 会占默认行高把 loading 挤到 - * 卡片中间,视觉上像大片 padding。 - * - `streaming_content`: 流式内容目标元素,初始为空。由 cardElement.content() - * 逐步填充。位于 loading 下方。 + * 只包含一个 markdown 元素 `streaming_content`,初始内容为 loading 提示。 + * 由 renderedText() 统一控制显示状态(思考中 / reasoning / 正文), + * 避免静态 loading 元素和 streaming 内容同时显示造成"两个思考中"。 * - * finalize 时整卡 update 替换,loading 元素自然消失。 - * - * openclaw-lark 用的是 `custom_icon` + 私有 img_key 做 loading 动画, - * 我们没有那个 img_key,用 emoji + notation 文字达到相似的"仍在处理"提示。 */ + * finalize 时整卡 update 替换为纯答复正文。 */ export function buildInitialStreamingCard(): Record { return { schema: '2.0', @@ -54,11 +48,6 @@ export function buildInitialStreamingCard(): Record { { tag: 'markdown', content: '☁️ *正在思考中...*', - text_size: 'notation', - }, - { - tag: 'markdown', - content: '', text_align: 'left', element_id: STREAMING_ELEMENT_ID, }, diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index ea22cf7f..ae73a809 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -148,7 +148,7 @@ export function TabBar() { if (tabs.length === 0) return null return ( -
+
{canScrollLeft && ( )} -
e.preventDefault()}> +
e.preventDefault()}> {tabs.map((tab, index) => (
-
+
{t('settings.skills.browserEyebrow')} @@ -102,7 +102,7 @@ export function SkillList() {

-
+
@@ -236,12 +236,12 @@ function SummaryCard({ className?: string }) { return ( -
-
- {icon} - {label} +
+
+ {icon} + {label}
-
+
{value}
diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index cec92833..5526234f 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -5,10 +5,15 @@ import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { DirectoryPicker } from '../components/shared/DirectoryPicker' +type ImTab = 'feishu' | 'telegram' + export function AdapterSettings() { const t = useTranslation() const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore() + // Active IM tab —— Feishu 默认展示,在前 + const [activeIm, setActiveIm] = useState('feishu') + // Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释), // 桌面端用 Tauri env var 注入动态端口。 const [defaultProjectDir, setDefaultProjectDir] = useState('') @@ -227,90 +232,98 @@ export function AdapterSettings() {

- {/* Telegram */} + {/* IM Adapter Tabs —— Feishu 默认在前,Telegram 在后 */}
-
- {t('settings.adapters.telegram')} -
-
- setTgBotToken(e.target.value)} - placeholder={t('settings.adapters.botTokenPlaceholder')} +
+ setActiveIm('feishu')} + /> + setActiveIm('telegram')} /> -
- setTgAllowedUsers(e.target.value)} - placeholder={t('settings.adapters.tgAllowedUsersPlaceholder')} - /> -

{t('settings.adapters.allowedUsersHint')}

-
-
- {/* Feishu */} -
-
- {t('settings.adapters.feishu')} -
-
-
- setFsAppId(e.target.value)} - placeholder={t('settings.adapters.appIdPlaceholder')} - /> - setFsAppSecret(e.target.value)} - placeholder={t('settings.adapters.appSecretPlaceholder')} - /> -
-
- setFsEncryptKey(e.target.value)} - placeholder={t('settings.adapters.encryptKeyPlaceholder')} - /> - setFsVerificationToken(e.target.value)} - placeholder={t('settings.adapters.verificationTokenPlaceholder')} - /> -
-
- setFsAllowedUsers(e.target.value)} - placeholder={t('settings.adapters.fsAllowedUsersPlaceholder')} - /> -

{t('settings.adapters.allowedUsersHint')}

-
-
{/* Save */} @@ -334,3 +347,29 @@ export function AdapterSettings() {
) } + +function ImTabButton({ + label, + active, + onClick, +}: { + label: string + active: boolean + onClick: () => void +}) { + return ( + + ) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 8253ffa6..4da18e86 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -765,7 +765,7 @@ function AgentsSettings() { ) : (
-
+
{t('settings.agents.browserEyebrow')} @@ -783,7 +783,7 @@ function AgentsSettings() {

-
+
@@ -1119,12 +1119,12 @@ function SummaryCard({ className?: string }) { return ( -
-
- {icon} - {label} +
+
+ {icon} + {label}
-
+
{value}
diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index a7ee07eb..00bf9c23 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -105,14 +105,30 @@ export class ConversationService { ]) console.log( - `[ConversationService] Starting CLI for ${sessionId}, cwd: ${workDir}`, + `[ConversationService] Starting CLI for ${sessionId}, cwd: ${workDir} (process.cwd()=${process.cwd()}, CALLER_DIR will be pinned to workDir)`, ) + // IMPORTANT (Bug#5): 必须覆盖子进程继承的 CALLER_DIR / PWD。 + // preload.ts 顶层读 process.env.CALLER_DIR 并调用 process.chdir(CALLER_DIR)。 + // 在 bundled 桌面端里,server sidecar 被 Tauri 从 cwd=/ 启动,claude-sidecar.ts + // 在 server/cli 模式入口把 CALLER_DIR 默认设成 process.cwd()(即 '/'), + // 随后这个 env 被完整继承到 Bun.spawn 的 CLI 子进程;即使这里显式传了 + // cwd: workDir,CLI 子进程里 preload.ts 还是会 chdir('/'),结果把 + // STATE.cwd / "Primary working directory" 打回根目录,IM 会话里 AI 感知的 + // 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts + // chdir 后落到正确目录。 + const childEnv = { + ...process.env, + CLAUDE_CODE_ENABLE_TASKS: '1', + CALLER_DIR: workDir, + PWD: workDir, + } + let proc: ReturnType try { proc = Bun.spawn(args, { cwd: workDir, - env: { ...process.env, CLAUDE_CODE_ENABLE_TASKS: '1' }, + env: childEnv, stdin: 'pipe', stdout: 'ignore', // CLI communicates via SDK WebSocket, not stdout stderr: 'pipe', diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 640c7f14..667418db 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -552,8 +552,16 @@ export class SessionService { // Default to user home directory when no workDir specified const resolvedWorkDir = workDir || os.homedir() - // Resolve to absolute path + // Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to + // expand relative paths — in bundled sidecar mode the server's cwd is + // typically '/'. Callers (IM adapters) already send absolute realPath, + // but we log here so cwd regressions are caught early. const absWorkDir = path.resolve(resolvedWorkDir) + console.log( + `[SessionService] createSession: requested workDir=${JSON.stringify( + workDir, + )}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`, + ) let stat try { stat = await fs.stat(absWorkDir) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index ef4adf54..1076955d 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -165,8 +165,18 @@ async function handleUserMessage( try { const resolved = await sessionService.getSessionWorkDir(sessionId) if (resolved) workDir = resolved - } catch { + console.log( + `[WS] handleUserMessage: sessionId=${sessionId}, resolved workDir=${JSON.stringify( + resolved, + )}, will spawn CLI with workDir=${workDir}`, + ) + } catch (resolveErr) { // fallback to cwd if session file not found + console.warn( + `[WS] handleUserMessage: failed to resolve workDir for ${sessionId}, using fallback=${workDir}: ${ + resolveErr instanceof Error ? resolveErr.message : String(resolveErr) + }`, + ) } const runtimeSettings = await getRuntimeSettings() const sdkUrl =