mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: batch resolve 5 issues from Feishu bug tracker
- fix(desktop): Settings Agents/Skills page layout overflow at narrow widths Shift header grid breakpoint md→xl, summary cards sm→3col, add min-w-0/truncate - fix(desktop): TabBar tab style — remove border-r separators, use bg-only active state Drop ::after indicator and inter-tab borders; active tab distinguished by surface bg - feat(desktop): IM Adapters page — horizontal tabs with Feishu first, Telegram second Replace vertical stack with tablist; add ImTabButton with aria-selected + focus-visible - fix(server): bundled IM session cwd resolves to / instead of selected project Root cause: preload.ts chdir(CALLER_DIR) inherits '/' from Tauri-spawned sidecar. Fix: pin CALLER_DIR and PWD to session workDir in conversationService spawn env. Add diagnostic logs at sessionService, ws/handler, and conversationService. - fix(feishu): StreamingCard shows duplicate "thinking" indicators Merge static loading element into streaming_content; renderedText() controls state. - test(adapters): add 5 ImageBlockWatcher edge-case unit tests (reset, relative path, multi-image chunk, malformed data URI) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
43f08dfe52
commit
bfafd26fb6
@ -76,4 +76,40 @@ describe('ImageBlockWatcher', () => {
|
|||||||
const all = w.drain()
|
const all = w.drain()
|
||||||
expect(all.length).toBe(2)
|
expect(all.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reset() clears buffer, seen set, and accumulated list', () => {
|
||||||
|
const w = new ImageBlockWatcher()
|
||||||
|
w.feed('')
|
||||||
|
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('')
|
||||||
|
expect(out.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips relative paths (cannot be resolved safely)', () => {
|
||||||
|
const w = new ImageBlockWatcher()
|
||||||
|
const out = w.feed(' and ')
|
||||||
|
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('  ')
|
||||||
|
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('')
|
||||||
|
// Not in `;base64,` form → classify returns null → skipped
|
||||||
|
expect(out.length).toBe(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -102,23 +102,13 @@ describe('buildInitialStreamingCard', () => {
|
|||||||
const card = buildInitialStreamingCard() as any
|
const card = buildInitialStreamingCard() as any
|
||||||
expect(card.schema).toBe('2.0')
|
expect(card.schema).toBe('2.0')
|
||||||
expect(card.config.streaming_mode).toBe(true)
|
expect(card.config.streaming_mode).toBe(true)
|
||||||
// 第二个元素是空的 streaming_content 目标(loading 在首位以避免顶部空 padding)
|
// 唯一元素:streaming_content,初始内容为 loading 提示
|
||||||
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
|
|
||||||
const elements = card.body.elements as any[]
|
const elements = card.body.elements as any[]
|
||||||
expect(elements.length).toBe(2)
|
expect(elements.length).toBe(1)
|
||||||
const loading = elements[0]
|
const streaming = elements[0]
|
||||||
expect(loading.tag).toBe('markdown')
|
expect(streaming.tag).toBe('markdown')
|
||||||
expect(loading.content).toContain('正在思考中')
|
expect(streaming.content).toContain('正在思考中')
|
||||||
expect(loading.text_size).toBe('notation')
|
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
|
||||||
// loading 元素不能有 element_id(那是给 streaming_content 独占的)
|
|
||||||
expect(loading.element_id).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -176,8 +166,8 @@ describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
|
|||||||
const cardJson = JSON.parse(calls[0]!.args.data.data)
|
const cardJson = JSON.parse(calls[0]!.args.data.data)
|
||||||
expect(cardJson.schema).toBe('2.0')
|
expect(cardJson.schema).toBe('2.0')
|
||||||
expect(cardJson.config.streaming_mode).toBe(true)
|
expect(cardJson.config.streaming_mode).toBe(true)
|
||||||
// loading 元素在首位,streaming_content 占位在第二
|
// 唯一元素即 streaming_content
|
||||||
expect(cardJson.body.elements[1].element_id).toBe(STREAMING_ELEMENT_ID)
|
expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID)
|
||||||
|
|
||||||
// IM message 引用 card_id
|
// IM message 引用 card_id
|
||||||
const content = JSON.parse(calls[1]!.args.data.content)
|
const content = JSON.parse(calls[1]!.args.data.content)
|
||||||
|
|||||||
@ -31,17 +31,11 @@ import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style
|
|||||||
|
|
||||||
/** 初始流式卡片:Schema 2.0 + streaming_mode + element_id。
|
/** 初始流式卡片:Schema 2.0 + streaming_mode + element_id。
|
||||||
*
|
*
|
||||||
* 包含两个 markdown 元素:
|
* 只包含一个 markdown 元素 `streaming_content`,初始内容为 loading 提示。
|
||||||
* - 独立的 loading 提示: "☁️ 正在思考中..." 小号灰字。放在**首位**紧贴
|
* 由 renderedText() 统一控制显示状态(思考中 / reasoning / 正文),
|
||||||
* 卡片顶部 —— 否则空的 streaming_content 会占默认行高把 loading 挤到
|
* 避免静态 loading 元素和 streaming 内容同时显示造成"两个思考中"。
|
||||||
* 卡片中间,视觉上像大片 padding。
|
|
||||||
* - `streaming_content`: 流式内容目标元素,初始为空。由 cardElement.content()
|
|
||||||
* 逐步填充。位于 loading 下方。
|
|
||||||
*
|
*
|
||||||
* finalize 时整卡 update 替换,loading 元素自然消失。
|
* finalize 时整卡 update 替换为纯答复正文。 */
|
||||||
*
|
|
||||||
* openclaw-lark 用的是 `custom_icon` + 私有 img_key 做 loading 动画,
|
|
||||||
* 我们没有那个 img_key,用 emoji + notation 文字达到相似的"仍在处理"提示。 */
|
|
||||||
export function buildInitialStreamingCard(): Record<string, unknown> {
|
export function buildInitialStreamingCard(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
schema: '2.0',
|
schema: '2.0',
|
||||||
@ -54,11 +48,6 @@ export function buildInitialStreamingCard(): Record<string, unknown> {
|
|||||||
{
|
{
|
||||||
tag: 'markdown',
|
tag: 'markdown',
|
||||||
content: '☁️ *正在思考中...*',
|
content: '☁️ *正在思考中...*',
|
||||||
text_size: 'notation',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: 'markdown',
|
|
||||||
content: '',
|
|
||||||
text_align: 'left',
|
text_align: 'left',
|
||||||
element_id: STREAMING_ELEMENT_ID,
|
element_id: STREAMING_ELEMENT_ID,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -148,7 +148,7 @@ export function TabBar() {
|
|||||||
if (tabs.length === 0) return null
|
if (tabs.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]" data-tauri-drag-region>
|
<div className="flex items-stretch bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]" data-tauri-drag-region>
|
||||||
|
|
||||||
{canScrollLeft && (
|
{canScrollLeft && (
|
||||||
<button onClick={() => scroll('left')} className="flex-shrink-0 w-7 h-[37px] flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]">
|
<button onClick={() => scroll('left')} className="flex-shrink-0 w-7 h-[37px] flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]">
|
||||||
@ -156,7 +156,7 @@ export function TabBar() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 flex items-center overflow-x-hidden" data-tauri-drag-region onDragOver={(e) => e.preventDefault()}>
|
<div ref={scrollRef} className="flex-1 flex items-stretch overflow-x-hidden" data-tauri-drag-region onDragOver={(e) => e.preventDefault()}>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<TabItem
|
<TabItem
|
||||||
key={tab.sessionId}
|
key={tab.sessionId}
|
||||||
@ -275,10 +275,10 @@ function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, o
|
|||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
className={`
|
className={`
|
||||||
flex-shrink-0 flex items-center gap-1.5 px-3 cursor-pointer group transition-colors relative border-r border-r-[var(--color-border)]/30
|
flex-shrink-0 flex items-center gap-1.5 px-3 min-h-[37px] cursor-pointer group transition-colors relative
|
||||||
${isActive
|
${isActive
|
||||||
? 'h-[37px] bg-[var(--color-surface)] border-t-2 border-t-[var(--color-brand)]'
|
? 'bg-[var(--color-surface)]'
|
||||||
: 'h-[37px] bg-transparent hover:bg-[var(--color-surface-hover)]'
|
: 'bg-transparent hover:bg-[var(--color-surface-hover)]'
|
||||||
}
|
}
|
||||||
${isDragOver ? 'before:absolute before:left-0 before:top-[6px] before:bottom-[6px] before:w-[2px] before:bg-[var(--color-brand)] before:rounded-full' : ''}
|
${isDragOver ? 'before:absolute before:left-0 before:top-[6px] before:bottom-[6px] before:w-[2px] before:bg-[var(--color-brand)] before:rounded-full' : ''}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@ -84,7 +84,7 @@ export function SkillList() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 min-w-0">
|
<div className="flex flex-col gap-6 min-w-0">
|
||||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
||||||
<div className="grid gap-4 px-5 py-5 md:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)] md:items-end">
|
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
||||||
{t('settings.skills.browserEyebrow')}
|
{t('settings.skills.browserEyebrow')}
|
||||||
@ -102,7 +102,7 @@ export function SkillList() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label={t('settings.skills.summary.totalSkills')}
|
label={t('settings.skills.summary.totalSkills')}
|
||||||
value={String(skills.length)}
|
value={String(skills.length)}
|
||||||
@ -120,7 +120,7 @@ export function SkillList() {
|
|||||||
label={t('settings.skills.summary.tokens')}
|
label={t('settings.skills.summary.tokens')}
|
||||||
value={t('settings.skills.tokenEstimateShort', { count: String(totalTokens) })}
|
value={t('settings.skills.tokenEstimateShort', { count: String(totalTokens) })}
|
||||||
icon="notes"
|
icon="notes"
|
||||||
className="col-span-2 md:col-span-1"
|
className="col-span-2 sm:col-span-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -236,12 +236,12 @@ function SummaryCard({
|
|||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
|
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0 ${className}`}>
|
||||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
|
||||||
<span className="material-symbols-outlined text-[14px]">{icon}</span>
|
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
|
||||||
<span>{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
|
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,10 +5,15 @@ import { Input } from '../components/shared/Input'
|
|||||||
import { Button } from '../components/shared/Button'
|
import { Button } from '../components/shared/Button'
|
||||||
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
|
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
|
||||||
|
|
||||||
|
type ImTab = 'feishu' | 'telegram'
|
||||||
|
|
||||||
export function AdapterSettings() {
|
export function AdapterSettings() {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore()
|
const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore()
|
||||||
|
|
||||||
|
// Active IM tab —— Feishu 默认展示,在前
|
||||||
|
const [activeIm, setActiveIm] = useState<ImTab>('feishu')
|
||||||
|
|
||||||
// Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释),
|
// Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释),
|
||||||
// 桌面端用 Tauri env var 注入动态端口。
|
// 桌面端用 Tauri env var 注入动态端口。
|
||||||
const [defaultProjectDir, setDefaultProjectDir] = useState('')
|
const [defaultProjectDir, setDefaultProjectDir] = useState('')
|
||||||
@ -227,90 +232,98 @@ export function AdapterSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Telegram */}
|
{/* IM Adapter Tabs —— Feishu 默认在前,Telegram 在后 */}
|
||||||
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
|
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--color-surface-hover)] border-b border-[var(--color-border)]">
|
<div role="tablist" aria-label="IM adapter" className="flex items-stretch border-b border-[var(--color-border)] bg-[var(--color-surface-hover)]">
|
||||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.telegram')}</span>
|
<ImTabButton
|
||||||
</div>
|
label={t('settings.adapters.feishu')}
|
||||||
<div className="p-4 space-y-4">
|
active={activeIm === 'feishu'}
|
||||||
<Input
|
onClick={() => setActiveIm('feishu')}
|
||||||
label={t('settings.adapters.botToken')}
|
/>
|
||||||
type="password"
|
<ImTabButton
|
||||||
value={tgBotToken}
|
label={t('settings.adapters.telegram')}
|
||||||
onChange={(e) => setTgBotToken(e.target.value)}
|
active={activeIm === 'telegram'}
|
||||||
placeholder={t('settings.adapters.botTokenPlaceholder')}
|
onClick={() => setActiveIm('telegram')}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Input
|
|
||||||
label={t('settings.adapters.allowedUsers')}
|
|
||||||
value={tgAllowedUsers}
|
|
||||||
onChange={(e) => setTgAllowedUsers(e.target.value)}
|
|
||||||
placeholder={t('settings.adapters.tgAllowedUsersPlaceholder')}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Feishu */}
|
{activeIm === 'feishu' && (
|
||||||
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
|
<div className="p-4 space-y-4">
|
||||||
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--color-surface-hover)] border-b border-[var(--color-border)]">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.adapters.feishu')}</span>
|
<Input
|
||||||
</div>
|
label={t('settings.adapters.appId')}
|
||||||
<div className="p-4 space-y-4">
|
value={fsAppId}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
onChange={(e) => setFsAppId(e.target.value)}
|
||||||
<Input
|
placeholder={t('settings.adapters.appIdPlaceholder')}
|
||||||
label={t('settings.adapters.appId')}
|
/>
|
||||||
value={fsAppId}
|
<Input
|
||||||
onChange={(e) => setFsAppId(e.target.value)}
|
label={t('settings.adapters.appSecret')}
|
||||||
placeholder={t('settings.adapters.appIdPlaceholder')}
|
type="password"
|
||||||
/>
|
value={fsAppSecret}
|
||||||
<Input
|
onChange={(e) => setFsAppSecret(e.target.value)}
|
||||||
label={t('settings.adapters.appSecret')}
|
placeholder={t('settings.adapters.appSecretPlaceholder')}
|
||||||
type="password"
|
/>
|
||||||
value={fsAppSecret}
|
|
||||||
onChange={(e) => setFsAppSecret(e.target.value)}
|
|
||||||
placeholder={t('settings.adapters.appSecretPlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label={t('settings.adapters.encryptKey')}
|
|
||||||
type="password"
|
|
||||||
value={fsEncryptKey}
|
|
||||||
onChange={(e) => setFsEncryptKey(e.target.value)}
|
|
||||||
placeholder={t('settings.adapters.encryptKeyPlaceholder')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('settings.adapters.verificationToken')}
|
|
||||||
type="password"
|
|
||||||
value={fsVerificationToken}
|
|
||||||
onChange={(e) => setFsVerificationToken(e.target.value)}
|
|
||||||
placeholder={t('settings.adapters.verificationTokenPlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Input
|
|
||||||
label={t('settings.adapters.allowedUsers')}
|
|
||||||
value={fsAllowedUsers}
|
|
||||||
onChange={(e) => setFsAllowedUsers(e.target.value)}
|
|
||||||
placeholder={t('settings.adapters.fsAllowedUsersPlaceholder')}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={fsStreamingCard}
|
|
||||||
onChange={(e) => setFsStreamingCard(e.target.checked)}
|
|
||||||
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.streamingCard')}</span>
|
|
||||||
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.streamingCardDesc')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
</div>
|
<Input
|
||||||
|
label={t('settings.adapters.encryptKey')}
|
||||||
|
type="password"
|
||||||
|
value={fsEncryptKey}
|
||||||
|
onChange={(e) => setFsEncryptKey(e.target.value)}
|
||||||
|
placeholder={t('settings.adapters.encryptKeyPlaceholder')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('settings.adapters.verificationToken')}
|
||||||
|
type="password"
|
||||||
|
value={fsVerificationToken}
|
||||||
|
onChange={(e) => setFsVerificationToken(e.target.value)}
|
||||||
|
placeholder={t('settings.adapters.verificationTokenPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Input
|
||||||
|
label={t('settings.adapters.allowedUsers')}
|
||||||
|
value={fsAllowedUsers}
|
||||||
|
onChange={(e) => setFsAllowedUsers(e.target.value)}
|
||||||
|
placeholder={t('settings.adapters.fsAllowedUsersPlaceholder')}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={fsStreamingCard}
|
||||||
|
onChange={(e) => setFsStreamingCard(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.streamingCard')}</span>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.streamingCardDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeIm === 'telegram' && (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<Input
|
||||||
|
label={t('settings.adapters.botToken')}
|
||||||
|
type="password"
|
||||||
|
value={tgBotToken}
|
||||||
|
onChange={(e) => setTgBotToken(e.target.value)}
|
||||||
|
placeholder={t('settings.adapters.botTokenPlaceholder')}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Input
|
||||||
|
label={t('settings.adapters.allowedUsers')}
|
||||||
|
value={tgAllowedUsers}
|
||||||
|
onChange={(e) => setTgAllowedUsers(e.target.value)}
|
||||||
|
placeholder={t('settings.adapters.tgAllowedUsersPlaceholder')}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.allowedUsersHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Save */}
|
{/* Save */}
|
||||||
@ -334,3 +347,29 @@ export function AdapterSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImTabButton({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`relative px-4 py-2.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-inset ${
|
||||||
|
active
|
||||||
|
? 'text-[var(--color-text-primary)] font-semibold after:absolute after:left-3 after:right-3 after:bottom-0 after:h-[2px] after:bg-[var(--color-brand)]'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -765,7 +765,7 @@ function AgentsSettings() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6 min-w-0">
|
<div className="flex flex-col gap-6 min-w-0">
|
||||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
||||||
<div className="grid gap-4 px-5 py-5 md:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)] md:items-end">
|
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
||||||
{t('settings.agents.browserEyebrow')}
|
{t('settings.agents.browserEyebrow')}
|
||||||
@ -783,7 +783,7 @@ function AgentsSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label={t('settings.agents.summary.totalAgents')}
|
label={t('settings.agents.summary.totalAgents')}
|
||||||
value={String(allAgents.length)}
|
value={String(allAgents.length)}
|
||||||
@ -798,7 +798,7 @@ function AgentsSettings() {
|
|||||||
label={t('settings.agents.summary.sources')}
|
label={t('settings.agents.summary.sources')}
|
||||||
value={String(sourceCount)}
|
value={String(sourceCount)}
|
||||||
icon="layers"
|
icon="layers"
|
||||||
className="col-span-2 md:col-span-1"
|
className="col-span-2 sm:col-span-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1119,12 +1119,12 @@ function SummaryCard({
|
|||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
|
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0 ${className}`}>
|
||||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
|
||||||
<span className="material-symbols-outlined text-[14px]">{icon}</span>
|
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
|
||||||
<span>{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
|
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -105,14 +105,30 @@ export class ConversationService {
|
|||||||
])
|
])
|
||||||
|
|
||||||
console.log(
|
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<typeof Bun.spawn>
|
let proc: ReturnType<typeof Bun.spawn>
|
||||||
try {
|
try {
|
||||||
proc = Bun.spawn(args, {
|
proc = Bun.spawn(args, {
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
env: { ...process.env, CLAUDE_CODE_ENABLE_TASKS: '1' },
|
env: childEnv,
|
||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
stdout: 'ignore', // CLI communicates via SDK WebSocket, not stdout
|
stdout: 'ignore', // CLI communicates via SDK WebSocket, not stdout
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
|
|||||||
@ -552,8 +552,16 @@ export class SessionService {
|
|||||||
// Default to user home directory when no workDir specified
|
// Default to user home directory when no workDir specified
|
||||||
const resolvedWorkDir = workDir || os.homedir()
|
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)
|
const absWorkDir = path.resolve(resolvedWorkDir)
|
||||||
|
console.log(
|
||||||
|
`[SessionService] createSession: requested workDir=${JSON.stringify(
|
||||||
|
workDir,
|
||||||
|
)}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`,
|
||||||
|
)
|
||||||
let stat
|
let stat
|
||||||
try {
|
try {
|
||||||
stat = await fs.stat(absWorkDir)
|
stat = await fs.stat(absWorkDir)
|
||||||
|
|||||||
@ -165,8 +165,18 @@ async function handleUserMessage(
|
|||||||
try {
|
try {
|
||||||
const resolved = await sessionService.getSessionWorkDir(sessionId)
|
const resolved = await sessionService.getSessionWorkDir(sessionId)
|
||||||
if (resolved) workDir = resolved
|
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
|
// 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 runtimeSettings = await getRuntimeSettings()
|
||||||
const sdkUrl =
|
const sdkUrl =
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user