mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ feat(cc-agent): improve for CC integration mode (#13950)
* ✨ feat(cc-agent-profile): swap model/skills pickers for CC CLI status in CC mode When an agent runs under the Claude Code heterogeneous runtime, its model and tools are owned by the external CLI, so the profile page's model selector and integration-skills block are misleading. Replace them with a card that re-detects `claude --version` on mount and shows the resolved binary path — useful when CLAUDE_CODE_BIN or similar points at a non-default CLI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 💄 style(cc-agent-profile): hide cron for CC agent and polish render previews - Hide cron sidebar entry when current agent is heterogeneous (CC) - Allow model avatar in agent header emoji picker - Add padding to Glob/Grep/Read/Write preview boxes for consistent spacing - Simplify NavPanelDraggable by removing slide animation layer Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ♻️ refactor(shared-tool-ui): extract ToolResultCard for Read/Write/Glob/Grep renders Hoist the shared card shell (icon + header + preview box) into @lobechat/shared-tool-ui/components so the four Claude Code Render files no longer duplicate container/header/previewBox styles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 💄 style(agent-header): restyle title and expand actions menu Bold the topic title, render the working directory as plain text (no chip/icon), move the "..." menu to the left, and expand it with pin/rename/copy working directory/copy session ID/delete. Fall back to "New Topic" when no topic is active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 💄 style(topic-list): replace spinning loader with ring-and-arc loading icon Adds a reusable RingLoadingIcon (static track + rotating arc, mirroring the send-button style) and swaps the topic-item loader over to it so the loading state reads as a polished ring rather than a thin spinning dash. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 💄 style(topic-list): switch unread indicator to a radar ping effect Replaces the glowing neon-dot pulse with a smaller 6px core dot plus a CSS-keyframe ripple ring that scales out and fades, giving the unread marker a subtler, more refined cadence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 💄 style(cc-chat-input): drop file upload in CC mode, surface typo toggle Claude Code brings its own file handling and knowledge context, so the paperclip dropdown only showed "Upload Image" + a useless "View More" link — confusing and not clean. Replace fileUpload with typo in the heterogeneous chat input, and fold ServerMode back into a single Upload/index.tsx now that the ClientMode/ServerMode split is gone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
13fe968480
commit
5dc94cbc45
27 changed files with 867 additions and 684 deletions
|
|
@ -191,6 +191,10 @@
|
|||
"analytics.telemetry.desc": "Help us improve {{appName}} with anonymous usage data",
|
||||
"analytics.telemetry.title": "Send Anonymous Usage Data",
|
||||
"analytics.title": "Analytics",
|
||||
"ccStatus.detecting": "Detecting Claude Code CLI...",
|
||||
"ccStatus.redetect": "Re-detect",
|
||||
"ccStatus.title": "Claude Code CLI",
|
||||
"ccStatus.unavailable": "Claude Code CLI not found. Please install or configure it.",
|
||||
"checking": "Checking...",
|
||||
"checkingPermissions": "Checking permissions...",
|
||||
"creds.actions.delete": "Delete",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
"actions.confirmRemoveUnstarred": "You are about to delete unstarred topics. This action cannot be undone.",
|
||||
"actions.copyLink": "Copy Link",
|
||||
"actions.copyLinkSuccess": "Link copied",
|
||||
"actions.copySessionId": "Copy Session ID",
|
||||
"actions.copySessionIdSuccess": "Session ID copied",
|
||||
"actions.copyWorkingDirectory": "Copy Working Directory",
|
||||
"actions.copyWorkingDirectorySuccess": "Working directory copied",
|
||||
"actions.duplicate": "Duplicate",
|
||||
"actions.export": "Export Topics",
|
||||
"actions.favorite": "Favorite",
|
||||
|
|
@ -36,6 +40,7 @@
|
|||
"importLoading": "Importing conversation...",
|
||||
"importSuccess": "Successfully imported {{count}} messages",
|
||||
"loadMore": "Load More",
|
||||
"newTopic": "New Topic",
|
||||
"searchPlaceholder": "Search Topics...",
|
||||
"searchResultEmpty": "No search results found.",
|
||||
"temp": "Temporary",
|
||||
|
|
|
|||
|
|
@ -191,6 +191,10 @@
|
|||
"analytics.telemetry.desc": "通过匿名使用数据帮助我们改进 {{appName}}",
|
||||
"analytics.telemetry.title": "发送匿名使用数据",
|
||||
"analytics.title": "数据统计",
|
||||
"ccStatus.detecting": "正在检测 Claude Code CLI…",
|
||||
"ccStatus.redetect": "重新检测",
|
||||
"ccStatus.title": "Claude Code CLI",
|
||||
"ccStatus.unavailable": "未检测到 Claude Code CLI,请先安装或配置",
|
||||
"checking": "检查中…",
|
||||
"checkingPermissions": "检查权限中…",
|
||||
"creds.actions.delete": "删除",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
"actions.confirmRemoveUnstarred": "您即将删除未加星标的话题,此操作无法撤销。",
|
||||
"actions.copyLink": "复制链接",
|
||||
"actions.copyLinkSuccess": "链接已复制",
|
||||
"actions.copySessionId": "复制会话 ID",
|
||||
"actions.copySessionIdSuccess": "会话 ID 已复制",
|
||||
"actions.copyWorkingDirectory": "复制工作目录",
|
||||
"actions.copyWorkingDirectorySuccess": "工作目录已复制",
|
||||
"actions.duplicate": "复制",
|
||||
"actions.export": "导出话题",
|
||||
"actions.favorite": "收藏",
|
||||
|
|
@ -36,6 +40,7 @@
|
|||
"importLoading": "正在导入对话…",
|
||||
"importSuccess": "已导入 {{count}} 条消息",
|
||||
"loadMore": "更多",
|
||||
"newTopic": "新话题",
|
||||
"searchPlaceholder": "搜索话题…",
|
||||
"searchResultEmpty": "暂无搜索结果",
|
||||
"temp": "临时",
|
||||
|
|
|
|||
|
|
@ -1,33 +1,20 @@
|
|||
'use client';
|
||||
|
||||
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Icon, Text } from '@lobehub/ui';
|
||||
import { Highlighter, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { FolderSearch } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 8px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
count: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
header: css`
|
||||
padding-inline: 4px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
pattern: css`
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
`,
|
||||
previewBox: css`
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
scope: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
|
|
@ -50,36 +37,37 @@ const Glob = memo<BuiltinRenderProps<GlobArgs>>(({ args, content }) => {
|
|||
}, [content]);
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8} wrap={'wrap'}>
|
||||
<Icon icon={FolderSearch} size={'small'} />
|
||||
{pattern && (
|
||||
<Text strong className={styles.pattern}>
|
||||
{pattern}
|
||||
</Text>
|
||||
)}
|
||||
{scope && (
|
||||
<Text ellipsis className={styles.scope}>
|
||||
{scope}
|
||||
</Text>
|
||||
)}
|
||||
{matchCount > 0 && <Text className={styles.count}>{`${matchCount} matches`}</Text>}
|
||||
</Flexbox>
|
||||
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={FolderSearch}
|
||||
header={
|
||||
<>
|
||||
{pattern && (
|
||||
<Text strong className={styles.pattern}>
|
||||
{pattern}
|
||||
</Text>
|
||||
)}
|
||||
{scope && (
|
||||
<Text ellipsis className={styles.scope}>
|
||||
{scope}
|
||||
</Text>
|
||||
)}
|
||||
{matchCount > 0 && <Text className={styles.count}>{`${matchCount} matches`}</Text>}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{content && (
|
||||
<Flexbox className={styles.previewBox}>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content}
|
||||
</Highlighter>
|
||||
</Flexbox>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
</ToolResultCard>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Icon, Tag, Text } from '@lobehub/ui';
|
||||
import { Highlighter, Tag, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Search } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 8px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
header: css`
|
||||
padding-inline: 4px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
pattern: css`
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
`,
|
||||
previewBox: css`
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
scope: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
|
|
@ -45,36 +32,37 @@ const Grep = memo<BuiltinRenderProps<GrepArgs>>(({ args, content }) => {
|
|||
const glob = args?.glob || args?.type;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8} wrap={'wrap'}>
|
||||
<Icon icon={Search} size={'small'} />
|
||||
{pattern && (
|
||||
<Text strong className={styles.pattern}>
|
||||
{pattern}
|
||||
</Text>
|
||||
)}
|
||||
{glob && <Tag>{glob}</Tag>}
|
||||
{scope && (
|
||||
<Text ellipsis className={styles.scope}>
|
||||
{scope}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={Search}
|
||||
header={
|
||||
<>
|
||||
{pattern && (
|
||||
<Text strong className={styles.pattern}>
|
||||
{pattern}
|
||||
</Text>
|
||||
)}
|
||||
{glob && <Tag>{glob}</Tag>}
|
||||
{scope && (
|
||||
<Text ellipsis className={styles.scope}>
|
||||
{scope}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{content && (
|
||||
<Flexbox className={styles.previewBox}>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content}
|
||||
</Highlighter>
|
||||
</Flexbox>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
</ToolResultCard>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Icon, Text } from '@lobehub/ui';
|
||||
import { Highlighter, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { FileText } from 'lucide-react';
|
||||
import path from 'path-browserify-esm';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 8px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
header: css`
|
||||
padding-inline: 4px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
path: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
word-break: break-all;
|
||||
`,
|
||||
previewBox: css`
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ReadArgs {
|
||||
|
|
@ -57,31 +44,31 @@ const Read = memo<BuiltinRenderProps<ReadArgs>>(({ args, content }) => {
|
|||
const source = useMemo(() => stripLineNumbers(content || ''), [content]);
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||
<Icon icon={FileText} size={'small'} />
|
||||
<Text strong>{fileName || 'Read'}</Text>
|
||||
{filePath && filePath !== fileName && (
|
||||
<Text ellipsis className={styles.path}>
|
||||
{filePath}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
<ToolResultCard
|
||||
icon={FileText}
|
||||
header={
|
||||
<>
|
||||
<Text strong>{fileName || 'Read'}</Text>
|
||||
{filePath && filePath !== fileName && (
|
||||
<Text ellipsis className={styles.path}>
|
||||
{filePath}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{source && (
|
||||
<Flexbox className={styles.previewBox}>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={ext || 'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{source}
|
||||
</Highlighter>
|
||||
</Flexbox>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={ext || 'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{source}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
</ToolResultCard>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Icon, Markdown, Skeleton, Text } from '@lobehub/ui';
|
||||
import { Highlighter, Markdown, Skeleton, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { FilePlus2 } from 'lucide-react';
|
||||
import path from 'path-browserify-esm';
|
||||
import { memo } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 8px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
header: css`
|
||||
padding-inline: 4px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
path: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
word-break: break-all;
|
||||
`,
|
||||
previewBox: css`
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface WriteArgs {
|
||||
|
|
@ -46,7 +33,7 @@ const Write = memo<BuiltinRenderProps<WriteArgs>>(({ args }) => {
|
|||
|
||||
if (ext === 'md' || ext === 'mdx') {
|
||||
return (
|
||||
<Markdown style={{ maxHeight: 240, overflow: 'auto', padding: '0 8px' }} variant={'chat'}>
|
||||
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
|
||||
{args.content}
|
||||
</Markdown>
|
||||
);
|
||||
|
|
@ -66,19 +53,21 @@ const Write = memo<BuiltinRenderProps<WriteArgs>>(({ args }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||
<Icon icon={FilePlus2} size={'small'} />
|
||||
<Text strong>{fileName || 'Write'}</Text>
|
||||
{filePath && filePath !== fileName && (
|
||||
<Text ellipsis className={styles.path}>
|
||||
{filePath}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
{args.content && <Flexbox className={styles.previewBox}>{renderContent()}</Flexbox>}
|
||||
</Flexbox>
|
||||
<ToolResultCard
|
||||
icon={FilePlus2}
|
||||
header={
|
||||
<>
|
||||
<Text strong>{fileName || 'Write'}</Text>
|
||||
{filePath && filePath !== fileName && (
|
||||
<Text ellipsis className={styles.path}>
|
||||
{filePath}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
</ToolResultCard>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./renders": "./src/Render/index.ts",
|
||||
"./inspectors": "./src/Inspector/index.ts",
|
||||
"./styles": "./src/styles.ts"
|
||||
|
|
|
|||
51
packages/shared-tool-ui/src/components/ToolResultCard.tsx
Normal file
51
packages/shared-tool-ui/src/components/ToolResultCard.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
import { Flexbox, Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import { memo, type ReactNode } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
padding: 8px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
header: css`
|
||||
padding-inline: 4px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
previewBox: css`
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ToolResultCardProps {
|
||||
children?: ReactNode;
|
||||
header: ReactNode;
|
||||
icon: LucideIcon;
|
||||
wrapHeader?: boolean;
|
||||
}
|
||||
|
||||
export const ToolResultCard = memo<ToolResultCardProps>(
|
||||
({ icon, header, children, wrapHeader }) => (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.header}
|
||||
gap={8}
|
||||
wrap={wrapHeader ? 'wrap' : undefined}
|
||||
>
|
||||
<Icon icon={icon} size={'small'} />
|
||||
{header}
|
||||
</Flexbox>
|
||||
{children && <Flexbox className={styles.previewBox}>{children}</Flexbox>}
|
||||
</Flexbox>
|
||||
),
|
||||
);
|
||||
|
||||
ToolResultCard.displayName = 'ToolResultCard';
|
||||
2
packages/shared-tool-ui/src/components/index.ts
Normal file
2
packages/shared-tool-ui/src/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { FilePathDisplay } from './FilePathDisplay';
|
||||
export { ToolResultCard } from './ToolResultCard';
|
||||
52
src/components/RingLoading.tsx
Normal file
52
src/components/RingLoading.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { cssVar, cx } from 'antd-style';
|
||||
import { type CSSProperties, type SVGProps } from 'react';
|
||||
|
||||
interface RingLoadingIconProps extends SVGProps<SVGSVGElement> {
|
||||
ringColor?: string;
|
||||
size?: number | string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const RingLoadingIcon = ({
|
||||
ref,
|
||||
size = 16,
|
||||
className,
|
||||
style,
|
||||
ringColor = cssVar.colorBorder,
|
||||
...rest
|
||||
}: RingLoadingIconProps & { ref?: React.RefObject<SVGSVGElement | null> }) => {
|
||||
return (
|
||||
<svg
|
||||
className={cx('anticon', className)}
|
||||
color="currentColor"
|
||||
height={size}
|
||||
ref={ref}
|
||||
style={{ flex: 'none', lineHeight: 1, ...style }}
|
||||
viewBox="0 0 1024 1024"
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...rest}
|
||||
>
|
||||
<g fill="none">
|
||||
<circle cx="512" cy="512" fill="none" r="400" stroke={ringColor} strokeWidth="128" />
|
||||
<path
|
||||
d="M912 512C912 290.92 733.08 112 512 112"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="128"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="1s"
|
||||
from="0 512 512"
|
||||
repeatCount="indefinite"
|
||||
to="360 512 512"
|
||||
type="rotate"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default RingLoadingIcon;
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
import { validateVideoFileSize } from '@lobechat/utils/client';
|
||||
import { type ItemType } from '@lobehub/ui';
|
||||
import { Icon, Tooltip } from '@lobehub/ui';
|
||||
import { Upload } from 'antd';
|
||||
import { css, cx } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ArrowRight, FileUp, FolderUp, ImageUp, LibraryBig, Paperclip } from 'lucide-react';
|
||||
import { memo, Suspense, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { message } from '@/components/AntdStaticMethods';
|
||||
import FileIcon from '@/components/FileIcon';
|
||||
import RepoIcon from '@/components/LibIcon';
|
||||
import TipGuide from '@/components/TipGuide';
|
||||
import { AttachKnowledgeModal } from '@/features/LibraryModal';
|
||||
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { preferenceSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { useAgentId } from '../../hooks/useAgentId';
|
||||
import Action from '../components/Action';
|
||||
import { type ActionDropdownMenuItems } from '../components/ActionDropdown';
|
||||
import CheckboxItem from '../components/CheckboxWithLoading';
|
||||
|
||||
const hotArea = css`
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
const FileUpload = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const upload = useFileStore((s) => s.uploadChatFiles);
|
||||
|
||||
const agentId = useAgentId();
|
||||
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
|
||||
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
|
||||
const isHeterogeneous = useAgentStore((s) =>
|
||||
agentByIdSelectors.isAgentHeterogeneousById(agentId)(s),
|
||||
);
|
||||
|
||||
const canUploadImage = useModelSupportVision(model, provider);
|
||||
|
||||
const [showTip, updateGuideState] = useUserStore((s) => [
|
||||
preferenceSelectors.showUploadFileInKnowledgeBaseTip(s),
|
||||
s.updateGuideState,
|
||||
]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const files = useAgentStore((s) => agentByIdSelectors.getAgentFilesById(agentId)(s), isEqual);
|
||||
const knowledgeBases = useAgentStore(
|
||||
(s) => agentByIdSelectors.getAgentKnowledgeBasesById(agentId)(s),
|
||||
isEqual,
|
||||
);
|
||||
|
||||
const [toggleFile, toggleKnowledgeBase] = useAgentStore((s) => [
|
||||
s.toggleFile,
|
||||
s.toggleKnowledgeBase,
|
||||
]);
|
||||
|
||||
const uploadItems: ActionDropdownMenuItems = [
|
||||
{
|
||||
closeOnClick: false,
|
||||
disabled: !canUploadImage,
|
||||
icon: ImageUp,
|
||||
key: 'upload-image',
|
||||
label: canUploadImage ? (
|
||||
<Upload
|
||||
multiple
|
||||
accept={'image/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={async (file) => {
|
||||
setDropdownOpen(false);
|
||||
await upload([file]);
|
||||
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div className={cx(hotArea)}>{t('upload.action.imageUpload')}</div>
|
||||
</Upload>
|
||||
) : (
|
||||
<Tooltip placement={'right'} title={t('upload.action.imageDisabled')}>
|
||||
<div className={cx(hotArea)}>{t('upload.action.imageUpload')}</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
// Heterogeneous agents (e.g. Claude Code) currently only support image upload.
|
||||
...(isHeterogeneous
|
||||
? []
|
||||
: [
|
||||
{
|
||||
closeOnClick: false,
|
||||
icon: FileUp,
|
||||
key: 'upload-file',
|
||||
label: (
|
||||
<Upload
|
||||
multiple
|
||||
showUploadList={false}
|
||||
beforeUpload={async (file) => {
|
||||
if (
|
||||
!canUploadImage &&
|
||||
(file.type.startsWith('image') || file.type.startsWith('video'))
|
||||
)
|
||||
return false;
|
||||
|
||||
// Validate video file size
|
||||
const validation = validateVideoFileSize(file);
|
||||
if (!validation.isValid) {
|
||||
message.error(
|
||||
t('upload.validation.videoSizeExceeded', {
|
||||
actualSize: validation.actualSize,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setDropdownOpen(false);
|
||||
await upload([file]);
|
||||
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div className={cx(hotArea)}>{t('upload.action.fileUpload')}</div>
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
{
|
||||
closeOnClick: false,
|
||||
icon: FolderUp,
|
||||
key: 'upload-folder',
|
||||
label: (
|
||||
<Upload
|
||||
directory
|
||||
multiple={true}
|
||||
showUploadList={false}
|
||||
beforeUpload={async (file) => {
|
||||
if (
|
||||
!canUploadImage &&
|
||||
(file.type.startsWith('image') || file.type.startsWith('video'))
|
||||
)
|
||||
return false;
|
||||
|
||||
// Validate video file size
|
||||
const validation = validateVideoFileSize(file);
|
||||
if (!validation.isValid) {
|
||||
message.error(
|
||||
t('upload.validation.videoSizeExceeded', {
|
||||
actualSize: validation.actualSize,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setDropdownOpen(false);
|
||||
await upload([file]);
|
||||
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div className={cx(hotArea)}>{t('upload.action.folderUpload')}</div>
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const knowledgeItems: ItemType[] = [];
|
||||
|
||||
// Only add knowledge base items if there are files or knowledge bases
|
||||
if (files.length > 0 || knowledgeBases.length > 0) {
|
||||
knowledgeItems.push({
|
||||
children: [
|
||||
// first the files
|
||||
...files.map((item) => ({
|
||||
icon: <FileIcon fileName={item.name} fileType={item.type} size={20} />,
|
||||
key: item.id,
|
||||
label: (
|
||||
<CheckboxItem
|
||||
checked={item.enabled}
|
||||
id={item.id}
|
||||
label={item.name}
|
||||
onUpdate={async (id, enabled) => {
|
||||
setUpdating(true);
|
||||
await toggleFile(id, enabled);
|
||||
setUpdating(false);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
|
||||
// then the knowledge bases
|
||||
...knowledgeBases.map((item) => ({
|
||||
icon: <RepoIcon />,
|
||||
key: item.id,
|
||||
label: (
|
||||
<CheckboxItem
|
||||
checked={item.enabled}
|
||||
id={item.id}
|
||||
label={item.name}
|
||||
onUpdate={async (id, enabled) => {
|
||||
setUpdating(true);
|
||||
await toggleKnowledgeBase(id, enabled);
|
||||
setUpdating(false);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
],
|
||||
key: 'relativeFilesOrLibraries',
|
||||
label: t('knowledgeBase.relativeFilesOrLibraries'),
|
||||
type: 'group',
|
||||
});
|
||||
}
|
||||
|
||||
// Always add the "View More" option
|
||||
knowledgeItems.push(
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
extra: <Icon icon={ArrowRight} />,
|
||||
icon: LibraryBig,
|
||||
key: 'knowledge-base-store',
|
||||
label: t('knowledgeBase.viewMore'),
|
||||
onClick: () => {
|
||||
setModalOpen(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const items: ActionDropdownMenuItems = [
|
||||
...uploadItems,
|
||||
...(knowledgeItems.length > 0 ? knowledgeItems : []),
|
||||
];
|
||||
|
||||
const content = (
|
||||
<Action
|
||||
icon={Paperclip}
|
||||
loading={updating}
|
||||
open={dropdownOpen}
|
||||
showTooltip={false}
|
||||
title={t('upload.action.tooltip')}
|
||||
trigger={'both'}
|
||||
dropdown={{
|
||||
maxHeight: 500,
|
||||
maxWidth: 480,
|
||||
menu: { items },
|
||||
minWidth: 240,
|
||||
}}
|
||||
onOpenChange={setDropdownOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Action disabled icon={Paperclip} title={t('upload.action.tooltip')} />}>
|
||||
{showTip ? (
|
||||
<TipGuide
|
||||
open={showTip}
|
||||
placement={'top'}
|
||||
title={t('knowledgeBase.uploadGuide')}
|
||||
onOpenChange={() => {
|
||||
updateGuideState({ uploadFileInKnowledgeBase: false });
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</TipGuide>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
<AttachKnowledgeModal open={modalOpen} setOpen={setModalOpen} />
|
||||
</Suspense>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUpload;
|
||||
|
|
@ -1,10 +1,277 @@
|
|||
import { validateVideoFileSize } from '@lobechat/utils/client';
|
||||
import { type ItemType } from '@lobehub/ui';
|
||||
import { Icon, Tooltip } from '@lobehub/ui';
|
||||
import { Upload } from 'antd';
|
||||
import { css, cx } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ArrowRight, FileUp, FolderUp, ImageUp, LibraryBig, Paperclip } from 'lucide-react';
|
||||
import { memo, Suspense, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { message } from '@/components/AntdStaticMethods';
|
||||
import FileIcon from '@/components/FileIcon';
|
||||
import RepoIcon from '@/components/LibIcon';
|
||||
import TipGuide from '@/components/TipGuide';
|
||||
import { AttachKnowledgeModal } from '@/features/LibraryModal';
|
||||
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { preferenceSelectors } from '@/store/user/selectors';
|
||||
|
||||
import ServerMode from './ServerMode';
|
||||
import { useAgentId } from '../../hooks/useAgentId';
|
||||
import Action from '../components/Action';
|
||||
import { type ActionDropdownMenuItems } from '../components/ActionDropdown';
|
||||
import CheckboxItem from '../components/CheckboxWithLoading';
|
||||
|
||||
const Upload = () => {
|
||||
const { enableKnowledgeBase } = useServerConfigStore(featureFlagsSelectors);
|
||||
return enableKnowledgeBase && <ServerMode />;
|
||||
};
|
||||
const hotArea = css`
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Upload;
|
||||
const FileUpload = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const enableKnowledgeBase = useServerConfigStore(
|
||||
(s) => featureFlagsSelectors(s).enableKnowledgeBase,
|
||||
);
|
||||
|
||||
const upload = useFileStore((s) => s.uploadChatFiles);
|
||||
|
||||
const agentId = useAgentId();
|
||||
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
|
||||
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
|
||||
|
||||
const canUploadImage = useModelSupportVision(model, provider);
|
||||
|
||||
const [showTip, updateGuideState] = useUserStore((s) => [
|
||||
preferenceSelectors.showUploadFileInKnowledgeBaseTip(s),
|
||||
s.updateGuideState,
|
||||
]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const files = useAgentStore((s) => agentByIdSelectors.getAgentFilesById(agentId)(s), isEqual);
|
||||
const knowledgeBases = useAgentStore(
|
||||
(s) => agentByIdSelectors.getAgentKnowledgeBasesById(agentId)(s),
|
||||
isEqual,
|
||||
);
|
||||
|
||||
const [toggleFile, toggleKnowledgeBase] = useAgentStore((s) => [
|
||||
s.toggleFile,
|
||||
s.toggleKnowledgeBase,
|
||||
]);
|
||||
|
||||
if (!enableKnowledgeBase) return null;
|
||||
|
||||
const uploadItems: ActionDropdownMenuItems = [
|
||||
{
|
||||
closeOnClick: false,
|
||||
disabled: !canUploadImage,
|
||||
icon: ImageUp,
|
||||
key: 'upload-image',
|
||||
label: canUploadImage ? (
|
||||
<Upload
|
||||
multiple
|
||||
accept={'image/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={async (file) => {
|
||||
setDropdownOpen(false);
|
||||
await upload([file]);
|
||||
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div className={cx(hotArea)}>{t('upload.action.imageUpload')}</div>
|
||||
</Upload>
|
||||
) : (
|
||||
<Tooltip placement={'right'} title={t('upload.action.imageDisabled')}>
|
||||
<div className={cx(hotArea)}>{t('upload.action.imageUpload')}</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
closeOnClick: false,
|
||||
icon: FileUp,
|
||||
key: 'upload-file',
|
||||
label: (
|
||||
<Upload
|
||||
multiple
|
||||
showUploadList={false}
|
||||
beforeUpload={async (file) => {
|
||||
if (!canUploadImage && (file.type.startsWith('image') || file.type.startsWith('video')))
|
||||
return false;
|
||||
|
||||
// Validate video file size
|
||||
const validation = validateVideoFileSize(file);
|
||||
if (!validation.isValid) {
|
||||
message.error(
|
||||
t('upload.validation.videoSizeExceeded', {
|
||||
actualSize: validation.actualSize,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setDropdownOpen(false);
|
||||
await upload([file]);
|
||||
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div className={cx(hotArea)}>{t('upload.action.fileUpload')}</div>
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
{
|
||||
closeOnClick: false,
|
||||
icon: FolderUp,
|
||||
key: 'upload-folder',
|
||||
label: (
|
||||
<Upload
|
||||
directory
|
||||
multiple={true}
|
||||
showUploadList={false}
|
||||
beforeUpload={async (file) => {
|
||||
if (!canUploadImage && (file.type.startsWith('image') || file.type.startsWith('video')))
|
||||
return false;
|
||||
|
||||
// Validate video file size
|
||||
const validation = validateVideoFileSize(file);
|
||||
if (!validation.isValid) {
|
||||
message.error(
|
||||
t('upload.validation.videoSizeExceeded', {
|
||||
actualSize: validation.actualSize,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setDropdownOpen(false);
|
||||
await upload([file]);
|
||||
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div className={cx(hotArea)}>{t('upload.action.folderUpload')}</div>
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const knowledgeItems: ItemType[] = [];
|
||||
|
||||
// Only add knowledge base items if there are files or knowledge bases
|
||||
if (files.length > 0 || knowledgeBases.length > 0) {
|
||||
knowledgeItems.push({
|
||||
children: [
|
||||
// first the files
|
||||
...files.map((item) => ({
|
||||
icon: <FileIcon fileName={item.name} fileType={item.type} size={20} />,
|
||||
key: item.id,
|
||||
label: (
|
||||
<CheckboxItem
|
||||
checked={item.enabled}
|
||||
id={item.id}
|
||||
label={item.name}
|
||||
onUpdate={async (id, enabled) => {
|
||||
setUpdating(true);
|
||||
await toggleFile(id, enabled);
|
||||
setUpdating(false);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
|
||||
// then the knowledge bases
|
||||
...knowledgeBases.map((item) => ({
|
||||
icon: <RepoIcon />,
|
||||
key: item.id,
|
||||
label: (
|
||||
<CheckboxItem
|
||||
checked={item.enabled}
|
||||
id={item.id}
|
||||
label={item.name}
|
||||
onUpdate={async (id, enabled) => {
|
||||
setUpdating(true);
|
||||
await toggleKnowledgeBase(id, enabled);
|
||||
setUpdating(false);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
],
|
||||
key: 'relativeFilesOrLibraries',
|
||||
label: t('knowledgeBase.relativeFilesOrLibraries'),
|
||||
type: 'group',
|
||||
});
|
||||
}
|
||||
|
||||
// Always add the "View More" option
|
||||
knowledgeItems.push(
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
extra: <Icon icon={ArrowRight} />,
|
||||
icon: LibraryBig,
|
||||
key: 'knowledge-base-store',
|
||||
label: t('knowledgeBase.viewMore'),
|
||||
onClick: () => {
|
||||
setModalOpen(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const items: ActionDropdownMenuItems = [
|
||||
...uploadItems,
|
||||
...(knowledgeItems.length > 0 ? knowledgeItems : []),
|
||||
];
|
||||
|
||||
const content = (
|
||||
<Action
|
||||
icon={Paperclip}
|
||||
loading={updating}
|
||||
open={dropdownOpen}
|
||||
showTooltip={false}
|
||||
title={t('upload.action.tooltip')}
|
||||
trigger={'both'}
|
||||
dropdown={{
|
||||
maxHeight: 500,
|
||||
maxWidth: 480,
|
||||
menu: { items },
|
||||
minWidth: 240,
|
||||
}}
|
||||
onOpenChange={setDropdownOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Action disabled icon={Paperclip} title={t('upload.action.tooltip')} />}>
|
||||
{showTip ? (
|
||||
<TipGuide
|
||||
open={showTip}
|
||||
placement={'top'}
|
||||
title={t('knowledgeBase.uploadGuide')}
|
||||
onOpenChange={() => {
|
||||
updateGuideState({ uploadFileInKnowledgeBase: false });
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</TipGuide>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
<AttachKnowledgeModal open={modalOpen} setOpen={setModalOpen} />
|
||||
</Suspense>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUpload;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import { DraggablePanel, Freeze } from '@lobehub/ui';
|
||||
import { DraggablePanel } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { AnimatePresence, m, useIsPresent } from 'motion/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { memo, Suspense, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { memo, Suspense, useMemo, useRef } from 'react';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { TOGGLE_BUTTON_ID } from '@/features/NavPanel/ToggleLeftPanelButton';
|
||||
|
|
@ -12,39 +11,11 @@ import Footer from '@/routes/(main)/home/_layout/Footer';
|
|||
import { USER_DROPDOWN_ICON_ID } from '@/routes/(main)/home/_layout/Header/components/User';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
||||
import type { PanelSlideMotionDirection } from '@/utils/motion/panelSlideMotion';
|
||||
import {
|
||||
isPanelLayerMotionDisabled,
|
||||
panelSlideMotionVariantsLeft,
|
||||
} from '@/utils/motion/panelSlideMotion';
|
||||
import { isMacOS } from '@/utils/platform';
|
||||
|
||||
import { useNavPanelSizeChangeHandler } from '../hooks/useNavPanel';
|
||||
import { BACK_BUTTON_ID } from './BackButton';
|
||||
|
||||
const getMotionDirectionByHistory = (
|
||||
history: string[],
|
||||
nextKey: string,
|
||||
): PanelSlideMotionDirection => {
|
||||
const currentKey = history.at(-1);
|
||||
if (currentKey === nextKey) return 0;
|
||||
|
||||
return history.includes(nextKey) ? -1 : 1;
|
||||
};
|
||||
|
||||
interface ExitingFrozenContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ExitingFrozenContent = memo<ExitingFrozenContentProps>(({ children }) => {
|
||||
const isPresent = useIsPresent();
|
||||
return <Freeze frozen={!isPresent}>{children}</Freeze>;
|
||||
});
|
||||
|
||||
ExitingFrozenContent.displayName = 'ExitingFrozenContent';
|
||||
|
||||
const draggableStyles = createStaticStyles(({ css, cssVar }) => ({
|
||||
content: css`
|
||||
position: relative;
|
||||
|
|
@ -139,8 +110,6 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
|
|||
systemStatusSelectors.showLeftPanel(s),
|
||||
s.toggleLeftPanel,
|
||||
]);
|
||||
const animationMode = useUserStore(userGeneralSettingsSelectors.animationMode);
|
||||
const shouldUseMotion = !isPanelLayerMotionDisabled(animationMode);
|
||||
const handleSizeChange = useNavPanelSizeChangeHandler();
|
||||
|
||||
const defaultWidthRef = useRef(0);
|
||||
|
|
@ -163,35 +132,6 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
|
|||
[],
|
||||
);
|
||||
|
||||
const historyRef = useRef([activeContent.key]);
|
||||
const directionRef = useRef<PanelSlideMotionDirection>(0);
|
||||
|
||||
const history = historyRef.current;
|
||||
const direction = shouldUseMotion ? getMotionDirectionByHistory(history, activeContent.key) : 0;
|
||||
if (direction !== 0) {
|
||||
directionRef.current = direction;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldUseMotion) return;
|
||||
|
||||
const snapshot = historyRef.current;
|
||||
const currentKey = snapshot.at(-1);
|
||||
const nextKey = activeContent.key;
|
||||
|
||||
if (currentKey === nextKey) return;
|
||||
|
||||
const existingIndex = snapshot.lastIndexOf(nextKey);
|
||||
if (existingIndex !== -1) {
|
||||
snapshot.splice(existingIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.push(nextKey);
|
||||
}, [activeContent.key, shouldUseMotion]);
|
||||
|
||||
const motionDirection = shouldUseMotion ? directionRef.current : 0;
|
||||
|
||||
return (
|
||||
<DraggablePanel
|
||||
className={draggableStyles.panel}
|
||||
|
|
@ -208,26 +148,9 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
|
|||
onSizeDragging={handleSizeChange}
|
||||
>
|
||||
<div className={draggableStyles.inner}>
|
||||
{shouldUseMotion ? (
|
||||
<AnimatePresence custom={motionDirection} initial={false} mode="sync">
|
||||
<m.div
|
||||
animate="animate"
|
||||
className={draggableStyles.layer}
|
||||
custom={motionDirection}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
key={activeContent.key}
|
||||
transition={panelSlideMotionVariantsLeft.transition}
|
||||
variants={panelSlideMotionVariantsLeft}
|
||||
>
|
||||
<ExitingFrozenContent>{activeContent.node}</ExitingFrozenContent>
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<div className={draggableStyles.layer} key={activeContent.key}>
|
||||
{activeContent.node}
|
||||
</div>
|
||||
)}
|
||||
<div className={draggableStyles.layer} key={activeContent.key}>
|
||||
{activeContent.node}
|
||||
</div>
|
||||
</div>
|
||||
<Suspense>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -206,6 +206,12 @@ export default {
|
|||
'analytics.telemetry.desc': 'Help us improve {{appName}} with anonymous usage data',
|
||||
'analytics.telemetry.title': 'Send Anonymous Usage Data',
|
||||
'analytics.title': 'Analytics',
|
||||
|
||||
// Claude Code CLI status (shown on agent profile page in CC integration mode)
|
||||
'ccStatus.detecting': 'Detecting Claude Code CLI...',
|
||||
'ccStatus.redetect': 'Re-detect',
|
||||
'ccStatus.title': 'Claude Code CLI',
|
||||
'ccStatus.unavailable': 'Claude Code CLI not found. Please install or configure it.',
|
||||
'checking': 'Checking...',
|
||||
|
||||
// Credentials Management
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ export default {
|
|||
'You are about to delete unstarred topics. This action cannot be undone.',
|
||||
'actions.copyLink': 'Copy Link',
|
||||
'actions.copyLinkSuccess': 'Link copied',
|
||||
'actions.copySessionId': 'Copy Session ID',
|
||||
'actions.copySessionIdSuccess': 'Session ID copied',
|
||||
'actions.copyWorkingDirectory': 'Copy Working Directory',
|
||||
'actions.copyWorkingDirectorySuccess': 'Working directory copied',
|
||||
'actions.duplicate': 'Duplicate',
|
||||
'actions.favorite': 'Favorite',
|
||||
'actions.unfavorite': 'Unfavorite',
|
||||
|
|
@ -38,6 +42,7 @@ export default {
|
|||
'importLoading': 'Importing conversation...',
|
||||
'importSuccess': 'Successfully imported {{count}} messages',
|
||||
'loadMore': 'Load More',
|
||||
'newTopic': 'New Topic',
|
||||
'searchPlaceholder': 'Search Topics...',
|
||||
'searchResultEmpty': 'No search results found.',
|
||||
'temp': 'Temporary',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import EmptyNavItem from '@/features/NavPanel/components/EmptyNavItem';
|
|||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import { useQueryRoute } from '@/hooks/useQueryRoute';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
|
||||
import CronTopicGroup from './CronTopicGroup';
|
||||
|
|
@ -26,10 +27,11 @@ const CronTopicList = memo<CronTopicListProps>(({ itemKey }) => {
|
|||
s.activeAgentId,
|
||||
s.useFetchCronTopicsWithJobInfo,
|
||||
]);
|
||||
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
|
||||
const { data: cronTopicsGroupsWithJobInfo = [], isLoading } = useFetchCronTopicsWithJobInfo(
|
||||
agentId,
|
||||
enableBusinessFeatures,
|
||||
enableBusinessFeatures && !isHeterogeneous,
|
||||
);
|
||||
|
||||
const handleCreateCronJob = useCallback(() => {
|
||||
|
|
@ -37,7 +39,7 @@ const CronTopicList = memo<CronTopicListProps>(({ itemKey }) => {
|
|||
router.push(urlJoin('/agent', agentId, 'cron', 'new'));
|
||||
}, [agentId, router]);
|
||||
|
||||
if (!enableBusinessFeatures) return null;
|
||||
if (!enableBusinessFeatures || isHeterogeneous) return null;
|
||||
|
||||
const addAction = (
|
||||
<ActionIcon
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { createStaticStyles, cssVar, keyframes } from 'antd-style';
|
||||
import { HashIcon, MessageSquareDashed } from 'lucide-react';
|
||||
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RingLoadingIcon from '@/components/RingLoading';
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
|
||||
import NavItem from '@/features/NavPanel/components/NavItem';
|
||||
|
|
@ -21,39 +21,51 @@ import Actions from './Actions';
|
|||
import Editing from './Editing';
|
||||
import { useTopicItemDropdownMenu } from './useDropdownMenu';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
neonDotWrapper: css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
const rippleAnim = keyframes`
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
unreadWrapper: css`
|
||||
position: relative;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
`,
|
||||
dotContainer: css`
|
||||
will-change: width;
|
||||
|
||||
unreadDot: css`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-inline-start: -6px;
|
||||
|
||||
transition: width 0.2s ${cssVar.motionEaseOut};
|
||||
`,
|
||||
neonDot: css`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
|
||||
background: ${cssVar.colorInfo};
|
||||
box-shadow:
|
||||
0 0 3px ${cssVar.colorInfo},
|
||||
0 0 6px ${cssVar.colorInfo};
|
||||
`,
|
||||
unreadRipple: css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: auto;
|
||||
border: 1px solid ${cssVar.colorInfo};
|
||||
border-radius: 50%;
|
||||
|
||||
background: transparent;
|
||||
|
||||
animation: ${rippleAnim} 1.8s ease-out infinite;
|
||||
`,
|
||||
}));
|
||||
|
||||
|
|
@ -129,44 +141,10 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
});
|
||||
|
||||
const hasUnread = id && isUnreadCompleted;
|
||||
const infoColor = cssVar.colorInfo;
|
||||
const unreadNode = (
|
||||
<span className={styles.dotContainer} style={{ width: hasUnread ? 18 : 0 }}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{hasUnread && (
|
||||
<m.div
|
||||
className={styles.neonDotWrapper}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<m.span
|
||||
className={styles.neonDot}
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
opacity: [1, 0.9, 1],
|
||||
boxShadow: [
|
||||
`0 0 3px ${infoColor}, 0 0 6px ${infoColor}`,
|
||||
`0 0 5px ${infoColor}, 0 0 8px color-mix(in srgb, ${infoColor} 60%, transparent)`,
|
||||
`0 0 3px ${infoColor}, 0 0 6px ${infoColor}`,
|
||||
],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
const unreadIcon = (
|
||||
<span className={styles.unreadWrapper}>
|
||||
<span className={styles.unreadRipple} />
|
||||
<span className={styles.unreadDot} />
|
||||
</span>
|
||||
);
|
||||
|
||||
|
|
@ -177,7 +155,11 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
active={active && !isInAgentSubRoute}
|
||||
icon={
|
||||
isLoading ? (
|
||||
<Icon spin color={cssVar.colorWarning} icon={Loader2Icon} size={'small'} />
|
||||
<RingLoadingIcon
|
||||
ringColor={cssVar.colorWarningBorder}
|
||||
size={14}
|
||||
style={{ color: cssVar.colorWarning }}
|
||||
/>
|
||||
) : (
|
||||
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
|
||||
)
|
||||
|
|
@ -213,9 +195,14 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
icon={(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} />
|
||||
<RingLoadingIcon
|
||||
ringColor={cssVar.colorWarningBorder}
|
||||
size={14}
|
||||
style={{ color: cssVar.colorWarning }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (hasUnread) return unreadIcon;
|
||||
if (metadata?.bot?.platform) {
|
||||
const ProviderIcon = getPlatformIcon(metadata.bot!.platform);
|
||||
if (ProviderIcon) {
|
||||
|
|
@ -226,9 +213,6 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
|
||||
);
|
||||
})()}
|
||||
slots={{
|
||||
iconPostfix: unreadNode,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { type DropdownItem } from '@lobehub/ui';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import { type DropdownItem, Icon } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { Copy, Hash, Maximize2, PencilLine, Star, Trash } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
|
||||
export const useMenu = (): { menuItems: DropdownItem[] } => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { t } = useTranslation(['chat', 'topic', 'common']);
|
||||
const { modal, message } = App.useApp();
|
||||
|
||||
const [wideScreen, toggleWideScreen] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.wideScreen(s),
|
||||
s.toggleWideScreen,
|
||||
]);
|
||||
|
||||
const menuItems = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
checked: wideScreen,
|
||||
icon: <Icon icon={Maximize2} />,
|
||||
key: 'full-width',
|
||||
label: t('viewMode.fullWidth'),
|
||||
onCheckedChange: toggleWideScreen,
|
||||
type: 'switch',
|
||||
},
|
||||
],
|
||||
[t, toggleWideScreen, wideScreen],
|
||||
);
|
||||
const activeTopic = useChatStore(topicSelectors.currentActiveTopic);
|
||||
const workingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const [favoriteTopic, removeTopic] = useChatStore((s) => [s.favoriteTopic, s.removeTopic]);
|
||||
|
||||
const topicId = activeTopic?.id;
|
||||
const isFavorite = !!activeTopic?.favorite;
|
||||
|
||||
const menuItems = useMemo<DropdownItem[]>(() => {
|
||||
const items: DropdownItem[] = [];
|
||||
|
||||
if (topicId) {
|
||||
items.push(
|
||||
{
|
||||
icon: <Icon icon={Star} />,
|
||||
key: 'favorite',
|
||||
label: t(isFavorite ? 'actions.unfavorite' : 'actions.favorite', { ns: 'topic' }),
|
||||
onClick: () => {
|
||||
favoriteTopic(topicId, !isFavorite);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={PencilLine} />,
|
||||
key: 'rename',
|
||||
label: t('rename', { ns: 'common' }),
|
||||
onClick: () => {
|
||||
useChatStore.setState({ topicRenamingId: topicId });
|
||||
},
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
);
|
||||
|
||||
if (isDesktop && workingDirectory) {
|
||||
items.push({
|
||||
icon: <Icon icon={Copy} />,
|
||||
key: 'copyWorkingDirectory',
|
||||
label: t('actions.copyWorkingDirectory', { ns: 'topic' }),
|
||||
onClick: () => {
|
||||
void navigator.clipboard.writeText(workingDirectory);
|
||||
message.success(t('actions.copyWorkingDirectorySuccess', { ns: 'topic' }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
icon: <Icon icon={Hash} />,
|
||||
key: 'copySessionId',
|
||||
label: t('actions.copySessionId', { ns: 'topic' }),
|
||||
onClick: () => {
|
||||
void navigator.clipboard.writeText(topicId);
|
||||
message.success(t('actions.copySessionIdSuccess', { ns: 'topic' }));
|
||||
},
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
);
|
||||
}
|
||||
|
||||
items.push({
|
||||
checked: wideScreen,
|
||||
icon: <Icon icon={Maximize2} />,
|
||||
key: 'full-width',
|
||||
label: t('viewMode.fullWidth'),
|
||||
onCheckedChange: toggleWideScreen,
|
||||
type: 'switch',
|
||||
});
|
||||
|
||||
if (topicId) {
|
||||
items.push(
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
danger: true,
|
||||
icon: <Icon icon={Trash} />,
|
||||
key: 'delete',
|
||||
label: t('delete', { ns: 'common' }),
|
||||
onClick: () => {
|
||||
modal.confirm({
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await removeTopic(topicId);
|
||||
},
|
||||
title: t('actions.confirmRemoveTopic', { ns: 'topic' }),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
topicId,
|
||||
isFavorite,
|
||||
workingDirectory,
|
||||
wideScreen,
|
||||
favoriteTopic,
|
||||
removeTopic,
|
||||
toggleWideScreen,
|
||||
t,
|
||||
modal,
|
||||
message,
|
||||
]);
|
||||
|
||||
return { menuItems };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { Github } from '@lobehub/icons';
|
||||
import { Icon, Tooltip } from '@lobehub/ui';
|
||||
import { Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { FolderIcon, GitBranchIcon } from 'lucide-react';
|
||||
import { memo, type ReactNode, useMemo } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { getRecentDirs } from '@/features/ChatInput/RuntimeConfig/recentDirs';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
|
@ -15,32 +12,23 @@ const styles = createStaticStyles(({ css }) => ({
|
|||
chip: css`
|
||||
cursor: pointer;
|
||||
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
height: 22px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 4px;
|
||||
max-width: 200px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
|
||||
transition: all 0.2s;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
label: css`
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
const FolderTag = memo(() => {
|
||||
|
|
@ -48,14 +36,6 @@ const FolderTag = memo(() => {
|
|||
|
||||
const topicBoundDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
|
||||
const iconNode = useMemo((): ReactNode => {
|
||||
if (!topicBoundDirectory) return null;
|
||||
const match = getRecentDirs().find((d) => d.path === topicBoundDirectory);
|
||||
if (match?.repoType === 'github') return <Github size={12} />;
|
||||
if (match?.repoType === 'git') return <Icon icon={GitBranchIcon} size={12} />;
|
||||
return <Icon icon={FolderIcon} size={12} />;
|
||||
}, [topicBoundDirectory]);
|
||||
|
||||
if (!isDesktop || !topicBoundDirectory) return null;
|
||||
|
||||
const displayName = topicBoundDirectory.split('/').findLast(Boolean) || topicBoundDirectory;
|
||||
|
|
@ -66,10 +46,9 @@ const FolderTag = memo(() => {
|
|||
|
||||
return (
|
||||
<Tooltip title={`${topicBoundDirectory} · ${t('localFiles.openFolder')}`}>
|
||||
<div className={styles.chip} onClick={handleOpen}>
|
||||
{iconNode}
|
||||
<span className={styles.label}>{displayName}</span>
|
||||
</div>
|
||||
<span className={styles.chip} onClick={handleOpen}>
|
||||
{displayName}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Flexbox } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
|
@ -10,6 +12,7 @@ import FolderTag from './FolderTag';
|
|||
import MemberCountTag from './MemberCountTag';
|
||||
|
||||
const TitleTags = memo(() => {
|
||||
const { t } = useTranslation('topic');
|
||||
const topicTitle = useChatStore((s) => topicSelectors.currentActiveTopic(s)?.title);
|
||||
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
|
||||
|
||||
|
|
@ -23,20 +26,19 @@ const TitleTags = memo(() => {
|
|||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
{topicTitle && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
opacity: 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{topicTitle}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
color: cssVar.colorText,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginLeft: 8,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{topicTitle || t('newTopic')}
|
||||
</span>
|
||||
<FolderTag />
|
||||
</Flexbox>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,15 +15,20 @@ const Header = memo(() => {
|
|||
return (
|
||||
<NavHeader
|
||||
left={
|
||||
<Flexbox style={{ backgroundColor: cssVar.colorBgContainer }}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={4}
|
||||
style={{ backgroundColor: cssVar.colorBgContainer }}
|
||||
>
|
||||
<Tags />
|
||||
<HeaderActions />
|
||||
</Flexbox>
|
||||
}
|
||||
right={
|
||||
<Flexbox horizontal align={'center'} style={{ backgroundColor: cssVar.colorBgContainer }}>
|
||||
<ShareButton />
|
||||
<WorkingPanelToggle />
|
||||
<HeaderActions />
|
||||
</Flexbox>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,17 +9,17 @@ import { useChatStore } from '@/store/chat';
|
|||
import WorkingDirectoryBar from './WorkingDirectoryBar';
|
||||
|
||||
// Heterogeneous agents (e.g. Claude Code) bring their own toolchain, memory,
|
||||
// and model, so LobeHub-side pickers don't apply. Only file upload is kept
|
||||
// (images feed into the agent via stream-json stdin).
|
||||
const leftActions: ActionKeys[] = ['fileUpload'];
|
||||
// and model, so LobeHub-side pickers don't apply. Typo is kept so the user
|
||||
// can still toggle the rich-text formatting bar.
|
||||
const leftActions: ActionKeys[] = ['typo'];
|
||||
const rightActions: ActionKeys[] = [];
|
||||
|
||||
/**
|
||||
* HeterogeneousChatInput
|
||||
*
|
||||
* Simplified ChatInput for heterogeneous agents (Claude Code, etc.).
|
||||
* Keeps only: text input, image/file upload, send button, and a
|
||||
* working-directory picker — no model/tools/memory/KB/MCP/runtime-mode.
|
||||
* Keeps only: text input, typo toggle, send button, and a working-directory
|
||||
* picker — no model/tools/memory/KB/MCP/runtime-mode/upload.
|
||||
*/
|
||||
const HeterogeneousChatInput = memo(() => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ const AgentHeader = memo(() => {
|
|||
>
|
||||
{/* Avatar Section */}
|
||||
<EmojiPicker
|
||||
allowModelAvatar
|
||||
allowUpload
|
||||
allowDelete={!!meta.avatar}
|
||||
loading={uploading}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { type ToolStatus } from '@lobechat/electron-client-ipc';
|
||||
import { ActionIcon, CopyButton, Flexbox, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { CheckCircle2, Loader2Icon, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { toolDetectorService } from '@/services/electron/toolDetector';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
card: css`
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
|
||||
background: ${token.colorFillQuaternary};
|
||||
`,
|
||||
path: css`
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const CCStatusCard = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { styles } = useStyles();
|
||||
const [status, setStatus] = useState<ToolStatus | undefined>();
|
||||
const [detecting, setDetecting] = useState(true);
|
||||
|
||||
const detect = useCallback(async () => {
|
||||
if (!isDesktop) return;
|
||||
setDetecting(true);
|
||||
try {
|
||||
const result = await toolDetectorService.detectTool('claude', true);
|
||||
setStatus(result);
|
||||
} catch (error) {
|
||||
console.error('[CCStatusCard] Failed to detect claude CLI:', error);
|
||||
setStatus({ available: false, error: (error as Error).message });
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void detect();
|
||||
}, [detect]);
|
||||
|
||||
const renderBody = () => {
|
||||
if (detecting) {
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.6 }} />
|
||||
<Text type="secondary">{t('ccStatus.detecting')}</Text>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status || !status.available) {
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Icon color="var(--ant-color-error)" icon={XCircle} size={16} />
|
||||
<Text type="secondary">{t('ccStatus.unavailable')}</Text>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={8} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Icon color="var(--ant-color-success)" icon={CheckCircle2} size={16} />
|
||||
{status.version && <Tag color="processing">{status.version}</Tag>}
|
||||
{status.path && (
|
||||
<Tooltip title={status.path}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
gap={4}
|
||||
style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<Text ellipsis className={styles.path}>
|
||||
{status.path}
|
||||
</Text>
|
||||
<CopyButton content={status.path} size="small" />
|
||||
</Flexbox>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.card} gap={8} style={{ marginBottom: 12 }}>
|
||||
<Flexbox horizontal align="center" gap={8} justify="space-between">
|
||||
<Text strong>{t('ccStatus.title')}</Text>
|
||||
<Tooltip title={t('ccStatus.redetect')}>
|
||||
<ActionIcon
|
||||
disabled={detecting}
|
||||
icon={RefreshCw}
|
||||
loading={detecting}
|
||||
size="small"
|
||||
onClick={detect}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flexbox>
|
||||
{renderBody()}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
CCStatusCard.displayName = 'CCStatusCard';
|
||||
|
||||
export default CCStatusCard;
|
||||
|
|
@ -15,10 +15,12 @@ import AgentSettings from '../AgentSettings';
|
|||
import EditorCanvas from '../EditorCanvas';
|
||||
import AgentHeader from './AgentHeader';
|
||||
import AgentTool from './AgentTool';
|
||||
import CCStatusCard from './CCStatusCard';
|
||||
|
||||
const ProfileEditor = memo(() => {
|
||||
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
|
||||
const updateConfig = useAgentStore((s) => s.updateAgentConfig);
|
||||
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
|
||||
|
||||
return (
|
||||
|
|
@ -31,25 +33,32 @@ const ProfileEditor = memo(() => {
|
|||
>
|
||||
{/* Header: Avatar + Name + Description */}
|
||||
<AgentHeader />
|
||||
{/* Config Bar: Model Selector */}
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={8}
|
||||
justify={'flex-start'}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<ModelSelect
|
||||
initialWidth
|
||||
popupWidth={400}
|
||||
value={{
|
||||
model: config.model,
|
||||
provider: config.provider,
|
||||
}}
|
||||
onChange={updateConfig}
|
||||
/>
|
||||
</Flexbox>
|
||||
<AgentTool />
|
||||
{isHeterogeneous ? (
|
||||
// CC integration mode: show CLI version + path instead of model/skills pickers
|
||||
<CCStatusCard />
|
||||
) : (
|
||||
<>
|
||||
{/* Config Bar: Model Selector */}
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={8}
|
||||
justify={'flex-start'}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<ModelSelect
|
||||
initialWidth
|
||||
popupWidth={400}
|
||||
value={{
|
||||
model: config.model,
|
||||
provider: config.provider,
|
||||
}}
|
||||
onChange={updateConfig}
|
||||
/>
|
||||
</Flexbox>
|
||||
<AgentTool />
|
||||
</>
|
||||
)}
|
||||
</Flexbox>
|
||||
<Divider />
|
||||
{/* Main Content: Prompt Editor */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue