Skip to content

Commit 7fa2720

Browse files
committed
feat: 添加对话缩略导航功能
1 parent 8d1b276 commit 7fa2720

File tree

4 files changed

+333
-0
lines changed

4 files changed

+333
-0
lines changed

app/components/chat.module.scss

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,3 +1702,150 @@
17021702
max-height: 70vh;
17031703
overflow: hidden;
17041704
}
1705+
1706+
// 对话缩略导航
1707+
.chat-navigator {
1708+
position: fixed;
1709+
right: 16px;
1710+
top: 50%;
1711+
transform: translateY(-50%);
1712+
z-index: 100;
1713+
display: flex;
1714+
align-items: center;
1715+
}
1716+
1717+
// 双模型 panel 内的导航 - 使用绝对定位
1718+
.chat-navigator-in-panel {
1719+
position: absolute;
1720+
right: 8px;
1721+
top: 50%;
1722+
transform: translateY(-50%);
1723+
z-index: 10;
1724+
}
1725+
1726+
.chat-navigator-toggle {
1727+
width: 36px;
1728+
height: 36px;
1729+
border-radius: 50%;
1730+
background: var(--white);
1731+
border: var(--border-in-light);
1732+
box-shadow: var(--card-shadow);
1733+
display: flex;
1734+
align-items: center;
1735+
justify-content: center;
1736+
cursor: pointer;
1737+
transition: all 0.2s ease;
1738+
1739+
&:hover {
1740+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
1741+
transform: scale(1.05);
1742+
}
1743+
}
1744+
1745+
.chat-navigator-panel {
1746+
position: absolute;
1747+
right: 36px;
1748+
top: 50%;
1749+
transform: translateY(-50%) translateX(10px);
1750+
width: 240px;
1751+
max-height: 360px;
1752+
background: var(--white);
1753+
border: var(--border-in-light);
1754+
border-radius: 10px;
1755+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1756+
display: flex;
1757+
flex-direction: column;
1758+
opacity: 0;
1759+
visibility: hidden;
1760+
pointer-events: none;
1761+
transition: all 0.2s ease;
1762+
// 添加左侧 padding 作为鼠标移动的缓冲区
1763+
padding-left: 10px;
1764+
margin-right: -10px;
1765+
1766+
// 用伪元素创建一个不可见的桥接区域
1767+
&::before {
1768+
content: "";
1769+
position: absolute;
1770+
right: -20px;
1771+
top: 0;
1772+
width: 20px;
1773+
height: 100%;
1774+
}
1775+
}
1776+
1777+
.chat-navigator:hover .chat-navigator-panel {
1778+
opacity: 1;
1779+
visibility: visible;
1780+
pointer-events: auto;
1781+
transform: translateY(-50%) translateX(0);
1782+
}
1783+
1784+
.chat-navigator-header {
1785+
padding: 10px 14px 10px 4px;
1786+
border-bottom: var(--border-in-light);
1787+
font-weight: 600;
1788+
font-size: 13px;
1789+
color: var(--black);
1790+
flex-shrink: 0;
1791+
text-align: center;
1792+
}
1793+
1794+
.chat-navigator-list {
1795+
flex: 1;
1796+
overflow-y: auto;
1797+
padding: 6px 0;
1798+
1799+
&::-webkit-scrollbar {
1800+
width: 4px;
1801+
}
1802+
&::-webkit-scrollbar-thumb {
1803+
background: var(--bar-color);
1804+
border-radius: 2px;
1805+
}
1806+
}
1807+
1808+
.chat-navigator-item {
1809+
padding: 8px 14px 8px 4px;
1810+
cursor: pointer;
1811+
display: flex;
1812+
flex-direction: column;
1813+
gap: 2px;
1814+
transition: all 0.15s ease;
1815+
border-left: 2px solid transparent;
1816+
1817+
&:hover {
1818+
background-color: rgba(0, 0, 0, 0.06);
1819+
}
1820+
}
1821+
1822+
.chat-navigator-item-active {
1823+
background-color: rgba(0, 0, 0, 0.04);
1824+
border-left-color: var(--primary);
1825+
1826+
.chat-navigator-item-preview {
1827+
color: var(--primary);
1828+
font-weight: 500;
1829+
}
1830+
}
1831+
1832+
.chat-navigator-item-preview {
1833+
font-size: 13px;
1834+
color: var(--black);
1835+
overflow: hidden;
1836+
text-overflow: ellipsis;
1837+
white-space: nowrap;
1838+
}
1839+
1840+
.chat-navigator-empty {
1841+
padding: 20px 20px 20px 10px;
1842+
text-align: center;
1843+
color: #999;
1844+
font-size: 13px;
1845+
}
1846+
1847+
@media (max-width: 600px) {
1848+
.chat-navigator {
1849+
display: none;
1850+
}
1851+
}

app/components/chat.tsx

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import AttachmentIcon from "../icons/paperclip.svg";
6262
import ToolboxIcon from "../icons/toolbox.svg";
6363
import EraserIcon from "../icons/eraser.svg";
6464
import DualModelIcon from "../icons/dual-model.svg";
65+
import ConfigIcon from "../icons/config.svg";
6566

6667
import {
6768
ChatMessage,
@@ -1902,6 +1903,14 @@ function DualModelView(props: {
19021903
const secondaryVirtuosoRef = useRef<VirtuosoHandle>(null);
19031904
const [primaryHitBottom, setPrimaryHitBottom] = useState(true);
19041905
const [secondaryHitBottom, setSecondaryHitBottom] = useState(true);
1906+
const [primaryVisibleRange, setPrimaryVisibleRange] = useState<{
1907+
startIndex: number;
1908+
endIndex: number;
1909+
} | null>(null);
1910+
const [secondaryVisibleRange, setSecondaryVisibleRange] = useState<{
1911+
startIndex: number;
1912+
endIndex: number;
1913+
} | null>(null);
19051914

19061915
// 合并 context 和消息
19071916
const primaryRenderMessages: RenderMessageType[] = useMemo(() => {
@@ -1976,6 +1985,26 @@ function DualModelView(props: {
19761985
props.onScrollBothToBottom?.(scrollBothToBottom);
19771986
}, [scrollBothToBottom, props.onScrollBothToBottom]);
19781987

1988+
// 计算当前可视范围对应的用户消息索引
1989+
const getCurrentUserIndex = useCallback(
1990+
(
1991+
visibleRange: { startIndex: number; endIndex: number } | null,
1992+
msgs: RenderMessageType[],
1993+
) => {
1994+
if (!visibleRange) return null;
1995+
const midIndex = Math.floor(
1996+
(visibleRange.startIndex + visibleRange.endIndex) / 2,
1997+
);
1998+
for (let i = midIndex; i >= 0; i--) {
1999+
if (msgs[i]?.role === "user") {
2000+
return i;
2001+
}
2002+
}
2003+
return null;
2004+
},
2005+
[],
2006+
);
2007+
19792008
return (
19802009
<div className={styles["dual-model-container"]}>
19812010
{/* 主模型面板 */}
@@ -2010,6 +2039,7 @@ function DualModelView(props: {
20102039
atBottomThreshold={64}
20112040
increaseViewportBy={{ top: 400, bottom: 800 }}
20122041
computeItemKey={(index, m) => `primary-${m.id || index}`}
2042+
rangeChanged={(range) => setPrimaryVisibleRange(range)}
20132043
itemContent={(index, message) =>
20142044
props.renderMessage(message, index, false)
20152045
}
@@ -2022,6 +2052,21 @@ function DualModelView(props: {
20222052
<BottomIcon />
20232053
</div>
20242054
)}
2055+
<ChatNavigator
2056+
messages={primaryRenderMessages}
2057+
currentIndex={getCurrentUserIndex(
2058+
primaryVisibleRange,
2059+
primaryRenderMessages,
2060+
)}
2061+
onJumpTo={(index) => {
2062+
primaryVirtuosoRef.current?.scrollToIndex({
2063+
index,
2064+
align: "start",
2065+
behavior: "auto",
2066+
});
2067+
}}
2068+
inPanel
2069+
/>
20252070
</div>
20262071
</div>
20272072

@@ -2060,6 +2105,7 @@ function DualModelView(props: {
20602105
atBottomThreshold={64}
20612106
increaseViewportBy={{ top: 400, bottom: 800 }}
20622107
computeItemKey={(index, m) => `secondary-${m.id || index}`}
2108+
rangeChanged={(range) => setSecondaryVisibleRange(range)}
20632109
itemContent={(index, message) =>
20642110
props.renderMessage(message, index, true)
20652111
}
@@ -2072,6 +2118,100 @@ function DualModelView(props: {
20722118
<BottomIcon />
20732119
</div>
20742120
)}
2121+
<ChatNavigator
2122+
messages={secondaryRenderMessages}
2123+
currentIndex={getCurrentUserIndex(
2124+
secondaryVisibleRange,
2125+
secondaryRenderMessages,
2126+
)}
2127+
onJumpTo={(index) => {
2128+
secondaryVirtuosoRef.current?.scrollToIndex({
2129+
index,
2130+
align: "start",
2131+
behavior: "auto",
2132+
});
2133+
}}
2134+
inPanel
2135+
/>
2136+
</div>
2137+
</div>
2138+
</div>
2139+
);
2140+
}
2141+
2142+
// 对话缩略导航组件
2143+
function ChatNavigator(props: {
2144+
messages: ChatMessage[];
2145+
currentIndex: number | null;
2146+
onJumpTo: (index: number) => void;
2147+
inPanel?: boolean; // 是否在双模型 panel 内
2148+
}) {
2149+
const PREVIEW_LENGTH = 20;
2150+
const listRef = useRef<HTMLDivElement>(null);
2151+
const activeItemRef = useRef<HTMLDivElement>(null);
2152+
2153+
// 过滤用户消息并生成缩略列表
2154+
const userMessages = useMemo(() => {
2155+
return props.messages
2156+
.map((msg, index) => ({
2157+
id: msg.id,
2158+
index,
2159+
preview: getMessageTextContent(msg).slice(0, PREVIEW_LENGTH),
2160+
role: msg.role,
2161+
}))
2162+
.filter((msg) => msg.role === "user");
2163+
}, [props.messages]);
2164+
2165+
// 当 hover 面板时,滚动到当前高亮项
2166+
const scrollToActiveItem = useCallback(() => {
2167+
if (activeItemRef.current && listRef.current) {
2168+
activeItemRef.current.scrollIntoView({
2169+
block: "center",
2170+
behavior: "auto",
2171+
});
2172+
}
2173+
}, []);
2174+
2175+
return (
2176+
<div
2177+
className={clsx(
2178+
styles["chat-navigator"],
2179+
props.inPanel && styles["chat-navigator-in-panel"],
2180+
)}
2181+
onMouseEnter={scrollToActiveItem}
2182+
>
2183+
<div className={styles["chat-navigator-toggle"]}>
2184+
<ConfigIcon />
2185+
</div>
2186+
<div className={styles["chat-navigator-panel"]}>
2187+
<div className={styles["chat-navigator-header"]}>
2188+
{Locale.Chat.Navigator.Title}
2189+
</div>
2190+
<div className={styles["chat-navigator-list"]} ref={listRef}>
2191+
{userMessages.length === 0 ? (
2192+
<div className={styles["chat-navigator-empty"]}>
2193+
{Locale.Chat.Navigator.Empty}
2194+
</div>
2195+
) : (
2196+
userMessages.map((item) => {
2197+
const isActive = props.currentIndex === item.index;
2198+
return (
2199+
<div
2200+
key={item.id}
2201+
ref={isActive ? activeItemRef : null}
2202+
className={clsx(
2203+
styles["chat-navigator-item"],
2204+
isActive && styles["chat-navigator-item-active"],
2205+
)}
2206+
onClick={() => props.onJumpTo(item.index)}
2207+
>
2208+
<div className={styles["chat-navigator-item-preview"]}>
2209+
{item.preview || "(空消息)"}
2210+
</div>
2211+
</div>
2212+
);
2213+
})
2214+
)}
20752215
</div>
20762216
</div>
20772217
</div>
@@ -3453,6 +3593,10 @@ function ChatComponent() {
34533593
? Math.max(0, Math.min(location.state.jumpToIndex, messages.length - 1))
34543594
: undefined;
34553595
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
3596+
const [visibleRange, setVisibleRange] = useState<{
3597+
startIndex: number;
3598+
endIndex: number;
3599+
} | null>(null);
34563600

34573601
// 跳转到引用的消息并高亮具体文本
34583602
const scrollToQuotedMessage = useCallback(
@@ -4976,6 +5120,7 @@ function ChatComponent() {
49765120
atBottomThreshold={64}
49775121
increaseViewportBy={{ top: 400, bottom: 800 }}
49785122
computeItemKey={(index, m) => m.id}
5123+
rangeChanged={(range) => setVisibleRange(range)}
49795124
itemContent={(index, message) => {
49805125
const i = index;
49815126
const isUser = message.role === "user";
@@ -5348,6 +5493,39 @@ function ChatComponent() {
53485493
}}
53495494
/>
53505495
)}
5496+
5497+
{/* 对话缩略导航 - 仅在非双模型模式下显示 */}
5498+
{!isDualMode && (
5499+
<ChatNavigator
5500+
messages={messages}
5501+
currentIndex={
5502+
// 计算当前可视范围中心的消息,如果是 assistant 则归属到其对应的 user
5503+
visibleRange
5504+
? (() => {
5505+
const midIndex = Math.floor(
5506+
(visibleRange.startIndex + visibleRange.endIndex) / 2,
5507+
);
5508+
// 从中间位置向前找到最近的 user 消息
5509+
for (let i = midIndex; i >= 0; i--) {
5510+
if (messages[i]?.role === "user") {
5511+
return i;
5512+
}
5513+
}
5514+
return null;
5515+
})()
5516+
: null
5517+
}
5518+
onJumpTo={(index) => {
5519+
virtuosoRef.current?.scrollToIndex({
5520+
index,
5521+
align: "start",
5522+
behavior: "auto",
5523+
});
5524+
setHighlightIndex(index);
5525+
setTimeout(() => setHighlightIndex(null), 3000);
5526+
}}
5527+
/>
5528+
)}
53515529
</div>
53525530
<div className={styles["chat-input-panel"]}>
53535531
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />

0 commit comments

Comments
 (0)