Commit 5ea26da9 authored by AI-甘富林's avatar AI-甘富林

refactor(ui): split renderer shell and stabilize home sessions

parent 8190539a
This diff is collapsed.
This diff is collapsed.
import type { ReactNode, ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
}
export function Button({
children,
variant = "primary",
size = "md",
className,
disabled,
...props
}: ButtonProps) {
const classes = [
"button",
`button-${variant}`,
`button-${size}`,
className
].filter(Boolean).join(" ");
return (
<button
className={classes}
disabled={disabled}
{...props}
>
{children}
</button>
);
}
import type { ButtonHTMLAttributes, ReactNode } from "react"
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode
variant?: "ghost" | "subtle" | "danger"
size?: "sm" | "md"
}
export function IconButton({
children,
variant = "ghost",
size = "md",
className,
type = "button",
...props
}: IconButtonProps) {
const classes = [
"icon-button",
`icon-button-${variant}`,
`icon-button-${size}`,
className
].filter(Boolean).join(" ")
return (
<button type={type} className={classes} {...props}>
{children}
</button>
)
}
import type { ReactNode, HTMLAttributes } from "react";
export interface PanelProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
header?: ReactNode;
footer?: ReactNode;
bodyClassName?: string;
}
export function Panel({
children,
header,
footer,
bodyClassName,
className,
...props
}: PanelProps) {
return (
<div
className={["panel", className].filter(Boolean).join(" ")}
{...props}
>
{header && (
<div className="panel-header">
{header}
</div>
)}
<div className={["panel-body", bodyClassName].filter(Boolean).join(" ")}>
{children}
</div>
{footer && (
<div className="panel-footer">
{footer}
</div>
)}
</div>
);
}
import type { ReactNode, HTMLAttributes } from "react";
export interface ScrollAreaProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export function ScrollArea({
children,
className,
...props
}: ScrollAreaProps) {
return (
<div
className={["scroll-area", className].filter(Boolean).join(" ")}
{...props}
>
<div className="scroll-area-content custom-scrollbar">
{children}
</div>
</div>
);
}
import type { ReactNode } from "react"
export type StatusChipTone = "positive" | "warning" | "info"
interface StatusChipProps {
tone: StatusChipTone
children: ReactNode
}
export function StatusChip({ tone, children }: StatusChipProps) {
return <span className={"status-chip " + tone}>{children}</span>
}
import type { ReactNode } from "react"
export interface TabItem {
id: string
label: ReactNode
disabled?: boolean
}
interface TabsProps {
items: TabItem[]
value: string
onValueChange: (value: string) => void
ariaLabel: string
className?: string
}
export function Tabs({ items, value, onValueChange, ariaLabel, className }: TabsProps) {
return (
<div className={["tabs", className].filter(Boolean).join(" ")} role="tablist" aria-label={ariaLabel}>
{items.map((item) => {
const active = item.id === value
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={active}
className={"tab-trigger" + (active ? " active" : "")}
disabled={item.disabled}
onClick={() => onValueChange(item.id)}
>
{item.label}
</button>
)
})}
</div>
)
}
export const DEFAULT_SKILL = {
id: "default-chat",
name: "千匠问天",
description: "通用助理,可处理 Excel、信息整理与资料检索,按需切换小红书/抖音运营视角",
category: "通用",
enabled: true,
ready: true,
downloadState: "ready" as const
};
export const ui = {
app: "\u5343\u5320Claw",
subtitle: "OpenClaw Client",
appDesc: "\u7ed1\u5b9a api_key \u540e\u81ea\u52a8\u540c\u6b65\u8fd0\u884c\u65f6\u914d\u7f6e\u3002",
heroLine: "先处理 Excel、资料整理、信息检索和任务拆解,需要做平台内容时再切到对应专家。",
chat: "\u5bf9\u8bdd",
skills: "\u80fd\u529b",
experts: "\u4e13\u5bb6",
plugins: "\u63d2\u4ef6",
knowledge: "知识库",
knowledgePageDesc: "上传和管理您的企业知识文档",
settings: "\u8bbe\u7f6e",
bound: "\u5df2\u7ed1\u5b9a",
unbound: "\u672a\u7ed1\u5b9a",
defaultChat: "千匠问天",
bindTitle: "绑定龙虾密钥",
bindDesc: "你好,我是千匠问天。可以先帮你整理 Excel、汇总资料、检索公开信息、拆解执行任务;如果你要做小红书或抖音内容,也可以切到对应专家继续处理。",
apiKey: "\u5458\u5de5\u5bc6\u94a5",
apiKeyPlaceholder: "\u8bf7\u8f93\u5165 OpenClaw employee api_key",
bindNow: "\u7acb\u5373\u7ed1\u5b9a",
binding: "\u7ed1\u5b9a\u4e2d...",
changeApiKey: "\u66f4\u6362\u5458\u5de5\u5bc6\u94a5",
skillChoice: "\u9009\u62e9\u80fd\u529b",
clearSkill: "\u6e05\u7a7a\u80fd\u529b",
skillMenuTitle: "\u9009\u62e9\u80fd\u529b",
noMessages: "\u5f53\u524d\u6ca1\u6709\u6d88\u606f\uff0c\u8bf7\u5148\u53d1\u9001\u4e00\u6761\u6d88\u606f\u3002",
expertSessionsTitle: "\u4f1a\u8bdd",
expertCapabilitiesTitle: "\u80fd\u529b",
expertReady: "\u5df2\u5c31\u7eea",
activeExpert: "\u5f53\u524d\u4e13\u5bb6",
starterQuestionsHint: "\u70b9\u51fb\u95ee\u9898\u540e\u4f1a\u5148\u586b\u5165\u8f93\u5165\u6846\uff0c\u4f60\u53ef\u4ee5\u7ee7\u7eed\u8865\u5145\u540e\u518d\u53d1\u9001\u3002",
taskPlaceholder: "\u8f93\u5165\u4f60\u7684\u9700\u6c42\uff0cEnter \u53d1\u9001\uff0cShift+Enter \u6362\u884c\u3002",
taskDisabledPlaceholder: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u540e\u5f00\u59cb\u5bf9\u8bdd\u3002",
send: "\u53d1\u9001",
sending: "\u53d1\u9001\u4e2d...",
preparingChat: "\u6b63\u5728\u51c6\u5907\u5bf9\u8bdd\u73af\u5883",
preparingChatHint: "\u670d\u52a1\u5c31\u7eea\u540e\u5373\u53ef\u53d1\u9001\u9996\u6761\u6d88\u606f\u3002",
preparing: "\u51c6\u5907\u4e2d...",
generating: "\u56de\u7b54\u4e2d...",
thinking: "\u6b63\u5728\u6574\u7406\u7b54\u6848",
startupTitle: "\u6b63\u5728\u51c6\u5907\u5343\u5320Claw",
startupDesc: "\u9996\u6b21\u8fdb\u5165\u4f1a\u5148\u5b8c\u6210\u5fc5\u8981\u51c6\u5907\uff0c\u5b8c\u6210\u540e\u81ea\u52a8\u8fdb\u5165\u5bf9\u8bdd\u3002",
startupReadySoon: "\u51c6\u5907\u5b8c\u6210\u540e\u5c06\u81ea\u52a8\u8fdb\u5165\u5bf9\u8bdd\u3002",
startupBooting: "\u6b63\u5728\u52a0\u8f7d\u5e94\u7528\u4fe1\u606f\u3002",
startupRetry: "\u91cd\u65b0\u51c6\u5907",
openSettings: "\u6253\u5f00\u8bbe\u7f6e",
startupLoadLocal: "\u8bfb\u53d6\u672c\u5730\u914d\u7f6e",
startupPrepareRuntime: "\u51c6\u5907\u672c\u5730\u52a9\u624b",
startupConnectService: "\u8fde\u63a5\u804a\u5929\u670d\u52a1",
startupEnterChat: "\u8fdb\u5165\u5bf9\u8bdd",
projectSwitcherLabel: "\u5f53\u524d\u9879\u76ee",
projectSessionsLabel: "\u4f1a\u8bdd",
newSession: "\u65b0\u5efa\u4f1a\u8bdd",
closeSession: "\u5173\u95ed\u4f1a\u8bdd",
traceTitle: "\u601d\u8003\u8fc7\u7a0b",
hideTrace: "\u6536\u8d77\u8be6\u60c5",
traceEmpty: "\u8fd8\u6ca1\u6709\u53ef\u663e\u793a\u7684\u8fdb\u5ea6\u3002",
traceCollapsed: "\u67e5\u770b\u601d\u8003\u8fc7\u7a0b",
preparingReply: "\u6b63\u5728\u7406\u89e3\u4f60\u7684\u95ee\u9898",
checkingChat: "\u6b63\u5728\u68c0\u67e5\u5bf9\u8bdd\u73af\u5883",
startingRuntime: "\u6b63\u5728\u542f\u52a8\u672c\u5730\u52a9\u624b",
connectingGateway: "\u6b63\u5728\u8fde\u63a5\u670d\u52a1",
waitingReply: "\u5df2\u6536\u5230\u95ee\u9898\uff0c\u6b63\u5728\u7ec4\u7ec7\u56de\u7b54",
checkingChatDetail: "\u9996\u6b21\u63d0\u95ee\u65f6\u4f1a\u5148\u68c0\u67e5\u5f53\u524d\u5bf9\u8bdd\u73af\u5883\u3002",
startingRuntimeDetail: "\u9996\u6b21\u542f\u52a8\u672c\u5730\u52a9\u624b\u53ef\u80fd\u9700\u8981\u51e0\u79d2\u949f\u3002",
connectingGatewayDetail: "\u6b63\u5728\u5efa\u7acb\u4e0e\u804a\u5929\u670d\u52a1\u7684\u8fde\u63a5\u3002",
replyStarted: "\u5df2\u5f00\u59cb\u6574\u7406\u56de\u7b54",
fallbackReply: "\u5b9e\u65f6\u8f93\u51fa\u6682\u4e0d\u53ef\u7528\uff0c\u6b63\u5728\u6574\u7406\u5b8c\u6574\u7b54\u590d",
fallbackComplete: "\u5df2\u751f\u6210\u5b8c\u6574\u7b54\u590d",
replyReady: "\u56de\u7b54\u5df2\u5b8c\u6210",
replyFailed: "回复失败",
suggestionSwitchTitle: "这个任务更适合在对应专家中完成",
suggestionSwitchPrefix: "切换到",
suggestionSwitchSuffix: "后,可继续获得更贴近平台的内容策划、文案与运营支持。",
suggestionContinue: "继续在首页完成",
suggestionDismiss: "暂不切换",
suggestionSwitchAction: "切换并继续",
saveSuccessPending: "\u5458\u5de5\u5bc6\u94a5\u5df2\u4fdd\u5b58\uff0c\u6b63\u5728\u540c\u6b65\u8fd0\u884c\u65f6\u914d\u7f6e\u3002",
saveSuccessApplied: "\u6a21\u578b\u914d\u7f6e\u5df2\u4fdd\u5b58\uff0c\u65b0\u7684\u914d\u7f6e\u5c06\u5728\u540e\u7eed\u6267\u884c\u4e2d\u751f\u6548\u3002",
copy: "\u590d\u5236",
copied: "\u5df2\u590d\u5236",
bindFirst: "\u8bf7\u5148\u7ed1\u5b9a",
bindFirstError: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u540e\u518d\u53d1\u9001\u6d88\u606f\u3002",
startingHint: "\u6b63\u5728\u51c6\u5907\u8fd0\u884c\u73af\u5883\uff0c\u8bf7\u7a0d\u5019\u3002",
chatNotReadyError: "\u5f53\u524d\u804a\u5929\u6682\u4e0d\u53ef\u7528\uff0c\u8bf7\u68c0\u67e5\u8fd0\u884c\u65f6\u72b6\u6001\u3002",
noSkillCards: "\u5f53\u524d\u4e13\u5bb6\u8fd8\u6ca1\u6709\u53ef\u7528\u80fd\u529b\u3002",
pluginTitle: "\u63d2\u4ef6\u5217\u8868",
noPlugins: "\u5f53\u524d\u6ca1\u6709\u53ef\u7528\u63d2\u4ef6\u3002",
settingsTitle: "\u8bbe\u7f6e",
settingsDesc: "\u914d\u7f6e\u8fd0\u884c\u65f6\u3001\u5bc6\u94a5\u548c\u5de5\u4f5c\u76ee\u5f55\u3002",
chatPageDesc: "\u5728\u8fd9\u91cc\u4e0e\u4e13\u5bb6\u5b8c\u6210\u5bf9\u8bdd\u548c\u534f\u4f5c\u3002",
skillsPageDesc: "\u67e5\u770b\u5f53\u524d\u4e13\u5bb6\u5df2\u51c6\u5907\u597d\u7684\u80fd\u529b\u3002",
pluginsPageDesc: "\u67e5\u770b\u5f53\u524d\u5df2\u542f\u7528\u7684\u63d2\u4ef6\u80fd\u529b\u3002",
workspacePath: "\u5de5\u4f5c\u76ee\u5f55",
save: "\u4fdd\u5b58",
saving: "\u4fdd\u5b58\u4e2d...",
diagnostics: "\u8bca\u65ad",
diagnosticsDesc: "\u67e5\u770b\u8fd0\u884c\u65f6\u3001\u7f51\u5173\u548c\u5b89\u88c5\u4fe1\u606f\u3002",
export: "\u5bfc\u51fa\u8bca\u65ad",
exported: "\u8bca\u65ad\u5df2\u5bfc\u51fa\uff1a",
currentBinding: "\u5f53\u524d\u7ed1\u5b9a",
none: "\u65e0"
} as const;
export const homeChatCopy = {
title: "首页对话",
microcopy: "从一个实用小任务开始:整理 Excel、汇总资料、检索信息,或把需求拆成执行清单。",
emptyTitle: " 先选一个能立刻开始的小任务",
// emptyDesc: "首页适合做 4 类事:Excel 整理、资料汇总、公开信息检索、需求拆解;明确要做小红书或抖音内容时,再切到对应专家继续处理",
prompts: [
"我想做海报内容,请帮我先整理主题、卖点层级、标题和版面文案",
"我想做 GEO 方向内容,请先帮我明确目标、策略框架和执行重点",
"我想做平台精准线索获取,请帮我梳理目标人群、线索标准、触达话术和转化路径",
"我想以销售冠军的方式推进成交,请帮我梳理目标客户、沟通话术、异议处理和转化动作"
]
} as const;
export const expertsPageCopy = {
title: "专家页",
noExperts: "当前还没有可用专家,先在首页直接对话即可。"
} as const;
import { useCallback, useEffect, useRef, useState } from "react"
import type {
AppConfig,
ChatLaunchState,
DesktopApi,
ExpertDefinition,
GatewayHealth,
GatewayStatus,
RuntimeCloudStatus,
RuntimeStatus,
RuntimeTelemetryStatus,
SystemSummary,
WorkspaceSummary
} from "@qjclaw/shared-types"
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge"
interface BootstrapSkill {
id: string
}
interface UseAppBootstrapOptions {
desktopApi: DesktopApi
selectedSkillId: string
defaultSkill: BootstrapSkill
viewMode: ViewMode
startupCopy: {
startingHint: string
chatNotReadyError: string
startupBooting: string
}
setSelectedSkillId(skillId: string): void
setErrorText(value: string): void
normalizeError(value: unknown): string
}
export function canExchangeMessages(
workspace: WorkspaceSummary | null,
runtimeStatus: RuntimeStatus | null,
gatewayStatus: GatewayStatus | null
) {
if (!workspace?.chatReady || !runtimeStatus || gatewayStatus?.state !== "connected") {
return false
}
return runtimeStatus.activeMode === "external-gateway" || runtimeStatus.processState === "running"
}
export function useAppBootstrap({
desktopApi,
selectedSkillId,
defaultSkill,
viewMode,
startupCopy,
setSelectedSkillId,
setErrorText,
normalizeError
}: UseAppBootstrapOptions) {
const [config, setConfig] = useState<AppConfig | null>(null)
const [workspace, setWorkspace] = useState<WorkspaceSummary | null>(null)
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeStatus | null>(null)
const [runtimeCloudStatus, setRuntimeCloudStatus] = useState<RuntimeCloudStatus | null>(null)
const [runtimeTelemetry, setRuntimeTelemetry] = useState<RuntimeTelemetryStatus | null>(null)
const [systemSummary, setSystemSummary] = useState<SystemSummary | null>(null)
const [expertDefinitions, setExpertDefinitions] = useState<ExpertDefinition[]>([])
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null)
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null)
const [refreshing, setRefreshing] = useState(false)
const startupWarmupRequestedRef = useRef(false)
const selectedSkillIdRef = useRef(selectedSkillId)
useEffect(() => {
selectedSkillIdRef.current = selectedSkillId
}, [selectedSkillId])
const refresh = useCallback(async (clearError = true): Promise<WorkspaceSummary | null> => {
setRefreshing(true)
if (clearError) {
setErrorText("")
}
try {
const [nextConfig, nextRuntime, nextCloud, nextTelemetry, nextSystem, nextWorkspace, nextExperts] = await Promise.all([
desktopApi.config.load(),
desktopApi.runtime.getStatus(),
desktopApi.runtimeCloud.getStatus(),
desktopApi.runtimeTelemetry.getStatus(),
desktopApi.system.getSummary(),
desktopApi.workspace.getSummary(),
desktopApi.experts.list().catch(() => [])
])
const statusResult = nextWorkspace.apiKeyConfigured
? await desktopApi.gateway.status().catch(() => null)
: null
setConfig(nextConfig)
setWorkspace(nextWorkspace)
setRuntimeStatus(nextRuntime)
setRuntimeCloudStatus(nextCloud)
setRuntimeTelemetry(nextTelemetry)
setSystemSummary(nextSystem)
setExpertDefinitions(nextExperts)
setGatewayStatus(statusResult)
const nextReadySkills = nextWorkspace.skills.filter((skill) => skill.ready)
const nextSkills = nextReadySkills.length ? [defaultSkill, ...nextReadySkills] : [defaultSkill]
if (!nextSkills.some((skill) => skill.id === selectedSkillIdRef.current)) {
setSelectedSkillId(nextSkills[0].id)
}
if (canExchangeMessages(nextWorkspace, nextRuntime, statusResult)) {
setGatewayHealth(await desktopApi.gateway.health().catch(() => null))
} else {
setGatewayHealth(null)
}
return nextWorkspace
} catch (error) {
setErrorText(normalizeError(error))
return null
} finally {
setRefreshing(false)
}
}, [defaultSkill, desktopApi, normalizeError, setErrorText, setSelectedSkillId])
const shellReady = workspace?.shellReady ?? false
const bindingRequired = workspace?.bindingRequired ?? !Boolean(workspace?.apiKeyConfigured ?? config?.apiKeyConfigured)
const isBound = !bindingRequired
const chatLaunchState: ChatLaunchState = workspace?.chatLaunchState ?? (!bindingRequired ? "starting" : "unbound")
const chatStatusMessage = workspace?.chatStatusMessage
?? (chatLaunchState === "starting" ? startupCopy.startingHint : chatLaunchState === "error" ? startupCopy.chatNotReadyError : "")
const startupMessage = workspace?.startupMessage ?? ((refreshing && !workspace) ? startupCopy.startupBooting : chatStatusMessage)
const startupPhase = workspace?.startupPhase ?? ((refreshing && !workspace) ? "syncing-config" : "idle")
const startupStateActive = viewMode !== "settings" && ((refreshing && !workspace) || !shellReady || (isBound && chatLaunchState !== "ready"))
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
const shouldPollStartupState = viewMode !== "settings"
&& startupStateActive
&& (chatLaunchState === "starting" || (!isBound && !shellReady))
if (!shouldPollStartupState) {
return
}
let cancelled = false
let timer: number | undefined
const pollWorkspace = async () => {
const nextWorkspace = await refresh(false)
if (cancelled) {
return
}
const nextShouldPoll = nextWorkspace != null && (
nextWorkspace.chatLaunchState === "starting"
|| (!nextWorkspace.shellReady && nextWorkspace.bindingRequired)
)
if (nextShouldPoll) {
timer = window.setTimeout(() => {
void pollWorkspace()
}, 1000)
}
}
timer = window.setTimeout(() => {
void pollWorkspace()
}, 800)
return () => {
cancelled = true
if (typeof timer !== "undefined") {
window.clearTimeout(timer)
}
}
}, [chatLaunchState, isBound, refresh, shellReady, startupStateActive, viewMode])
useEffect(() => {
const shouldRequestStartupWarmup = startupStateActive && (
(isBound && chatLaunchState === "starting")
|| (!isBound && !shellReady)
)
if (!shouldRequestStartupWarmup) {
startupWarmupRequestedRef.current = false
return
}
if (startupWarmupRequestedRef.current) {
return
}
startupWarmupRequestedRef.current = true
void desktopApi.workspace.warmup().catch(() => undefined)
}, [chatLaunchState, desktopApi, isBound, shellReady, startupStateActive])
return {
config,
setConfig,
workspace,
setWorkspace,
runtimeStatus,
setRuntimeStatus,
runtimeCloudStatus,
runtimeTelemetry,
setRuntimeTelemetry,
systemSummary,
expertDefinitions,
gatewayStatus,
setGatewayStatus,
gatewayHealth,
refreshing,
refresh,
shellReady,
bindingRequired,
isBound,
chatLaunchState,
chatStatusMessage,
startupMessage,
startupPhase,
startupStateActive
}
}
interface BindEntryProps {
lobsterKeyDraft: string
workspaceApiKeyConfigured: boolean
saving: boolean
bindingLabel: string
onLobsterKeyChange: (value: string) => void
onSave: () => void
}
export function BindEntry({
lobsterKeyDraft,
workspaceApiKeyConfigured,
saving,
bindingLabel,
onLobsterKeyChange,
onSave
}: BindEntryProps) {
return (
<div className="bind-entry bind-entry-elevated">
<div className="bind-entry-copy">
<span className="bind-entry-kicker">Lobster Key</span>
<strong>绑定龙虾密钥</strong>
<p>输入龙虾密钥后即可完成身份绑定,同步工作区配置并解锁专家对话、插件协作与运行时能力。</p>
</div>
<div className="bind-entry-benefits" aria-label="绑定后可用能力">
<span>同步聊天配置</span>
<span>启用专家与插件</span>
<span>进入桌面协作工作台</span>
</div>
<div className="bind-row">
<input
type="password"
value={lobsterKeyDraft}
placeholder={workspaceApiKeyConfigured ? "输入新龙虾密钥以更新绑定" : "请输入龙虾密钥"}
onChange={(event) => onLobsterKeyChange(event.target.value)}
/>
<button disabled={saving || lobsterKeyDraft.trim().length === 0} onClick={onSave}>
{saving ? bindingLabel : "立即绑定"}
</button>
</div>
</div>
)
}
import type {
ChangeEvent,
CSSProperties,
DragEvent as ReactDragEvent,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
ReactNode,
RefObject
} from "react"
import type { ChatAttachment } from "@qjclaw/shared-types"
interface ComposerSkill {
id: string
name: string
description: string
}
interface ChatComposerProps {
prompt: string
isBound: boolean
sending: boolean
canSend: boolean
isDragOver: boolean
isResizeActive: boolean
viewMode: "chat" | "experts" | "plugins" | "settings" | "knowledge"
shellStyle: CSSProperties
attachmentInputRef: RefObject<HTMLInputElement | null>
skillMenuRef: RefObject<HTMLDivElement | null>
attachmentAccept: string
attachments: ChatAttachment[]
placeholder: string
sendButtonLabel: string
skillMenuTitle: string
defaultChatLabel: string
defaultSkillId: string
selectedSkillId: string
selectedSkillName: string
skills: ComposerSkill[]
skillMenuOpen: boolean
attachmentIcon: ReactNode
submitIcon: ReactNode
onSubmit: () => void | Promise<void>
onPromptChange: (value: string) => void
onTextareaKeyDown: (event: ReactKeyboardEvent<HTMLTextAreaElement>) => void | Promise<void>
onAttachmentSelection: (event: ChangeEvent<HTMLInputElement>) => void
onOpenAttachmentPicker: () => void
onRemoveAttachment: (localPath: string) => void
onToggleSkillMenu: () => void
onClearSelectedSkill: () => void
onChooseSkill: (skillId: string) => void
onDragEnter: (event: ReactDragEvent<HTMLFormElement>) => void
onDragOver: (event: ReactDragEvent<HTMLFormElement>) => void
onDragLeave: (event: ReactDragEvent<HTMLFormElement>) => void
onDrop: (event: ReactDragEvent<HTMLFormElement>) => void
onResizePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
onResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void
onResizePointerEnd: (event: ReactPointerEvent<HTMLDivElement>) => void
}
export function ChatComposer({
prompt,
isBound,
sending,
canSend,
isDragOver,
isResizeActive,
viewMode,
shellStyle,
attachmentInputRef,
skillMenuRef,
attachmentAccept,
attachments,
placeholder,
sendButtonLabel,
skillMenuTitle,
defaultChatLabel,
defaultSkillId,
selectedSkillId,
selectedSkillName,
skills,
skillMenuOpen,
attachmentIcon,
submitIcon,
onSubmit,
onPromptChange,
onTextareaKeyDown,
onAttachmentSelection,
onOpenAttachmentPicker,
onRemoveAttachment,
onToggleSkillMenu,
onClearSelectedSkill,
onChooseSkill,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
onResizePointerDown,
onResizePointerMove,
onResizePointerEnd
}: ChatComposerProps) {
return (
<form
className={
"composer-shell"
+ (isDragOver ? " dragging" : "")
+ (isResizeActive ? " resizing" : "")
+ (viewMode === "chat" ? " composer-shell-home" : "")
+ (viewMode === "experts" ? " composer-shell-expert" : "")
}
style={shellStyle}
onSubmit={(event) => {
event.preventDefault()
void onSubmit()
}}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<input
ref={attachmentInputRef}
className="composer-attachment-input"
type="file"
accept={attachmentAccept}
multiple
tabIndex={-1}
onChange={onAttachmentSelection}
/>
{isDragOver ? <div className="composer-drop-indicator">释放以上传附件</div> : null}
<div
className="composer-resize-handle"
role="separator"
aria-orientation="horizontal"
aria-label={"\u8c03\u6574\u8f93\u5165\u6846\u9ad8\u5ea6"}
title={"\u8c03\u6574\u8f93\u5165\u6846\u9ad8\u5ea6"}
onPointerDown={onResizePointerDown}
onPointerMove={onResizePointerMove}
onPointerUp={onResizePointerEnd}
onPointerCancel={onResizePointerEnd}
>
<span aria-hidden="true" />
</div>
<div className="composer-surface">
<label className="composer-field">
<textarea
value={prompt}
disabled={!isBound}
onChange={(event) => onPromptChange(event.target.value)}
onKeyDown={(event) => void onTextareaKeyDown(event)}
placeholder={placeholder}
className="composer-textarea"
/>
</label>
{attachments.length ? (
<div className="composer-attachment-strip">
{attachments.map((attachment) => (
<span key={attachment.localPath} className="composer-attachment-chip">
<span className="composer-attachment-chip-label">{attachment.name}</span>
<button type="button" className="composer-attachment-remove" onClick={() => onRemoveAttachment(attachment.localPath)} aria-label={`移除附件 ${attachment.name}`}>
x
</button>
</span>
))}
</div>
) : null}
<div className="composer-footer">
<div className="composer-left-tools" ref={skillMenuRef}>
<button type="button" className="attachment-trigger icon-only" disabled={!isBound || sending} onClick={onOpenAttachmentPicker} aria-label="上传附件" title="上传附件">
{attachmentIcon}
</button>
<button type="button" className="skill-trigger" disabled={!isBound} aria-label={skillMenuTitle} aria-expanded={skillMenuOpen} onClick={onToggleSkillMenu}>
@
</button>
{selectedSkillId !== defaultSkillId ? (
<button type="button" className="skill-chip" disabled={!isBound} onClick={onClearSelectedSkill}>
{"@" + selectedSkillName}
</button>
) : null}
{skillMenuOpen ? (
<div className="skill-menu" role="menu" aria-label={skillMenuTitle}>
{skills.map((skill) => {
const active = skill.id === selectedSkillId
return (
<button
key={skill.id}
type="button"
role="menuitemradio"
aria-checked={active}
className={"skill-menu-item" + (active ? " active" : "")}
onClick={() => onChooseSkill(skill.id)}
>
<strong>{skill.id === defaultSkillId ? defaultChatLabel : "@" + skill.name}</strong>
<span>{skill.description}</span>
</button>
)
})}
</div>
) : null}
</div>
<button
type="submit"
className={"composer-submit" + (sending ? " is-busy" : "")}
disabled={!canSend}
aria-label={sendButtonLabel}
title={sendButtonLabel}
>
{sending ? <span className="composer-submit-spinner" aria-hidden="true" /> : submitIcon}
<span className="visually-hidden">{sendButtonLabel}</span>
</button>
</div>
<p className="composer-hint">按 Enter 发送,Shift + Enter 换行</p>
</div>
</form>
)
}
import type { ReactNode, RefObject } from "react"
interface ChatWorkspaceProps {
panelLead: ReactNode
panelActions: ReactNode
workspaceRef: RefObject<HTMLDivElement | null>
statusNotice: ReactNode
intentNotice: ReactNode
bodyContent: ReactNode
composerContent: ReactNode
}
export function ChatWorkspace({
panelLead,
panelActions,
workspaceRef,
statusNotice,
intentNotice,
bodyContent,
composerContent
}: ChatWorkspaceProps) {
return (
<section className="chat-panel conversation-panel">
<div className="conversation-panel-head conversation-panel-head-layout app-drag-region">
<div className="conversation-panel-copy">
{panelLead}
</div>
<div className="conversation-drag-strip" aria-hidden="true" />
<div className="conversation-panel-actions app-no-drag">
{panelActions}
</div>
</div>
<div className="conversation-workspace" ref={workspaceRef}>
{statusNotice}
{intentNotice}
<div className="conversation-panel-body">
{bodyContent}
</div>
{composerContent}
</div>
</section>
)
}
import type { ChatLaunchState, ProjectIntentSuggestion } from "@qjclaw/shared-types"
import type { ReactNode } from "react"
interface ConversationStatusNoticeProps {
show: boolean
chatLaunchState: ChatLaunchState
status: string
}
export function ConversationStatusNotice({ show, chatLaunchState, status }: ConversationStatusNoticeProps) {
if (!show) {
return null
}
return (
<div className={"notice" + (chatLaunchState === "error" ? " error" : " toast-notice")} role={chatLaunchState === "error" ? "alert" : "status"} aria-live="polite">
{status}
</div>
)
}
interface HomeIntentSuggestionNoticeProps {
suggestion: ProjectIntentSuggestion | null
decisionPending: boolean
labels: {
title: string
prefix: string
suffix: string
continue: string
switchAction: string
}
renderIcon: (projectId: string) => ReactNode
onContinue: () => void | Promise<unknown>
onSwitch: () => void | Promise<unknown>
}
export function HomeIntentSuggestionNotice({
suggestion,
decisionPending,
labels,
renderIcon,
onContinue,
onSwitch
}: HomeIntentSuggestionNoticeProps) {
if (!suggestion) {
return null
}
return (
<div className="home-intent-suggestion" role="status" aria-live="polite">
<div className="home-intent-suggestion-leading" aria-hidden="true">
<span className={"home-intent-suggestion-icon home-intent-suggestion-icon-" + suggestion.projectId}>
{renderIcon(suggestion.projectId)}
</span>
</div>
<div className="home-intent-suggestion-copy">
<strong>{labels.title}</strong>
<p>
{labels.prefix}
{suggestion.projectDisplayName}
{labels.suffix}
</p>
</div>
<div className="home-intent-suggestion-actions">
<button type="button" className="secondary" disabled={decisionPending} onClick={() => void onContinue()}>
{labels.continue}
</button>
<button type="button" disabled={decisionPending} onClick={() => void onSwitch()}>
{labels.switchAction}
</button>
</div>
</div>
)
}
This diff is collapsed.
import type { ReactNode } from "react"
interface RenderMarkdownContentOptions {
messageId: string
copiedToken: string
copyIcon: ReactNode
copiedIcon: ReactNode
onCopy: (token: string, text: string) => void | Promise<void>
}
function formatCodeLanguageLabel(language: string): string {
const normalized = language.trim().toLowerCase()
if (!normalized) {
return "Text"
}
const aliases: Record<string, string> = {
js: "JavaScript",
ts: "TypeScript",
jsx: "React JSX",
tsx: "React TSX",
py: "Python",
sh: "Shell",
yml: "YAML",
md: "Markdown"
}
return aliases[normalized] ?? normalized.charAt(0).toUpperCase() + normalized.slice(1)
}
function renderMarkdownInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = []
const pattern = /`([^`]+)`|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\n]+)\*|_([^_\n]+)_/g
let cursor = 0
let match: RegExpExecArray | null
let tokenIndex = 0
while ((match = pattern.exec(text)) !== null) {
if (match.index > cursor) {
nodes.push(text.slice(cursor, match.index))
}
const key = `${keyPrefix}-${tokenIndex}`
if (match[1]) {
nodes.push(<code key={key} className="markdown-inline-code">{match[1]}</code>)
} else if (match[2] && match[3]) {
nodes.push(
<a key={key} href={match[3]} target="_blank" rel="noreferrer" className="markdown-link">
{renderMarkdownInline(match[2], `${key}-link`)}
</a>
)
} else if (match[4] || match[5]) {
nodes.push(<strong key={key}>{renderMarkdownInline(match[4] || match[5] || "", `${key}-strong`)}</strong>)
} else if (match[6] || match[7]) {
nodes.push(<em key={key}>{renderMarkdownInline(match[6] || match[7] || "", `${key}-em`)}</em>)
}
cursor = pattern.lastIndex
tokenIndex += 1
}
if (cursor < text.length) {
nodes.push(text.slice(cursor))
}
return nodes
}
function renderParagraphLines(lines: string[], keyPrefix: string): ReactNode[] {
return lines.flatMap((line, lineIndex) => [
lineIndex > 0 ? <br key={`${keyPrefix}-br-${lineIndex}`} /> : null,
...renderMarkdownInline(line, `${keyPrefix}-line-${lineIndex}`)
])
}
function renderMarkdownHeading(level: number, key: string, children: ReactNode[]) {
switch (level) {
case 1:
return <h1 key={key}>{children}</h1>
case 2:
return <h2 key={key}>{children}</h2>
case 3:
return <h3 key={key}>{children}</h3>
case 4:
return <h4 key={key}>{children}</h4>
case 5:
return <h5 key={key}>{children}</h5>
default:
return <h6 key={key}>{children}</h6>
}
}
function renderMarkdownBlocks(text: string, keyPrefix: string): ReactNode[] {
const normalized = text.replace(/\r\n?/g, "\n")
const lines = normalized.split("\n")
const blocks: ReactNode[] = []
let index = 0
const isBlockBoundary = (line: string) => {
const trimmed = line.trim()
return !trimmed
|| /^#{1,6}\s+/.test(trimmed)
|| /^>\s?/.test(trimmed)
|| /^[-*_]{3,}\s*$/.test(trimmed)
|| /^\s*[-*]\s+/.test(trimmed)
|| /^\s*\d+\.\s+/.test(trimmed)
}
while (index < lines.length) {
const current = lines[index]
const trimmed = current.trim()
if (!trimmed) {
index += 1
continue
}
const headingMatch = /^(#{1,6})\s+(.*)$/.exec(trimmed)
if (headingMatch) {
const level = Math.min(headingMatch[1].length, 6)
blocks.push(
renderMarkdownHeading(
level,
`${keyPrefix}-heading-${index}`,
renderMarkdownInline(headingMatch[2], `${keyPrefix}-heading-${index}`)
)
)
index += 1
continue
}
if (/^[-*_]{3,}\s*$/.test(trimmed)) {
blocks.push(<hr key={`${keyPrefix}-rule-${index}`} className="markdown-rule" />)
index += 1
continue
}
if (/^>\s?/.test(trimmed)) {
const quoteLines: string[] = []
while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
quoteLines.push(lines[index].replace(/^\s*>\s?/, ""))
index += 1
}
blocks.push(
<blockquote key={`${keyPrefix}-quote-${index}`} className="markdown-quote">
<p>{renderParagraphLines(quoteLines, `${keyPrefix}-quote-${index}`)}</p>
</blockquote>
)
continue
}
if (/^\s*[-*]\s+/.test(trimmed)) {
const items: string[] = []
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index].trim())) {
items.push(lines[index].replace(/^\s*[-*]\s+/, ""))
index += 1
}
blocks.push(
<ul key={`${keyPrefix}-ul-${index}`} className="markdown-list markdown-list-unordered">
{items.map((item, itemIndex) => (
<li key={`${keyPrefix}-ul-${index}-${itemIndex}`}>{renderMarkdownInline(item, `${keyPrefix}-ul-item-${itemIndex}`)}</li>
))}
</ul>
)
continue
}
if (/^\s*\d+\.\s+/.test(trimmed)) {
const items: string[] = []
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index].trim())) {
items.push(lines[index].replace(/^\s*\d+\.\s+/, ""))
index += 1
}
blocks.push(
<ol key={`${keyPrefix}-ol-${index}`} className="markdown-list markdown-list-ordered">
{items.map((item, itemIndex) => (
<li key={`${keyPrefix}-ol-${index}-${itemIndex}`}>{renderMarkdownInline(item, `${keyPrefix}-ol-item-${itemIndex}`)}</li>
))}
</ol>
)
continue
}
const paragraphLines: string[] = []
while (index < lines.length && !isBlockBoundary(lines[index])) {
paragraphLines.push(lines[index])
index += 1
}
blocks.push(
<p key={`${keyPrefix}-p-${index}`} className="markdown-paragraph">
{renderParagraphLines(paragraphLines, `${keyPrefix}-p-${index}`)}
</p>
)
}
return blocks
}
export function renderMarkdownContent(content: string, options: RenderMarkdownContentOptions): ReactNode[] {
const normalized = content.replace(/\r\n?/g, "\n")
const blocks: ReactNode[] = []
const fencePattern = /```([^\n`]*)\n([\s\S]*?)```/g
let cursor = 0
let blockIndex = 0
let match: RegExpExecArray | null
while ((match = fencePattern.exec(normalized)) !== null) {
const proseBeforeFence = normalized.slice(cursor, match.index)
if (proseBeforeFence.trim()) {
blocks.push(...renderMarkdownBlocks(proseBeforeFence, `${options.messageId}-md-${blockIndex}`))
blockIndex += 1
}
const language = (match[1] || "").trim().split(/\s+/)[0] || ""
const code = match[2].replace(/\n$/, "")
const copyToken = `message:${options.messageId}:code:${blockIndex}`
blocks.push(
<div key={`${options.messageId}-code-${blockIndex}`} className="markdown-code-block">
<div className="markdown-code-toolbar">
<span>{formatCodeLanguageLabel(language)}</span>
<button
type="button"
className={"markdown-code-copy" + (options.copiedToken === copyToken ? " copied" : "")}
aria-label="复制代码"
title="复制代码"
onClick={() => void options.onCopy(copyToken, code)}
>
{options.copiedToken === copyToken ? options.copiedIcon : options.copyIcon}
</button>
</div>
<pre className="markdown-code-pre">
<code>{code}</code>
</pre>
</div>
)
cursor = match.index + match[0].length
blockIndex += 1
}
const trailingProse = normalized.slice(cursor)
if (trailingProse.trim()) {
blocks.push(...renderMarkdownBlocks(trailingProse, `${options.messageId}-md-${blockIndex}`))
}
return blocks.length > 0
? blocks
: [
<p key={`${options.messageId}-md-empty`} className="markdown-paragraph">
{renderParagraphLines([normalized], `${options.messageId}-md-empty`)}
</p>
]
}
import type { ChatMessage } from "@qjclaw/shared-types"
import type { ReactNode, RefObject, UIEvent } from "react"
import { getTraceLineClassName, getTraceLineLabels } from "./messageTraceDisplay"
import type { MessageTraceState } from "./useMessageTraces"
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge"
type MessageReaction = "up" | "down"
export type ExpertKey = "xiaohongshu" | "douyin" | "browser" | "general"
export type MessageListMessage = ChatMessage & {
streamState?: "streaming" | "error"
statusLabel?: string
statusDetail?: string
}
interface VideoStatusCardContent {
title: string
meta?: string
hint?: string
}
interface MessageListLabels {
thinking: string
hideTrace: string
traceCollapsed: string
}
interface MessageListProps {
messages: MessageListMessage[]
viewMode: ViewMode
showBindEntry: boolean
showEmptyState: boolean
emptyState: ReactNode
messageListRef: RefObject<HTMLDivElement | null>
messageTraces: Record<string, MessageTraceState>
messageReactions: Record<string, MessageReaction | undefined>
copiedToken: string
sending: boolean
activeExpertKey: ExpertKey
labels: MessageListLabels
copyIcon: ReactNode
copiedIcon: ReactNode
regenerateIcon: ReactNode
renderThumbIcon: (direction: MessageReaction) => ReactNode
renderMarkdownContent: (
content: string,
options: {
messageId: string
copiedToken: string
onCopy: (token: string, text: string) => void | Promise<void>
}
) => ReactNode[]
buildDouyinVideoStatusCard: (message: MessageListMessage, expertKey: ExpertKey) => VideoStatusCardContent | null
formatMessageTimestamp: (value: string) => string
onScroll: (event: UIEvent<HTMLDivElement>) => void
onCopyText: (token: string, text: string) => void | Promise<void>
onTraceExpandedChange: (messageId: string, expanded: boolean) => void
onRegenerateAssistantMessage: (messageId: string) => void | Promise<void>
onToggleMessageReaction: (messageId: string, reaction: MessageReaction) => void
}
export function MessageList({
messages,
viewMode,
showBindEntry,
showEmptyState,
emptyState,
messageListRef,
messageTraces,
messageReactions,
copiedToken,
sending,
activeExpertKey,
labels,
copyIcon,
copiedIcon,
regenerateIcon,
renderThumbIcon,
renderMarkdownContent,
buildDouyinVideoStatusCard,
formatMessageTimestamp,
onScroll,
onCopyText,
onTraceExpandedChange,
onRegenerateAssistantMessage,
onToggleMessageReaction
}: MessageListProps) {
return (
<div ref={messageListRef} onScroll={onScroll} className={"message-list chat-scroll-smooth" + (viewMode === "chat" ? " message-list-home" : "")}>
{messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim()
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null
const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined
const hasTrace = Boolean(messageTrace?.items.length)
const isTraceExpanded = Boolean(messageTrace?.expanded)
const canCopyMessage = Boolean(message.content.trim())
const copyToken = `message:${message.id}`
const reaction = messageReactions[message.id]
void reaction
return (
<article key={message.id} className={"message-card group " + message.role + (message.streamState ? " " + message.streamState : "")}>
<div className={"message-bubble" + (message.role === "assistant" ? " message-bubble-assistant" : " message-bubble-user")}>
{showThinking ? (
videoStatusCard ? (
<div className="generation-status-card" aria-live="polite">
<div className="generation-status-leading">
<span className="thinking-spinner generation-status-spinner" aria-hidden="true" />
</div>
<div className="generation-status-body">
<span className="generation-status-kicker">抖音专家执行中</span>
<strong className="generation-status-title">{videoStatusCard.title}</strong>
{videoStatusCard.meta ? <span className="generation-status-meta">{videoStatusCard.meta}</span> : null}
{videoStatusCard.hint ? <span className="generation-status-hint">{videoStatusCard.hint}</span> : null}
<span className="generation-status-progress" aria-hidden="true" />
</div>
</div>
) : (
<div className="thinking-indicator" aria-live="polite">
<span className="thinking-spinner" aria-hidden="true" />
<span className="thinking-label">{message.statusLabel ?? labels.thinking}</span>
{message.statusDetail ? <span className="thinking-detail">{message.statusDetail}</span> : null}
</div>
)
) : message.content ? (
message.role === "assistant" ? (
<div className="markdown-body">
{renderMarkdownContent(message.content, {
messageId: message.id,
copiedToken,
onCopy: onCopyText
})}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</div>
) : (
<p className="message-plain-text">
{message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p>
)
) : null}
{hasTrace ? (
<div className="message-trace">
<button type="button" className="trace-inline-toggle" onClick={() => onTraceExpandedChange(message.id, !isTraceExpanded)}>
{isTraceExpanded ? labels.hideTrace : labels.traceCollapsed}
</button>
{isTraceExpanded ? (
<div className="message-trace-content">
{messageTrace?.items.map((item) => {
const traceLabels = getTraceLineLabels(item)
return (
<p key={item.id} className={getTraceLineClassName(item.tone)}>
<span className="message-trace-time">{traceLabels.time}</span>
<span className="message-trace-text">{traceLabels.label}</span>
{traceLabels.detail ? <span className="message-trace-detail">{traceLabels.detail}</span> : null}
</p>
)
})}
</div>
) : null}
</div>
) : null}
</div>
<span className="message-timestamp" aria-hidden="true">{formatMessageTimestamp(message.createdAt)}</span>
{message.role === "assistant" && canCopyMessage ? (
<div className="message-card-actions">
<button
type="button"
className={"message-action-icon" + (copiedToken === copyToken ? " copied" : "")}
onClick={() => void onCopyText(copyToken, message.content)}
aria-label="复制消息"
title="复制消息"
>
{copiedToken === copyToken ? copiedIcon : copyIcon}
</button>
{message.role === "assistant" ? (
<>
<button
type="button"
className="hidden"
onClick={() => void onRegenerateAssistantMessage(message.id)}
disabled={sending}
aria-label="重新生成"
title="重新生成"
>
{regenerateIcon}
</button>
<button
type="button"
className="hidden"
onClick={() => onToggleMessageReaction(message.id, "up")}
aria-label="赞"
title="赞"
>
{renderThumbIcon("up")}
</button>
<button
type="button"
className="hidden"
onClick={() => onToggleMessageReaction(message.id, "down")}
aria-label="踩"
title="踩"
>
{renderThumbIcon("down")}
</button>
</>
) : null}
</div>
) : null}
</article>
)
})}
{!messages.length && !showBindEntry && showEmptyState ? emptyState : null}
</div>
)
}
import type { ConversationTraceItem, TraceTone } from "./useMessageTraces"
export function formatTraceTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleTimeString("zh-CN", { hour12: false })
}
export function getTraceLineClassName(tone: TraceTone): string {
return "message-trace-line " + tone
}
export function getTraceLineLabels(item: ConversationTraceItem): {
time: string
label: string
detail?: string
} {
return {
time: formatTraceTime(item.createdAt),
label: item.label,
detail: item.detail
}
}
import type { ReactNode } from "react"
import { CheckIcon, CopyIcon } from "../../components/icons/AppIcons"
import { renderMarkdownContent as renderChatMarkdownContent } from "./MarkdownContent"
export function renderChatMessageContent(
content: string,
options: {
messageId: string;
copiedToken: string;
onCopy: (token: string, text: string) => void | Promise<void>;
}
): ReactNode[] {
return renderChatMarkdownContent(content, {
...options,
copyIcon: <CopyIcon />,
copiedIcon: <CheckIcon />
});
}
import { useCallback, useEffect, useState } from "react"
import type { DesktopApi, WorkspaceSummary } from "@qjclaw/shared-types"
import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import { resolvePreferredSessionId } from "../../lib/chat-utils"
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge"
interface UseChatSessionsControllerDeps {
desktopApi: DesktopApi
viewMode: ViewMode
isBound: boolean
bindingRequired: boolean
sessionScopeProjectId?: string
activeSessionId: string
preferredSessionId?: string
sendPhase: "idle" | "preparing" | "streaming" | "finalizing"
activeStreamSessionId?: string
workspace: WorkspaceSummary | null
homeSessionTitle: string
setSessions: (sessions: WorkspaceSummary["sessions"]) => void
clearSessions: () => void
clearAllSessionMessages: () => void
setActiveProjectSession: (sessionId: string) => void
upsertSession: (session: WorkspaceSummary["sessions"][number]) => void
setWorkspace: (workspace: WorkspaceSummary | null) => void
setErrorText: (value: string) => void
}
export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
const {
desktopApi,
viewMode,
isBound,
bindingRequired,
sessionScopeProjectId,
activeSessionId,
preferredSessionId,
sendPhase,
activeStreamSessionId,
workspace,
homeSessionTitle,
setSessions,
clearSessions,
clearAllSessionMessages,
setActiveProjectSession,
upsertSession,
setWorkspace,
setErrorText
} = deps
const [projectActionPending, setProjectActionPending] = useState(false)
const [sessionsLoading, setSessionsLoading] = useState(false)
const [loadedSessionScopeId, setLoadedSessionScopeId] = useState<string | undefined>(undefined)
const shouldLoadScopedSessions = isBound && !bindingRequired && Boolean(sessionScopeProjectId)
useEffect(() => {
if (activeSessionId || !preferredSessionId) {
return
}
setActiveProjectSession(preferredSessionId)
}, [activeSessionId, preferredSessionId, setActiveProjectSession])
useEffect(() => {
let cancelled = false
const preserveVisibleConversation = sendPhase !== "idle"
async function syncScopedSessions() {
if (!isBound || bindingRequired || !sessionScopeProjectId) {
if (!cancelled) {
setSessionsLoading(false)
setLoadedSessionScopeId(undefined)
clearSessions()
if (!preserveVisibleConversation) {
clearAllSessionMessages()
}
}
return
}
setSessionsLoading(true)
try {
const nextSessions = await desktopApi.chat.listSessionsByProject(sessionScopeProjectId)
if (cancelled) {
return
}
setSessions(nextSessions)
setLoadedSessionScopeId(sessionScopeProjectId)
if (preserveVisibleConversation) {
return
}
const hasActiveSession = activeSessionId
? nextSessions.some((session) => session.id === activeSessionId)
: false
const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId)
if (hasActiveSession) {
return
}
if (nextSessionId) {
setActiveProjectSession(nextSessionId)
} else if (sessionScopeProjectId === HOME_CHAT_PROJECT_ID) {
const homeSession = await desktopApi.chat.createSessionForProject(HOME_CHAT_PROJECT_ID, homeSessionTitle)
if (cancelled) {
return
}
upsertSession(homeSession)
setActiveProjectSession(homeSession.id)
} else {
setActiveProjectSession(EMPTY_SESSION_ID)
if (!preserveVisibleConversation) {
clearAllSessionMessages()
}
}
} catch (error) {
if (cancelled) {
return
}
clearSessions()
if (!preserveVisibleConversation) {
clearAllSessionMessages()
}
setErrorText(error instanceof Error ? error.message : String(error))
} finally {
if (!cancelled) {
setSessionsLoading(false)
}
}
}
void syncScopedSessions()
return () => {
cancelled = true
}
}, [
activeSessionId,
bindingRequired,
clearAllSessionMessages,
clearSessions,
desktopApi.chat,
homeSessionTitle,
isBound,
sendPhase,
sessionScopeProjectId,
setActiveProjectSession,
setErrorText,
setSessions,
upsertSession
])
const switchProject = useCallback(async (projectId: string) => {
if (projectActionPending) {
return
}
setProjectActionPending(true)
setErrorText("")
try {
const nextWorkspace = await desktopApi.projects.setActive(projectId)
setWorkspace(nextWorkspace)
clearSessions()
clearAllSessionMessages()
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error))
} finally {
setProjectActionPending(false)
}
}, [clearAllSessionMessages, clearSessions, desktopApi.projects, projectActionPending, setErrorText, setWorkspace])
const switchProjectPreservingMessages = useCallback(async (projectId: string) => {
if (projectActionPending) {
return
}
setProjectActionPending(true)
setErrorText("")
try {
const nextWorkspace = await desktopApi.projects.setActive(projectId)
setWorkspace(nextWorkspace)
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error))
} finally {
setProjectActionPending(false)
}
}, [desktopApi.projects, projectActionPending, setErrorText, setWorkspace])
const createProjectSession = useCallback(async () => {
if (projectActionPending || !sessionScopeProjectId) {
return
}
setProjectActionPending(true)
setErrorText("")
try {
const session = await desktopApi.chat.createSessionForProject(sessionScopeProjectId)
upsertSession(session)
if (viewMode === "experts") {
const nextWorkspace = await desktopApi.workspace.getSummary().catch(() => null)
if (nextWorkspace) {
setWorkspace(nextWorkspace)
}
}
setActiveProjectSession(session.id)
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error))
} finally {
setProjectActionPending(false)
}
}, [
desktopApi.chat,
desktopApi.workspace,
projectActionPending,
sessionScopeProjectId,
setActiveProjectSession,
setErrorText,
setWorkspace,
upsertSession,
viewMode
])
const closeProjectSession = useCallback(async (sessionId: string) => {
if (projectActionPending || (sendPhase !== "idle" && activeStreamSessionId === sessionId)) {
return
}
setProjectActionPending(true)
setErrorText("")
try {
const nextSessions = await desktopApi.chat.closeSession(sessionId)
setSessions(nextSessions)
if (viewMode === "experts") {
const nextWorkspace = await desktopApi.workspace.getSummary().catch(() => null)
if (nextWorkspace) {
setWorkspace(nextWorkspace)
}
}
const nextSessionId = nextSessions.find((session) => session.id !== sessionId)?.id ?? nextSessions[0]?.id ?? EMPTY_SESSION_ID
setActiveProjectSession(nextSessionId)
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error))
} finally {
setProjectActionPending(false)
}
}, [
activeStreamSessionId,
desktopApi.chat,
desktopApi.workspace,
projectActionPending,
sendPhase,
setActiveProjectSession,
setErrorText,
setSessions,
setWorkspace,
viewMode
])
const openSession = useCallback((sessionId: string) => {
setActiveProjectSession(sessionId)
}, [setActiveProjectSession])
const isCloseProjectSessionDisabled = useCallback((sessionId: string) => {
return projectActionPending || (sendPhase !== "idle" && activeStreamSessionId === sessionId)
}, [activeStreamSessionId, projectActionPending, sendPhase])
return {
projectActionPending,
sessionsLoading: sessionsLoading || (shouldLoadScopedSessions && loadedSessionScopeId !== sessionScopeProjectId),
switchProject,
switchProjectPreservingMessages,
createProjectSession,
closeProjectSession,
openSession,
isCloseProjectSessionDisabled
}
}
This diff is collapsed.
import { useRef, useState } from "react";
import type { ChangeEvent, DragEvent as ReactDragEvent } from "react";
import type { ChatAttachment } from "@qjclaw/shared-types";
import {
IMAGE_ATTACHMENT_EXTENSIONS,
SUPPORTED_ATTACHMENT_EXTENSIONS
} from "../../lib/constants";
import { desktopApi } from "../../lib/desktop-api";
interface UseComposerAttachmentsOptions {
setErrorText: (value: string) => void;
}
export function useComposerAttachments({ setErrorText }: UseComposerAttachmentsOptions) {
const [composerAttachments, setComposerAttachments] = useState<ChatAttachment[]>([]);
const [isComposerDragOver, setIsComposerDragOver] = useState(false);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const composerDragDepthRef = useRef(0);
function resolveComposerAttachmentKind(file: File, localPath: string): ChatAttachment["kind"] | null {
if (file.type.startsWith("image/")) {
return "image";
}
const extension = (file.name ? file.name.slice(file.name.lastIndexOf(".")) : localPath.slice(localPath.lastIndexOf("."))).toLowerCase();
if (IMAGE_ATTACHMENT_EXTENSIONS.has(extension)) {
return "image";
}
if (SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension)) {
return "file";
}
return null;
}
function appendComposerAttachments(nextAttachments: ChatAttachment[]) {
setComposerAttachments((current) => {
const merged = [...current];
const seen = new Set(current.map((attachment) => attachment.localPath.toLowerCase()));
for (const attachment of nextAttachments) {
const key = attachment.localPath.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
merged.push(attachment);
}
return merged;
});
}
function clearComposerAttachment() {
setErrorText("");
setComposerAttachments([]);
if (attachmentInputRef.current) {
attachmentInputRef.current.value = "";
}
}
function removeComposerAttachment(localPath: string) {
setComposerAttachments((current) => current.filter((attachment) => attachment.localPath !== localPath));
}
function acceptComposerAttachmentFile(file: File) {
const localPath = (file as File & { path?: string }).path?.trim();
if (!localPath) {
setErrorText("The desktop client did not provide a local file path, so this attachment cannot be sent into the project workspace.");
return;
}
const kind = resolveComposerAttachmentKind(file, localPath);
if (!kind) {
setErrorText("Supported attachments: images, MP3, PDF, PPT, Excel, Word, CSV, TXT, Markdown, and JSON.");
return;
}
setErrorText("");
appendComposerAttachments([{
kind,
name: file.name || localPath.split(/[\\/]/).pop() || "attachment",
mimeType: file.type || "application/octet-stream",
localPath
}]);
}
function handleComposerDragEnter(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current += 1;
setIsComposerDragOver(true);
}
function handleComposerDragOver(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
function handleComposerDragLeave(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current = Math.max(0, composerDragDepthRef.current - 1);
if (composerDragDepthRef.current === 0) {
setIsComposerDragOver(false);
}
}
function handleComposerDrop(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current = 0;
setIsComposerDragOver(false);
const files = Array.from(event.dataTransfer.files);
for (const file of files) {
acceptComposerAttachmentFile(file);
}
}
async function openAttachmentPicker() {
if (window.qjcDesktop) {
const attachments = await desktopApi.chat.pickAttachments();
if (!attachments.length) {
return;
}
setErrorText("");
appendComposerAttachments(attachments);
return;
}
attachmentInputRef.current?.click();
}
function handleAttachmentSelection(event: ChangeEvent<HTMLInputElement>) {
const files = event.target.files ? Array.from(event.target.files) : [];
if (!files.length) {
return;
}
for (const file of files) {
acceptComposerAttachmentFile(file);
}
event.target.value = "";
}
return {
attachmentInputRef,
composerAttachments,
isComposerDragOver,
clearComposerAttachment,
removeComposerAttachment,
openAttachmentPicker,
handleAttachmentSelection,
handleComposerDragEnter,
handleComposerDragOver,
handleComposerDragLeave,
handleComposerDrop
};
}
import { useEffect, useMemo, useRef, useState } from "react";
import type {
CSSProperties,
PointerEvent as ReactPointerEvent,
RefObject
} from "react";
import {
clampComposerTextareaHeight,
getComposerTextareaBounds
} from "../../lib/chat-utils";
import { COMPOSER_TEXTAREA_DEFAULT_RATIO } from "../../lib/constants";
interface UseComposerResizeOptions {
isConversationView: boolean;
workspaceRef: RefObject<HTMLDivElement | null>;
}
export function useComposerResize({ isConversationView, workspaceRef }: UseComposerResizeOptions) {
const [isComposerResizeActive, setIsComposerResizeActive] = useState(false);
const [composerTextareaRatio, setComposerTextareaRatio] = useState(COMPOSER_TEXTAREA_DEFAULT_RATIO);
const [composerTextareaHeight, setComposerTextareaHeight] = useState(77);
const [composerWorkspaceHeight, setComposerWorkspaceHeight] = useState(0);
const composerResizeDragRef = useRef<{ startY: number; startHeight: number; workspaceHeight: number } | null>(null);
const composerTextareaBounds = useMemo(() => getComposerTextareaBounds(composerWorkspaceHeight), [composerWorkspaceHeight]);
useEffect(() => {
if (!isConversationView) {
return;
}
const workspaceElement = workspaceRef.current;
if (!workspaceElement) {
return;
}
const updateComposerHeight = (workspaceHeight: number) => {
const safeWorkspaceHeight = Number.isFinite(workspaceHeight) && workspaceHeight > 0 ? workspaceHeight : 0;
setComposerWorkspaceHeight((currentHeight) => Math.abs(currentHeight - safeWorkspaceHeight) < 0.5 ? currentHeight : safeWorkspaceHeight);
const nextHeight = clampComposerTextareaHeight(safeWorkspaceHeight * composerTextareaRatio, safeWorkspaceHeight);
setComposerTextareaHeight((currentHeight) => Math.abs(currentHeight - nextHeight) < 0.5 ? currentHeight : nextHeight);
};
updateComposerHeight(workspaceElement.getBoundingClientRect().height);
if (!("ResizeObserver" in window)) {
return;
}
const observer = new ResizeObserver((entries) => {
updateComposerHeight(entries[0]?.contentRect.height ?? workspaceElement.getBoundingClientRect().height);
});
observer.observe(workspaceElement);
return () => observer.disconnect();
}, [composerTextareaRatio, isConversationView, workspaceRef]);
function handleComposerResizePointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (event.button !== 0) {
return;
}
const workspaceHeight = workspaceRef.current?.getBoundingClientRect().height ?? composerWorkspaceHeight;
setComposerWorkspaceHeight((currentHeight) => Math.abs(currentHeight - workspaceHeight) < 0.5 ? currentHeight : workspaceHeight);
composerResizeDragRef.current = {
startY: event.clientY,
startHeight: composerTextareaHeight,
workspaceHeight
};
event.currentTarget.setPointerCapture(event.pointerId);
setIsComposerResizeActive(true);
event.preventDefault();
}
function handleComposerResizePointerMove(event: ReactPointerEvent<HTMLDivElement>) {
const resizeState = composerResizeDragRef.current;
if (!resizeState) {
return;
}
const workspaceHeight = workspaceRef.current?.getBoundingClientRect().height || resizeState.workspaceHeight;
const nextHeight = clampComposerTextareaHeight(resizeState.startHeight + resizeState.startY - event.clientY, workspaceHeight);
setComposerWorkspaceHeight((currentHeight) => Math.abs(currentHeight - workspaceHeight) < 0.5 ? currentHeight : workspaceHeight);
setComposerTextareaHeight(nextHeight);
setComposerTextareaRatio(nextHeight / Math.max(workspaceHeight, 1));
event.preventDefault();
}
function handleComposerResizePointerEnd(event: ReactPointerEvent<HTMLDivElement>) {
if (!composerResizeDragRef.current) {
return;
}
composerResizeDragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setIsComposerResizeActive(false);
event.preventDefault();
}
const composerShellStyle = {
"--composer-textarea-height": `${Math.round(composerTextareaHeight)}px`,
"--composer-textarea-min-height": `${Math.round(composerTextareaBounds.min)}px`,
"--composer-textarea-max-height": `${Math.round(composerTextareaBounds.max)}px`
} as CSSProperties;
return {
isComposerResizeActive,
composerShellStyle,
handleComposerResizePointerDown,
handleComposerResizePointerMove,
handleComposerResizePointerEnd
};
}
import { useCallback, useEffect, useState } from "react"
import type { ChatAttachment, DesktopApi, ProjectIntentSuggestion } from "@qjclaw/shared-types"
import type { SubmitPromptOptions } from "./useChatStreamingController"
import { resolvePreferredSessionId } from "../../lib/chat-utils"
import { HOME_CHAT_PROJECT_ID } from "../../lib/constants"
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge"
export interface PendingHomeIntentSuggestion {
suggestion: ProjectIntentSuggestion
prompt: string
skillId?: string
attachments?: ChatAttachment[]
}
interface UseHomeIntentSuggestionDeps {
desktopApi: DesktopApi
viewMode: ViewMode
sessionScopeProjectId?: string
prompt: string
selectedSkillId: string
defaultSkillId: string
composerAttachments: ChatAttachment[]
setViewMode: (viewMode: ViewMode) => void
setPrompt: (prompt: string) => void
setActiveProjectSession: (sessionId: string) => void
upsertSession: (session: { id: string; projectId: string; title: string; updatedAt: string }) => void
setSkillMenuOpen: (open: boolean) => void
setErrorText: (value: string) => void
submitPrompt: (options: SubmitPromptOptions) => Promise<void>
switchExpert: (projectId: string) => Promise<void>
normalizeError: (error: unknown) => string
}
export function useHomeIntentSuggestion(deps: UseHomeIntentSuggestionDeps) {
const {
desktopApi,
viewMode,
sessionScopeProjectId,
prompt,
selectedSkillId,
defaultSkillId,
composerAttachments,
setViewMode,
setPrompt,
setActiveProjectSession,
upsertSession,
setSkillMenuOpen,
setErrorText,
submitPrompt,
switchExpert,
normalizeError
} = deps
const [pendingHomeIntentSuggestion, setPendingHomeIntentSuggestion] = useState<PendingHomeIntentSuggestion | null>(null)
const [homeIntentDecisionPending, setHomeIntentDecisionPending] = useState(false)
useEffect(() => {
if (viewMode !== "chat") {
setPendingHomeIntentSuggestion(null)
setHomeIntentDecisionPending(false)
}
}, [viewMode])
useEffect(() => {
if (sessionScopeProjectId !== HOME_CHAT_PROJECT_ID) {
setPendingHomeIntentSuggestion(null)
setHomeIntentDecisionPending(false)
}
}, [sessionScopeProjectId])
const requestHomeIntentSuggestion = useCallback(async ({
promptText,
skillId,
attachments,
requireKnownProject = false,
knownProjectIds
}: {
promptText: string
skillId?: string
attachments?: ChatAttachment[]
requireKnownProject?: boolean
knownProjectIds?: ReadonlySet<string>
}): Promise<ProjectIntentSuggestion | null> => {
const trimmedPrompt = promptText.trim()
if (!trimmedPrompt) {
setPendingHomeIntentSuggestion(null)
return null
}
const suggestion = await desktopApi.projects.resolveIntent(trimmedPrompt, HOME_CHAT_PROJECT_ID)
if (!suggestion || (requireKnownProject && knownProjectIds && !knownProjectIds.has(suggestion.projectId))) {
setPendingHomeIntentSuggestion(null)
return null
}
setPendingHomeIntentSuggestion({
suggestion,
prompt: trimmedPrompt,
skillId,
attachments
})
setSkillMenuOpen(false)
setErrorText("")
return suggestion
}, [desktopApi.projects, setErrorText, setSkillMenuOpen])
const resolveHomeIntentSuggestion = useCallback(async (nextPrompt?: string) => {
const currentPrompt = (nextPrompt ?? prompt).trim()
if (!currentPrompt) {
setPendingHomeIntentSuggestion(null)
return { visible: false }
}
if (typeof nextPrompt === "string") {
setPrompt(currentPrompt)
}
const skillId = selectedSkillId === defaultSkillId ? undefined : selectedSkillId
const suggestion = await requestHomeIntentSuggestion({
promptText: currentPrompt,
skillId,
attachments: composerAttachments.length ? composerAttachments : undefined
})
if (!suggestion) {
return { visible: false }
}
return {
visible: true,
projectId: suggestion.projectId,
projectName: suggestion.projectName,
pendingPrompt: currentPrompt
}
}, [composerAttachments, defaultSkillId, prompt, requestHomeIntentSuggestion, selectedSkillId, setPrompt])
const continuePendingHomePromptInHome = useCallback(async (options?: { createSmokeSession?: boolean }) => {
if (!pendingHomeIntentSuggestion || homeIntentDecisionPending) {
return { continued: false }
}
const pendingRequest = pendingHomeIntentSuggestion
setHomeIntentDecisionPending(true)
setPendingHomeIntentSuggestion(null)
try {
const homeSession = options?.createSmokeSession
? await desktopApi.chat.createSessionForProject(HOME_CHAT_PROJECT_ID, "Smoke Home Intent")
: undefined
const forcedSessionId = homeSession?.id
if (homeSession) {
upsertSession(homeSession)
setViewMode("chat")
setActiveProjectSession(homeSession.id)
}
await submitPrompt({
promptText: pendingRequest.prompt,
skillId: pendingRequest.skillId,
forcedSessionId,
forcedProjectId: HOME_CHAT_PROJECT_ID,
forcedAttachments: pendingRequest.attachments
})
return {
continued: true,
sessionId: forcedSessionId
}
} finally {
setHomeIntentDecisionPending(false)
}
}, [desktopApi.chat, homeIntentDecisionPending, pendingHomeIntentSuggestion, setActiveProjectSession, setViewMode, submitPrompt, upsertSession])
const switchExpertAndContinuePendingHomePrompt = useCallback(async (options?: { smokeTitle?: string; throwOnError?: boolean }) => {
if (!pendingHomeIntentSuggestion || homeIntentDecisionPending) {
return {
switched: false,
projectId: undefined
}
}
const pendingRequest = pendingHomeIntentSuggestion
setHomeIntentDecisionPending(true)
setPendingHomeIntentSuggestion(null)
setErrorText("")
try {
setViewMode("experts")
await switchExpert(pendingRequest.suggestion.projectId)
const projectSessions = await desktopApi.chat.listSessionsByProject(pendingRequest.suggestion.projectId)
const ensuredSessionId = resolvePreferredSessionId(projectSessions)
?? (await desktopApi.chat.createSessionForProject(pendingRequest.suggestion.projectId, options?.smokeTitle)).id
setActiveProjectSession(ensuredSessionId)
await submitPrompt({
promptText: pendingRequest.prompt,
skillId: pendingRequest.skillId,
forcedSessionId: ensuredSessionId,
forcedProjectId: pendingRequest.suggestion.projectId,
forcedAttachments: pendingRequest.attachments
})
return {
switched: true,
projectId: pendingRequest.suggestion.projectId,
sessionId: ensuredSessionId
}
} catch (error) {
setViewMode("chat")
setPendingHomeIntentSuggestion(pendingRequest)
setErrorText(normalizeError(error))
if (options?.throwOnError) {
throw error
}
return {
switched: false,
projectId: pendingRequest.suggestion.projectId
}
} finally {
setHomeIntentDecisionPending(false)
}
}, [desktopApi.chat, homeIntentDecisionPending, normalizeError, pendingHomeIntentSuggestion, setActiveProjectSession, setErrorText, setViewMode, submitPrompt, switchExpert])
const dismissPendingHomeIntentSuggestion = useCallback(() => {
if (homeIntentDecisionPending) {
return
}
setPendingHomeIntentSuggestion(null)
}, [homeIntentDecisionPending])
return {
pendingHomeIntentSuggestion,
homeIntentDecisionPending,
requestHomeIntentSuggestion,
resolveHomeIntentSuggestion,
continuePendingHomePromptInHome,
switchExpertAndContinuePendingHomePrompt,
dismissPendingHomeIntentSuggestion
}
}
import { useCallback, useEffect, useRef } from "react"
import type { UIEvent as ReactUIEvent } from "react"
import { MESSAGE_LIST_AUTO_SCROLL_THRESHOLD_PX } from "../../lib/constants"
export function useMessageListAutoScroll() {
const messageListRef = useRef<HTMLDivElement | null>(null)
const shouldAutoScrollMessageListRef = useRef(true)
const messageListScrollFrameRef = useRef<number | null>(null)
const isMessageListNearBottom = useCallback((element = messageListRef.current): boolean => {
if (!element) {
return true
}
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight
return distanceFromBottom <= MESSAGE_LIST_AUTO_SCROLL_THRESHOLD_PX
}, [])
const handleMessageListScroll = useCallback((event: ReactUIEvent<HTMLDivElement>) => {
shouldAutoScrollMessageListRef.current = isMessageListNearBottom(event.currentTarget)
}, [isMessageListNearBottom])
const scrollMessageListToBottom = useCallback((options: { force?: boolean; behavior?: ScrollBehavior } = {}) => {
const element = messageListRef.current
if (!element) {
return
}
const shouldScroll = options.force || shouldAutoScrollMessageListRef.current || isMessageListNearBottom(element)
if (!shouldScroll) {
return
}
shouldAutoScrollMessageListRef.current = true
if (messageListScrollFrameRef.current) {
window.cancelAnimationFrame(messageListScrollFrameRef.current)
}
messageListScrollFrameRef.current = window.requestAnimationFrame(() => {
messageListScrollFrameRef.current = null
const latestElement = messageListRef.current
if (!latestElement) {
return
}
latestElement.scrollTo({
top: latestElement.scrollHeight,
behavior: options.behavior ?? "smooth"
})
})
}, [isMessageListNearBottom])
const resetMessageListAutoScroll = useCallback(() => {
shouldAutoScrollMessageListRef.current = true
}, [])
useEffect(() => {
return () => {
if (messageListScrollFrameRef.current) {
window.cancelAnimationFrame(messageListScrollFrameRef.current)
messageListScrollFrameRef.current = null
}
}
}, [])
return {
messageListRef,
resetMessageListAutoScroll,
handleMessageListScroll,
scrollMessageListToBottom
}
}
import { useCallback, useState } from "react"
import { createClientMessageId } from "../../lib/chat-utils"
import { MAX_TRACE_ITEMS } from "../../lib/constants"
export type TraceTone = "info" | "error" | "success"
export interface ConversationTraceItem {
id: string
stage: string
label: string
detail?: string
tone: TraceTone
createdAt: string
}
export interface MessageTraceState {
items: ConversationTraceItem[]
expanded: boolean
}
function pushTraceItem(current: ConversationTraceItem[], item: ConversationTraceItem): ConversationTraceItem[] {
const lastItem = current.at(-1)
if (lastItem && lastItem.stage === item.stage && lastItem.label === item.label && lastItem.detail === item.detail && lastItem.tone === item.tone) {
return current
}
const next = [...current, item]
return next.slice(-MAX_TRACE_ITEMS)
}
export function useMessageTraces() {
const [messageTraces, setMessageTraces] = useState<Record<string, MessageTraceState>>({})
const renameMessageTrace = useCallback((currentMessageId: string, nextMessageId: string) => {
setMessageTraces((current) => {
if (!(currentMessageId in current)) {
return current
}
const existing = current[currentMessageId]
const { [currentMessageId]: _removed, ...rest } = current
return {
...rest,
[nextMessageId]: existing
}
})
}, [])
const initializeMessageTrace = useCallback((messageId: string, item?: ConversationTraceItem) => {
setMessageTraces((current) => ({
...current,
[messageId]: {
items: item ? [item] : [],
expanded: true
}
}))
}, [])
const appendTrace = useCallback((messageId: string, stage: string, label: string, detail?: string, tone: TraceTone = "info") => {
setMessageTraces((current) => {
const existing = current[messageId] ?? { items: [], expanded: true }
return {
...current,
[messageId]: {
items: pushTraceItem(existing.items, {
id: createClientMessageId("trace"),
stage,
label,
detail,
tone,
createdAt: new Date().toISOString()
}),
expanded: tone === "error" ? true : existing.expanded
}
}
})
}, [])
const setMessageTraceExpanded = useCallback((messageId: string, expanded: boolean) => {
setMessageTraces((current) => {
const existing = current[messageId]
if (!existing || existing.expanded === expanded) {
return current
}
return {
...current,
[messageId]: {
...existing,
expanded
}
}
})
}, [])
const collapseMessageTrace = useCallback((messageId: string) => {
setMessageTraceExpanded(messageId, false)
}, [setMessageTraceExpanded])
return {
messageTraces,
renameMessageTrace,
initializeMessageTrace,
appendTrace,
setMessageTraceExpanded,
collapseMessageTrace
}
}
import { useCallback, useMemo, useState } from "react"
import type { WorkspaceSummary } from "@qjclaw/shared-types"
import { EMPTY_SESSION_ID } from "../../lib/constants"
import { resolvePreferredSessionId } from "../../lib/chat-utils"
export function useProjectSessions() {
const [sessions, setSessions] = useState<WorkspaceSummary["sessions"]>([])
const [activeSessionId, setActiveSessionId] = useState(EMPTY_SESSION_ID)
const preferredSessionId = useMemo(
() => resolvePreferredSessionId(sessions, activeSessionId),
[activeSessionId, sessions]
)
const visibleSessionId = useMemo(
() => activeSessionId || preferredSessionId,
[activeSessionId, preferredSessionId]
)
const clearSessions = useCallback(() => {
setSessions([])
setActiveSessionId(EMPTY_SESSION_ID)
}, [])
const openSession = useCallback((sessionId: string) => {
setActiveSessionId(sessionId)
}, [])
const upsertSession = useCallback((session: WorkspaceSummary["sessions"][number]) => {
setSessions((current) => [session, ...current.filter((item) => item.id !== session.id)])
}, [])
return {
sessions,
activeSessionId,
preferredSessionId,
visibleSessionId,
setSessions,
setActiveSessionId,
clearSessions,
openSession,
upsertSession
}
}
import { useCallback } from "react"
import type { ChatAttachment } from "@qjclaw/shared-types"
import type { SubmitPromptOptions } from "./useChatStreamingController"
import { HOME_CHAT_PROJECT_ID, HOME_EXPERT_SUGGESTION_PROJECT_IDS } from "../../lib/constants"
import { shouldOfferHomeExpertSwitch } from "../../lib/chat-utils"
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge"
interface ResumePromptOptions {
skipHomeIntentSuggestion?: boolean
}
interface UsePromptSubmissionDeps {
canSend: boolean
viewMode: ViewMode
sessionScopeProjectId?: string
prompt: string
selectedSkillId: string
defaultSkillId: string
composerAttachments: ChatAttachment[]
submitPrompt: (options: SubmitPromptOptions) => Promise<void>
requestHomeIntentSuggestion: (options: {
promptText: string
skillId?: string
attachments?: ChatAttachment[]
requireKnownProject?: boolean
knownProjectIds?: ReadonlySet<string>
}) => Promise<unknown>
clearHomeIntentSuggestion: () => void
setErrorText: (value: string) => void
normalizeError: (error: unknown) => string
}
export function usePromptSubmission(deps: UsePromptSubmissionDeps) {
const {
canSend,
viewMode,
sessionScopeProjectId,
prompt,
selectedSkillId,
defaultSkillId,
composerAttachments,
submitPrompt,
requestHomeIntentSuggestion,
clearHomeIntentSuggestion,
setErrorText,
normalizeError
} = deps
const sendPrompt = useCallback(async (options?: ResumePromptOptions) => {
if (!canSend) {
return
}
const skillId = selectedSkillId === defaultSkillId ? undefined : selectedSkillId
const trimmedPrompt = prompt.trim()
const attachmentsToSend = composerAttachments.length ? composerAttachments : undefined
const shouldSuggestExpert = !options?.skipHomeIntentSuggestion
&& viewMode === "chat"
&& sessionScopeProjectId === HOME_CHAT_PROJECT_ID
&& !skillId
&& trimmedPrompt.length > 0
&& shouldOfferHomeExpertSwitch(trimmedPrompt)
if (shouldSuggestExpert) {
try {
const suggestion = await requestHomeIntentSuggestion({
promptText: trimmedPrompt,
skillId,
attachments: attachmentsToSend,
requireKnownProject: true,
knownProjectIds: HOME_EXPERT_SUGGESTION_PROJECT_IDS
})
if (suggestion) {
return
}
} catch (error) {
setErrorText(normalizeError(error))
return
}
}
clearHomeIntentSuggestion()
await submitPrompt({
promptText: prompt,
skillId,
forcedAttachments: attachmentsToSend
})
}, [canSend, clearHomeIntentSuggestion, composerAttachments, defaultSkillId, normalizeError, prompt, requestHomeIntentSuggestion, selectedSkillId, sessionScopeProjectId, setErrorText, submitPrompt, viewMode])
return { sendPrompt }
}
import { useCallback, useMemo, useState } from "react"
import type { UiChatMessage } from "../../lib/chat-utils"
export type MessagesBySession = Record<string, UiChatMessage[]>
export function useSessionMessageStore(visibleSessionId: string) {
const [messagesBySession, setMessagesBySession] = useState<MessagesBySession>({})
const messages = useMemo(
() => (visibleSessionId ? messagesBySession[visibleSessionId] ?? [] : []),
[messagesBySession, visibleSessionId]
)
const updateSessionMessages = useCallback((sessionId: string, updater: (current: UiChatMessage[]) => UiChatMessage[]) => {
if (!sessionId) {
return
}
setMessagesBySession((current) => {
const currentMessages = current[sessionId] ?? []
const nextMessages = updater(currentMessages)
if (nextMessages === currentMessages) {
return current
}
if (!nextMessages.length) {
if (!(sessionId in current)) {
return current
}
const { [sessionId]: _removed, ...rest } = current
return rest
}
return {
...current,
[sessionId]: nextMessages
}
})
}, [])
const clearAllSessionMessages = useCallback(() => {
setMessagesBySession({})
}, [])
const moveSessionMessages = useCallback((sourceSessionId: string, targetSessionId: string, messageIds: string[]) => {
if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId || messageIds.length === 0) {
return
}
setMessagesBySession((current) => {
const sourceMessages = current[sourceSessionId] ?? []
const targetMessages = current[targetSessionId] ?? []
const movedMessages = sourceMessages.filter((message) => messageIds.includes(message.id))
if (!movedMessages.length) {
return current
}
const nextSourceMessages = sourceMessages.filter((message) => !messageIds.includes(message.id))
const targetMessageIds = new Set(targetMessages.map((message) => message.id))
const nextMovedMessages = movedMessages.filter((message) => !targetMessageIds.has(message.id))
return {
...current,
[sourceSessionId]: nextSourceMessages,
[targetSessionId]: nextMovedMessages.length > 0 ? [...targetMessages, ...nextMovedMessages] : targetMessages
}
})
}, [])
const updateMessageById = useCallback((messageId: string, updater: (message: UiChatMessage) => UiChatMessage) => {
setMessagesBySession((current) => {
for (const [sessionId, sessionMessages] of Object.entries(current)) {
const messageIndex = sessionMessages.findIndex((message) => message.id === messageId)
if (messageIndex < 0) {
continue
}
const nextMessages = [...sessionMessages]
nextMessages[messageIndex] = updater(sessionMessages[messageIndex])
return {
...current,
[sessionId]: nextMessages
}
}
return current
})
}, [])
return {
messagesBySession,
messages,
updateSessionMessages,
clearAllSessionMessages,
moveSessionMessages,
updateMessageById
}
}
import { useCallback, useMemo, useState } from "react"
import type { ChatMessage } from "@qjclaw/shared-types"
import type { UiChatMessage } from "../../lib/chat-utils"
export type MessagesBySession = Record<string, UiChatMessage[]>
export function useSessionMessages(visibleSessionId?: string) {
const [messagesBySession, setMessagesBySession] = useState<MessagesBySession>({})
const messages = useMemo(
() => (visibleSessionId ? messagesBySession[visibleSessionId] ?? [] : []),
[messagesBySession, visibleSessionId]
)
const updateSessionMessages = useCallback((sessionId: string, updater: (current: UiChatMessage[]) => UiChatMessage[]) => {
if (!sessionId) {
return
}
setMessagesBySession((current) => {
const currentMessages = current[sessionId] ?? []
const nextMessages = updater(currentMessages)
if (nextMessages === currentMessages) {
return current
}
if (!nextMessages.length) {
if (!(sessionId in current)) {
return current
}
const { [sessionId]: _removed, ...rest } = current
return rest
}
return {
...current,
[sessionId]: nextMessages
}
})
}, [])
const clearAllSessionMessages = useCallback(() => {
setMessagesBySession({})
}, [])
const moveSessionMessages = useCallback((sourceSessionId: string, targetSessionId: string, messageIds: string[]) => {
if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId || messageIds.length === 0) {
return
}
setMessagesBySession((current) => {
const sourceMessages = current[sourceSessionId] ?? []
const targetMessages = current[targetSessionId] ?? []
const movedMessages = sourceMessages.filter((message) => messageIds.includes(message.id))
if (!movedMessages.length) {
return current
}
const nextSourceMessages = sourceMessages.filter((message) => !messageIds.includes(message.id))
const targetMessageIds = new Set(targetMessages.map((message) => message.id))
const nextMovedMessages = movedMessages.filter((message) => !targetMessageIds.has(message.id))
return {
...current,
[sourceSessionId]: nextSourceMessages,
[targetSessionId]: nextMovedMessages.length > 0 ? [...targetMessages, ...nextMovedMessages] : targetMessages
}
})
}, [])
const updateMessageById = useCallback((messageId: string, updater: (message: UiChatMessage) => UiChatMessage) => {
setMessagesBySession((current) => {
for (const [sessionId, sessionMessages] of Object.entries(current)) {
const messageIndex = sessionMessages.findIndex((message) => message.id === messageId)
if (messageIndex < 0) {
continue
}
const nextMessages = [...sessionMessages]
nextMessages[messageIndex] = updater(sessionMessages[messageIndex])
return {
...current,
[sessionId]: nextMessages
}
}
return current
})
}, [])
const plainMessagesBySession = useMemo(
() => Object.fromEntries(
Object.entries(messagesBySession).map(([sessionId, sessionMessages]) => [
sessionId,
sessionMessages.map(({ streamState, statusLabel, statusDetail, ...message }) => message)
])
) as Record<string, ChatMessage[]>,
[messagesBySession]
)
return {
messages,
messagesBySession,
plainMessagesBySession,
updateSessionMessages,
clearAllSessionMessages,
moveSessionMessages,
updateMessageById
}
}
import type { ReactNode, RefObject } from "react"
import { Panel } from "../../components/ui/Panel"
import { ChatWorkspace } from "../chat/ChatWorkspace"
export type ExpertKey = "xiaohongshu" | "douyin" | "browser" | "general"
export type ExpertVisualKey =
| "xiaohongshu"
| "douyin"
| "browser"
| "general"
| "planner"
| "zhihu"
| "wechat"
| "x"
| "tiktok"
| "poster"
| "geo"
| "leads"
| "sales"
export interface ExpertGuideContent {
greeting: string
summary: string
intro?: string
requirementChecklist?: string[]
routeOptions?: Array<{
title: string
detail: string
accent: "host" | "visual"
}>
workflowSteps?: string[]
continueHint?: string
placeholder?: string
prompts: string[]
}
interface ExpertsViewProps {
activeExpertName: string
activeExpertKey: ExpertKey
activeExpertVisualKey: ExpertVisualKey
panelActions: ReactNode
workspaceRef: RefObject<HTMLDivElement | null>
statusNotice: ReactNode
bodyContent: ReactNode
composerContent: ReactNode
renderExpertIcon: (expertKey: ExpertVisualKey) => ReactNode
renderExpertLogo: (expertKey: ExpertKey) => ReactNode
}
interface ExpertEmptyStateProps {
activeExpertName: string
activeExpertKey: ExpertKey
activeExpertGuide: ExpertGuideContent
starterQuestionsHint: string
onStarterPrompt: (prompt: string) => void
}
export function ExpertsView({
activeExpertName,
activeExpertKey,
activeExpertVisualKey,
panelActions,
workspaceRef,
statusNotice,
bodyContent,
composerContent,
renderExpertIcon,
renderExpertLogo
}: ExpertsViewProps) {
const expertWorkspaceLogo = renderExpertLogo(activeExpertKey)
return (
<ChatWorkspace
panelLead={(
<div className="conversation-panel-kicker expert-hero-kicker">
{expertWorkspaceLogo ?? (
<span className={"expert-hero-icon expert-hero-icon-" + activeExpertVisualKey} aria-hidden="true">
{renderExpertIcon(activeExpertVisualKey)}
</span>
)}
<span className="expert-hero-copy">
<strong>{activeExpertName}</strong>
</span>
</div>
)}
panelActions={panelActions}
workspaceRef={workspaceRef}
statusNotice={statusNotice}
intentNotice={null}
bodyContent={bodyContent}
composerContent={composerContent}
/>
)
}
export function ExpertEmptyState({
activeExpertName,
activeExpertKey,
activeExpertGuide,
starterQuestionsHint,
onStarterPrompt
}: ExpertEmptyStateProps) {
if (activeExpertKey === "douyin") {
return (
<div className="empty-state expert-empty-state expert-empty-state-douyin">
<div className="expert-guide-heading">
<span className="empty-state-kicker">{activeExpertName}</span>
<strong>{activeExpertGuide.greeting}</strong>
{activeExpertGuide.intro ? <p>{activeExpertGuide.intro}</p> : null}
</div>
{activeExpertGuide.requirementChecklist?.length ? (
<section className="douyin-guide-card">
<span className="douyin-guide-label">建议先告诉我这些</span>
<div className="douyin-guide-chip-list">
{activeExpertGuide.requirementChecklist.map((item) => (
<span key={item} className="douyin-guide-chip">{item}</span>
))}
</div>
</section>
) : null}
{activeExpertGuide.routeOptions?.length ? (
<div className="douyin-guide-grid">
{activeExpertGuide.routeOptions.map((item) => (
<section key={item.title} className={"douyin-guide-card douyin-guide-route-card " + item.accent}>
<span className="douyin-guide-route-badge">{item.accent === "host" ? "有人出镜" : "纯画面"}</span>
<strong className="douyin-guide-route-title">{item.title}</strong>
<p className="douyin-guide-route-detail">{item.detail}</p>
</section>
))}
</div>
) : null}
{activeExpertGuide.workflowSteps?.length ? (
<section className="douyin-guide-card douyin-guide-card-wide">
<span className="douyin-guide-label">生成流程</span>
<div className="douyin-guide-step-list">
{activeExpertGuide.workflowSteps.map((item, index) => (
<div key={item} className="douyin-guide-step">
<span className="douyin-guide-step-index">{index + 1}</span>
<span className="douyin-guide-step-copy">{item}</span>
</div>
))}
</div>
{activeExpertGuide.continueHint ? <p className="douyin-guide-continue">{activeExpertGuide.continueHint}</p> : null}
</section>
) : null}
<div className="douyin-guide-footer">
<p>{starterQuestionsHint}</p>
<div className="starter-prompt-list">
{activeExpertGuide.prompts.map((item) => (
<button key={item} type="button" className="starter-prompt" onClick={() => onStarterPrompt(item)}>{item}</button>
))}
</div>
</div>
</div>
)
}
return (
<div className={"empty-state expert-empty-state" + (activeExpertKey === "xiaohongshu" ? " expert-empty-state-xiaohongshu" : "")}>
<span className="empty-state-kicker">{activeExpertName}</span>
<strong>{activeExpertGuide.greeting}</strong>
<p>{starterQuestionsHint}</p>
<div className="starter-prompt-list">
{activeExpertGuide.prompts.map((item) => (
<button key={item} type="button" className="starter-prompt" onClick={() => onStarterPrompt(item)}>{item}</button>
))}
</div>
</div>
)
}
export function NoExpertsState({ children }: { children: ReactNode }) {
return <Panel className="empty-state" bodyClassName="empty-state-body">{children}</Panel>
}
import type { ExpertDefinition } from "@qjclaw/shared-types"
import type { MessageListMessage } from "../chat/MessageList"
import type { ExpertGuideContent, ExpertKey } from "./ExpertsView"
import type { ExpertProject, ExpertVisualKey } from "../shell/ExpertTree"
export interface VideoStatusCardContent {
title: string
meta?: string
hint?: string
}
export function getProjectDisplayName(project: ExpertProject | undefined, defaultChatName = "千匠问天"): string {
return project?.displayName ?? project?.name ?? defaultChatName
}
export function resolveExpertKey(project: ExpertProject | undefined): ExpertKey {
const seed = [project?.platform, project?.displayName, project?.name, project?.id]
.filter(Boolean)
.join(" ")
.toLowerCase()
if (/xiaohongshu|xhs|rednote|小红书/.test(seed)) {
return "xiaohongshu"
}
if (/douyin|tiktok|抖音/.test(seed)) {
return "douyin"
}
if (/browser|automation|chrome|playwright|web|浏览器|自动化/.test(seed)) {
return "browser"
}
return "general"
}
export function resolveExpertVisualKey(project?: ExpertProject, definition?: ExpertDefinition): ExpertVisualKey {
const seed = [definition?.id, definition?.name, project?.platform, project?.displayName, project?.name, project?.id]
.filter(Boolean)
.join(" ")
.toLowerCase()
if (/xiaohongshu|xhs|rednote|小红书/.test(seed)) {
return "xiaohongshu"
}
if (/douyin|抖音/.test(seed)) {
return "douyin"
}
if (/tiktok/.test(seed)) {
return "tiktok"
}
if (/wechat|weixin|公众号|微信/.test(seed)) {
return "wechat"
}
if (/zhihu|知乎/.test(seed)) {
return "zhihu"
}
if (/content-account|planner|账号规划|内容账号规划/.test(seed)) {
return "planner"
}
if (/precision-leads|线索|lead/.test(seed)) {
return "leads"
}
if (/sales-champion|销售冠军|销冠|sales champion/.test(seed)) {
return "sales"
}
if (/poster|海报/.test(seed)) {
return "poster"
}
if (/(^|[\s-])x($|[\s-])|twitter/.test(seed)) {
return "x"
}
if (/geo/.test(seed)) {
return "geo"
}
if (/browser|automation|chrome|playwright|web|浏览器|自动化/.test(seed)) {
return "browser"
}
return "general"
}
export function buildDouyinVideoStatusCard(message: MessageListMessage, expertKey: ExpertKey): VideoStatusCardContent | null {
if (expertKey !== "douyin" || message.role !== "assistant" || message.streamState !== "streaming" || message.content.trim()) {
return null
}
const title = message.statusLabel?.trim() ?? ""
const detail = message.statusDetail?.trim() ?? ""
const seed = `${title} ${detail}`.toLowerCase()
const looksLikeVideoGeneration = /视频|口播|omnihuman|seedance|远端|生成|分钟|引擎|预计/.test(seed)
if (!looksLikeVideoGeneration) {
return null
}
const [meta, eta, hint] = detail.split(" | ").map((item) => item.trim()).filter(Boolean)
const metaParts = [meta, eta].filter(Boolean)
return {
title: title || "视频正在生成中...",
meta: metaParts.length ? metaParts.join(" · ") : undefined,
hint: hint || "已进入长任务生成阶段,客户端会持续刷新状态。"
}
}
function getExpertGuide(project: ExpertProject | undefined): ExpertGuideContent {
switch (resolveExpertKey(project)) {
case "xiaohongshu":
return {
greeting: "把产品、场景和目标人群说清楚,我先给你一版能直接开工的小红书任务。",
summary: "适合先做选题判断、笔记结构、标题草案、配图思路和发布时间建议。",
prompts: [
"帮我做一篇推荐平价火锅的笔记",
"做一篇制作抹茶奶酪欧包教程的爆文。",
"给通勤女生做一篇春季穿搭笔记。"
]
}
case "douyin":
return {
greeting: "你可以直接说目标,我会按抖音内容和发布节奏来拆解。",
summary: "适合抖音脚本策划、短视频结构、口播优化和发布执行建议。",
prompts: [
"帮我规划 5 条抖音短视频选题,目标是引流到私域。",
"把这个产品介绍改成 30 秒抖音口播脚本。",
"给我一个抖音视频的镜头脚本和字幕节奏。",
"分析这个选题为什么更适合抖音而不是小红书。"
]
}
case "browser":
return {
greeting: "适合处理浏览器自动化、采集、表单填写和发布流程设计。",
summary: "围绕浏览器操作自动化、信息采集、流程编排和执行前检查提供帮助。",
prompts: [
"帮我设计一个浏览器自动采集选题的执行流程。",
"梳理抖音发布前需要自动检查的页面步骤。",
"如果要自动登录并抓取页面数据,我应该先确认哪些风险点?",
"把小红书和抖音的发布流程拆成可自动化的步骤。"
]
}
default:
return {
greeting: "说清你的目标,我会先帮你拆成可执行步骤",
summary: "适合梳理需求、生成方案、组织信息和推进执行",
prompts: [
"你好啊",
"请做一个自我介绍,给出一个你的能力清单"
]
}
}
}
export function getExpertGuideContent(project: ExpertProject | undefined): ExpertGuideContent {
const expertKey = resolveExpertKey(project)
if (expertKey !== "douyin") {
return getExpertGuide(project)
}
return {
greeting: " 先把这条抖音视频的条件说清楚,我会先帮你整理需求并给出预览。",
summary: "适合抖音视频需求补全、文案与分镜预览、数字人口播和纯画面路线判断。",
intro: "最好一次说明主题、给谁看、想达到什么目标、风格、时长、做数字人还是纯画面,以及现在有没有图片或音频。",
requirementChecklist: [
"主题:产品、话题、门店活动或要讲的内容",
"给谁看:目标人群、行业或使用场景",
"目标:卖货、种草、科普、活动引流",
"风格:精致广告感、真实手机拍摄感、氛围感、剧情感",
"时长:数字人口播尽量控制在 65 秒内,纯画面请直接给秒数",
"视频类型:有人出镜讲解,还是纯画面展示",
"素材:有没有人物图片、现成音频,或准备走系统配音"
],
routeOptions: [
{
title: " 有人出镜讲解",
detail: "适合数字人口播、知识讲解、产品说明。继续生成前通常要确认人物图片,以及现成音频或男声/女声配音。",
accent: "host"
},
{
title: " 纯画面展示",
detail: "适合氛围片、空镜、场景展示、画面加旁白。先把视频秒数说清楚,再出文案和分镜预览。",
accent: "visual"
}
],
workflowSteps: [
" 先生成文案和分镜预览,不直接开跑视频。",
" 你确认预览后,再继续生成数字人或纯画面视频。",
" 如果当前路线缺素材,再按需要补图片、音频或配音要求。"
],
// continueHint: "看完预览后,你可以直接回复:继续生成;如果要改,直接回复:修改第几镜 + 你的修改意见。",
placeholder: "例如:做一个讲天气预报的抖音视频,给职场人看,目标是科普,男声数字人口播,10 秒,氛围感,我会上传人物照片,先给我文案和分镜预览。",
prompts: [
"做一个海边视频不要出现人物,给附近上班族看,目标是到店引流,纯画面展示,15 秒,真实手机拍摄感",
"做一个护肤品种草短视频,给 25-35 岁女性看,目标是种草转化,纯画面加旁白,10 秒,精致广告感"
]
}
}
import { Button } from "../../components/ui/Button"
import { Panel } from "../../components/ui/Panel"
export function KnowledgeView() {
return (
<div className="page-stack knowledge-page-stack">
<Panel className="knowledge-panel" bodyClassName="knowledge-panel-body">
<div className="knowledge-header">
<h1 className="knowledge-title">企业知识库</h1>
<p className="knowledge-subtitle">上传和管理您的企业知识文档</p>
</div>
<div className="knowledge-upload-section">
<div className="upload-card">
<div className="upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h3 className="upload-title">上传文档</h3>
<p className="upload-desc">支持 PDF、Word、Excel、TXT 等格式</p>
<Button type="button" className="upload-button btn-primary">
选择文件上传
</Button>
<p className="upload-hint">或拖拽文件到此处</p>
</div>
</div>
<div className="knowledge-list-section">
<h2 className="section-title">最近文档</h2>
<div className="document-grid">
<div className="document-card">
<div className="document-icon">DOC</div>
<div className="document-info">
<h4>产品手册.pdf</h4>
<p>更新于 2 小时前 · 2.4 MB</p>
</div>
</div>
</div>
</div>
</Panel>
</div>
)
}
import { useEffect, useMemo, useState } from "react"
import type { ReactNode } from "react"
import type { WorkspaceSummary } from "@qjclaw/shared-types"
import { Panel } from "../../components/ui/Panel"
import { ScrollArea } from "../../components/ui/ScrollArea"
import { Tabs } from "../../components/ui/Tabs"
type Plugin = WorkspaceSummary["plugins"][number]
interface PluginsViewProps {
pluginCards: Array<{ plugin: Plugin; sectionTitle: string }>
noPluginsLabel: string
getPluginCopy(plugin: Plugin): { name: string; description: string }
getPluginTone(status: Plugin["status"]): "positive" | "warning" | "info"
getPluginStatusLabel(status: Plugin["status"]): string
renderStatusChip(tone: "positive" | "warning" | "info", label: string): ReactNode
renderPluginIcon(): ReactNode
}
export function PluginsView({
pluginCards,
noPluginsLabel,
getPluginCopy,
getPluginTone,
getPluginStatusLabel,
renderStatusChip,
renderPluginIcon
}: PluginsViewProps) {
const pluginGroupTabs = useMemo(() => {
const sectionTitles = Array.from(new Set(pluginCards.map((card) => card.sectionTitle)))
return [
{ id: "all", label: "\u5168\u90e8" },
...sectionTitles.map((title) => ({ id: title, label: title }))
]
}, [pluginCards])
const [activePluginGroup, setActivePluginGroup] = useState("all")
useEffect(() => {
if (!pluginGroupTabs.some((tab) => tab.id === activePluginGroup)) {
setActivePluginGroup("all")
}
}, [activePluginGroup, pluginGroupTabs])
const visiblePluginCards = activePluginGroup === "all"
? pluginCards
: pluginCards.filter((card) => card.sectionTitle === activePluginGroup)
return (
<div className="page-stack plugin-page-stack">
<Panel className="plugin-page" bodyClassName="plugin-page-body">
{pluginGroupTabs.length > 2 ? (
<div className="plugin-tabs-row">
<Tabs
items={pluginGroupTabs}
value={activePluginGroup}
onValueChange={setActivePluginGroup}
ariaLabel="\u63d2\u4ef6\u5206\u7ec4"
/>
</div>
) : null}
<ScrollArea className="scroll-panel plugin-section-list plugin-flat-list">
<div className="plugin-grid plugin-flat-grid">
{visiblePluginCards.map(({ plugin, sectionTitle }) => {
const copy = getPluginCopy(plugin)
return (
<article key={plugin.id} className="catalog-item static plugin-card plugin-flat-card">
<div className="plugin-card-glow" aria-hidden="true" />
<div className="plugin-card-claw" aria-hidden="true">
{renderPluginIcon()}
</div>
<div className="plugin-card-meta">
<span className="plugin-card-group">{sectionTitle}</span>
{renderStatusChip(getPluginTone(plugin.status), getPluginStatusLabel(plugin.status))}
</div>
<div className="plugin-card-head">
<strong>{copy.name}</strong>
</div>
<p>{copy.description}</p>
</article>
)
})}
</div>
{!visiblePluginCards.length ? <div className="empty-state">{noPluginsLabel}</div> : null}
</ScrollArea>
</Panel>
</div>
)
}
import type { WorkspaceSummary } from "@qjclaw/shared-types"
type Plugin = WorkspaceSummary["plugins"][number]
type Tone = "positive" | "warning" | "info"
const pluginDisplayMap: Record<string, { name: string; description: string }> = {
"spreadsheet-tools": { name: "\u8868\u683c\u5de5\u5177", description: "\u8bfb\u53d6\u548c\u5904\u7406 Excel\u3001CSV \u7b49\u5e38\u89c1\u8868\u683c\u6587\u4ef6\u3002" },
"sheet-plugin": { name: "\u8868\u683c\u5de5\u5177", description: "\u8bfb\u53d6\u548c\u5904\u7406 Excel\u3001CSV \u7b49\u5e38\u89c1\u8868\u683c\u6587\u4ef6\u3002" },
"document-tools": { name: "\u6587\u6863\u5de5\u5177", description: "\u5904\u7406 txt\u3001md\u3001docx\u3001pdf \u7b49\u5e38\u89c1\u6587\u6863\u3002" },
"doc-plugin": { name: "\u6587\u6863\u5de5\u5177", description: "\u5904\u7406 txt\u3001md\u3001docx\u3001pdf \u7b49\u5e38\u89c1\u6587\u6863\u3002" },
"web-tools": { name: "\u7f51\u9875\u4fe1\u606f\u63d0\u53d6", description: "\u6293\u53d6\u7f51\u9875\u5185\u5bb9\u5e76\u8fdb\u884c\u63d0\u53d6\u3001\u6e05\u6d17\u548c\u6c47\u603b\u3002" },
"web-plugin": { name: "\u7f51\u9875\u4fe1\u606f\u63d0\u53d6", description: "\u6293\u53d6\u7f51\u9875\u5185\u5bb9\u5e76\u8fdb\u884c\u63d0\u53d6\u3001\u6e05\u6d17\u548c\u6c47\u603b\u3002" },
"file-tools": { name: "\u6587\u4ef6\u5de5\u5177", description: "\u6267\u884c\u6587\u4ef6\u590d\u5236\u3001\u79fb\u52a8\u548c\u5f52\u6863\u7b49\u64cd\u4f5c\u3002" },
"runtime-diagnostics": { name: "\u8fd0\u884c\u65f6\u8bca\u65ad", description: "\u67e5\u770b\u8fd0\u884c\u65f6\u4fe1\u606f\u3001\u65e5\u5fd7\u548c\u72b6\u6001\u3002" },
"browser-automation": { name: "\u7f51\u9875\u81ea\u52a8\u5316", description: "\u81ea\u52a8\u6267\u884c\u7f51\u9875\u6d4f\u89c8\u3001\u70b9\u51fb\u548c\u8868\u5355\u64cd\u4f5c\u3002" },
"browser-plugin": { name: "\u7f51\u9875\u81ea\u52a8\u5316", description: "\u81ea\u52a8\u6267\u884c\u7f51\u9875\u6d4f\u89c8\u3001\u70b9\u51fb\u548c\u8868\u5355\u64cd\u4f5c\u3002" },
"ocr-tools": { name: "OCR \u8bc6\u522b", description: "\u8bc6\u522b\u626b\u63cf\u4ef6\u548c\u56fe\u7247\u6587\u5b57\u5e76\u63d0\u53d6\u7ed3\u6784\u3002" }
}
export function getPluginTone(status: Plugin["status"]): Tone {
switch (status) {
case "included":
return "positive"
case "extension":
return "info"
default:
return "warning"
}
}
export function getPluginStatusLabel(status: Plugin["status"]): string {
switch (status) {
case "included":
return "\u5df2\u63a5\u5165"
case "extension":
return "\u6269\u5c55"
default:
return "\u4e0d\u53ef\u7528"
}
}
export function getPluginStatusDescription(status: Plugin["status"]): string {
switch (status) {
case "included":
return "\u5df2\u7ecf\u968f\u5f53\u524d\u5de5\u4f5c\u533a\u63d0\u4f9b\uff0c\u53ef\u4ee5\u76f4\u63a5\u8c03\u7528\u3002"
case "extension":
return "\u8ba1\u5212\u4f5c\u4e3a\u6269\u5c55\u80fd\u529b\u63a5\u5165\u3002"
default:
return "\u5f53\u524d\u8fd0\u884c\u73af\u5883\u6682\u672a\u63d0\u4f9b\u8be5\u80fd\u529b\u3002"
}
}
export function groupPluginsByStatus(plugins: WorkspaceSummary["plugins"] | undefined) {
const source = plugins ?? []
return {
included: source.filter((plugin) => plugin.status === "included"),
extension: source.filter((plugin) => plugin.status === "extension"),
unavailable: source.filter((plugin) => plugin.status === "unavailable")
}
}
export function getPluginCopy(plugin: Plugin) {
return pluginDisplayMap[plugin.id] ?? { name: plugin.name, description: plugin.description }
}
This diff is collapsed.
import type { ReactNode } from "react"
interface SettingsViewProps {
statusHint: ReactNode
children: ReactNode
}
export function SettingsView({ statusHint, children }: SettingsViewProps) {
return (
<div className="page-stack settings-page-stack settings-page-shell">
{statusHint}
<div className="settings-console-grid">
{children}
</div>
</div>
)
}
This diff is collapsed.
import { useState } from "react";
import type { AppConfig } from "@qjclaw/shared-types";
export interface SettingsState {
lobsterKeyDraft: string;
xhsFeishuAppIdDraft: string;
xhsFeishuAppSecretDraft: string;
xhsFeishuAppTokenDraft: string;
xhsFeishuTableIdDraft: string;
workspacePathDraft: string;
saving: boolean;
}
export function useSettingsState(config: AppConfig | null) {
const [lobsterKeyDraft, setLobsterKeyDraft] = useState("");
const [workspacePathDraft, setWorkspacePathDraft] = useState("");
const [imageModelApiKeyDraft, setImageModelApiKeyDraft] = useState("");
const [videoModelApiKeyDraft, setVideoModelApiKeyDraft] = useState("");
const [copywritingModelApiKeyDraft, setCopywritingModelApiKeyDraft] = useState("");
const [digitalHumanVolcAccessKeyDraft, setDigitalHumanVolcAccessKeyDraft] = useState("");
const [digitalHumanVolcSecretKeyDraft, setDigitalHumanVolcSecretKeyDraft] = useState("");
const [digitalHumanQiniuAccessKeyDraft, setDigitalHumanQiniuAccessKeyDraft] = useState("");
const [digitalHumanQiniuSecretKeyDraft, setDigitalHumanQiniuSecretKeyDraft] = useState("");
const [videoAnalyzerBaseUrlDraft, setVideoAnalyzerBaseUrlDraft] = useState("");
const [videoAnalyzerModelIdDraft, setVideoAnalyzerModelIdDraft] = useState("");
const [videoAnalyzerApiKeyDraft, setVideoAnalyzerApiKeyDraft] = useState("");
const [replicationBriefBaseUrlDraft, setReplicationBriefBaseUrlDraft] = useState("");
const [replicationBriefModelIdDraft, setReplicationBriefModelIdDraft] = useState("");
const [replicationBriefApiKeyDraft, setReplicationBriefApiKeyDraft] = useState("");
const [vectcutBaseUrlDraft, setVectcutBaseUrlDraft] = useState("");
const [vectcutFileBaseUrlDraft, setVectcutFileBaseUrlDraft] = useState("");
const [vectcutApiKeyDraft, setVectcutApiKeyDraft] = useState("");
const [xhsFeishuAppIdDraft, setXhsFeishuAppIdDraft] = useState("");
const [xhsFeishuAppSecretDraft, setXhsFeishuAppSecretDraft] = useState("");
const [xhsFeishuAppTokenDraft, setXhsFeishuAppTokenDraft] = useState("");
const [xhsFeishuTableIdDraft, setXhsFeishuTableIdDraft] = useState("");
const [saving, setSaving] = useState(false);
const hasPendingLobsterKey = lobsterKeyDraft.trim().length > 0;
const hasPendingXhsFeishuConfig = Boolean(
xhsFeishuAppIdDraft.trim() ||
xhsFeishuAppSecretDraft.trim() ||
xhsFeishuAppTokenDraft.trim() ||
xhsFeishuTableIdDraft.trim()
);
const hasPendingModelKeys = Boolean(
imageModelApiKeyDraft.trim()
|| videoModelApiKeyDraft.trim()
|| copywritingModelApiKeyDraft.trim()
|| digitalHumanVolcAccessKeyDraft.trim()
|| digitalHumanVolcSecretKeyDraft.trim()
|| digitalHumanQiniuAccessKeyDraft.trim()
|| digitalHumanQiniuSecretKeyDraft.trim()
|| videoAnalyzerBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.videoAnalyzer.baseUrl ?? "").trim()
|| videoAnalyzerModelIdDraft.trim() !== (config?.douyinRuntimeConfig.videoAnalyzer.modelId ?? "").trim()
|| videoAnalyzerApiKeyDraft.trim()
|| replicationBriefBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.replicationBrief.baseUrl ?? "").trim()
|| replicationBriefModelIdDraft.trim() !== (config?.douyinRuntimeConfig.replicationBrief.modelId ?? "").trim()
|| replicationBriefApiKeyDraft.trim()
|| vectcutBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.vectcut.baseUrl ?? "").trim()
|| vectcutFileBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.vectcut.fileBaseUrl ?? "").trim()
|| vectcutApiKeyDraft.trim()
);
return {
lobsterKeyDraft,
setLobsterKeyDraft,
workspacePathDraft,
setWorkspacePathDraft,
imageModelApiKeyDraft,
setImageModelApiKeyDraft,
videoModelApiKeyDraft,
setVideoModelApiKeyDraft,
copywritingModelApiKeyDraft,
setCopywritingModelApiKeyDraft,
digitalHumanVolcAccessKeyDraft,
setDigitalHumanVolcAccessKeyDraft,
digitalHumanVolcSecretKeyDraft,
setDigitalHumanVolcSecretKeyDraft,
digitalHumanQiniuAccessKeyDraft,
setDigitalHumanQiniuAccessKeyDraft,
digitalHumanQiniuSecretKeyDraft,
setDigitalHumanQiniuSecretKeyDraft,
videoAnalyzerBaseUrlDraft,
setVideoAnalyzerBaseUrlDraft,
videoAnalyzerModelIdDraft,
setVideoAnalyzerModelIdDraft,
videoAnalyzerApiKeyDraft,
setVideoAnalyzerApiKeyDraft,
replicationBriefBaseUrlDraft,
setReplicationBriefBaseUrlDraft,
replicationBriefModelIdDraft,
setReplicationBriefModelIdDraft,
replicationBriefApiKeyDraft,
setReplicationBriefApiKeyDraft,
vectcutBaseUrlDraft,
setVectcutBaseUrlDraft,
vectcutFileBaseUrlDraft,
setVectcutFileBaseUrlDraft,
vectcutApiKeyDraft,
setVectcutApiKeyDraft,
xhsFeishuAppIdDraft,
setXhsFeishuAppIdDraft,
xhsFeishuAppSecretDraft,
setXhsFeishuAppSecretDraft,
xhsFeishuAppTokenDraft,
setXhsFeishuAppTokenDraft,
xhsFeishuTableIdDraft,
setXhsFeishuTableIdDraft,
saving,
setSaving,
hasPendingLobsterKey,
hasPendingXhsFeishuConfig,
hasPendingModelKeys
};
}
This diff is collapsed.
This diff is collapsed.
import type { SessionSummary } from "@qjclaw/shared-types"
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"
interface SessionListProps {
visible: boolean
label: string
sessions: SessionSummary[]
activeSessionId: string
sessionTitles: Record<string, string>
projectActionPending: boolean
sendPhase: SendPhase
activeStreamSessionId?: string
closeLabel: string
formatSessionTitle(title: string, index: number): string
onOpenSession(sessionId: string): void
onCloseSession(sessionId: string): void
}
export function SessionList({
visible,
label,
sessions,
activeSessionId,
sessionTitles,
projectActionPending,
sendPhase,
activeStreamSessionId,
closeLabel,
formatSessionTitle,
onOpenSession,
onCloseSession
}: SessionListProps) {
if (!visible) {
return null
}
return (
<section className="sidebar-section sidebar-section-fill compact sidebar-session-section">
<div className="sidebar-section-head sidebar-section-head-subtle">
<div className="sidebar-section-copy">
<span className="sidebar-section-label">{label}</span>
</div>
</div>
<div className="sidebar-session-list">
{sessions.map((session, index) => (
<div key={session.id} className={"sidebar-session-card" + (activeSessionId === session.id ? " active" : "")}>
<button type="button" className="sidebar-session-main app-no-drag" disabled={projectActionPending} onClick={() => onOpenSession(session.id)}>
<strong>{sessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button>
{sessions.length > 1 ? (
<button
type="button"
className="sidebar-session-close app-no-drag"
aria-label={closeLabel}
title={closeLabel}
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamSessionId === session.id)}
onClick={() => onCloseSession(session.id)}
>
x
</button>
) : null}
</div>
))}
</div>
</section>
)
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
@import url("https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700&family=Exo+2:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
@import "tailwindcss";
@theme {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment